Restrict admin to system management
This commit is contained in:
@@ -155,6 +155,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireUser(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.user?.role !== "user") {
|
||||
res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function currentUser(req: Request) {
|
||||
if (!req.user) {
|
||||
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
|
||||
|
||||
+264
-294
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "./migrations.js";
|
||||
import { pool, query, withTransaction } from "./db.js";
|
||||
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, verifyPassword, type UserRole } from "./auth.js";
|
||||
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
|
||||
import {
|
||||
badRequest,
|
||||
parseDay,
|
||||
@@ -84,14 +84,6 @@ function parseOptionalBilledMinutes(value: unknown) {
|
||||
return minutes;
|
||||
}
|
||||
|
||||
function parseUserRole(value: unknown) {
|
||||
if (value !== "admin" && value !== "user") {
|
||||
throw badRequest("role must be admin or user");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseZammadBaseUrl(value: unknown) {
|
||||
const raw = requireString(value, "baseUrl").replace(/\/+$/, "");
|
||||
|
||||
@@ -1178,7 +1170,7 @@ app.post("/api/auth/login", async (req, res) => {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
role: "admin" | "user";
|
||||
active: boolean;
|
||||
password_hash: string;
|
||||
}>(
|
||||
@@ -1295,7 +1287,6 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
|
||||
const username = parseUsername(req.body.username);
|
||||
const displayName = requireString(req.body.displayName, "displayName");
|
||||
const password = requireString(req.body.password, "password");
|
||||
const role = parseUserRole(req.body.role ?? "user");
|
||||
const active = optionalBoolean(req.body.active, true);
|
||||
|
||||
if (password.length < 6) {
|
||||
@@ -1309,7 +1300,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, display_name, role, active, created_at, updated_at;
|
||||
`,
|
||||
[username, displayName, await hashPassword(password), role, active]
|
||||
[username, displayName, await hashPassword(password), "user", active]
|
||||
);
|
||||
|
||||
res.status(201).json({ user: result.rows[0] });
|
||||
@@ -1332,7 +1323,6 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
|
||||
const userId = parsePositiveInteger(req.params.userId, "userId");
|
||||
const username = parseUsername(req.body.username);
|
||||
const displayName = requireString(req.body.displayName, "displayName");
|
||||
const role = parseUserRole(req.body.role);
|
||||
const active = optionalBoolean(req.body.active, true);
|
||||
const password = optionalString(req.body.password);
|
||||
|
||||
@@ -1340,8 +1330,21 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
|
||||
throw badRequest("password must be at least 6 characters");
|
||||
}
|
||||
|
||||
if (currentUser(req).id === String(userId) && (!active || role !== "admin")) {
|
||||
res.status(409).json({ error: "Du kannst deinen eigenen Admin-Zugang nicht deaktivieren oder herabstufen" });
|
||||
const existingResult = await query<{ id: string; role: "admin" | "user" }>("SELECT id, role FROM users WHERE id = $1;", [userId]);
|
||||
const existingUser = existingResult.rows[0] ?? null;
|
||||
|
||||
if (!existingUser) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingUser.role === "admin" && currentUser(req).id !== String(userId)) {
|
||||
res.status(409).json({ error: "Der feste Admin kann nicht über die Benutzerliste bearbeitet werden" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingUser.role === "admin" && !active) {
|
||||
res.status(409).json({ error: "Der feste Admin kann nicht deaktiviert werden" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1351,14 +1354,14 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
|
||||
UPDATE users
|
||||
SET username = $1,
|
||||
display_name = $2,
|
||||
role = $3,
|
||||
active = $4,
|
||||
password_hash = COALESCE($5, password_hash),
|
||||
role = role,
|
||||
active = $3,
|
||||
password_hash = COALESCE($4, password_hash),
|
||||
updated_at = now()
|
||||
WHERE id = $6
|
||||
WHERE id = $5
|
||||
RETURNING id, username, display_name, role, active, created_at, updated_at;
|
||||
`,
|
||||
[username, displayName, role, active, password ? await hashPassword(password) : null, userId]
|
||||
[username, displayName, active, password ? await hashPassword(password) : null, userId]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
@@ -1588,6 +1591,202 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res)
|
||||
});
|
||||
});
|
||||
|
||||
const databaseExportTables = [
|
||||
"users",
|
||||
"auth_sessions",
|
||||
"app_settings",
|
||||
"organizations",
|
||||
"tickets",
|
||||
"recurring_billings",
|
||||
"recurring_billing_slots",
|
||||
"recurring_billing_exceptions",
|
||||
"sessions",
|
||||
"ticket_month_closures",
|
||||
"month_closures",
|
||||
"ticket_day_closures",
|
||||
"day_closures",
|
||||
"ticket_day_billings",
|
||||
"ticket_day_billing_acknowledgements"
|
||||
] as const;
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function quoteLiteral(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return "NULL";
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `'${value.toISOString().replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return `decode('${value.toString("hex")}', 'hex')`;
|
||||
}
|
||||
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
async function exportTableSql(table: string) {
|
||||
const columnsResult = await query<{ column_name: string }>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
ORDER BY ordinal_position ASC;
|
||||
`,
|
||||
[table]
|
||||
);
|
||||
const columns = columnsResult.rows.map((row) => row.column_name);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`];
|
||||
}
|
||||
|
||||
const rowsResult = await query<Record<string, unknown>>(
|
||||
`
|
||||
SELECT ${columns.map(quoteIdentifier).join(", ")}
|
||||
FROM ${quoteIdentifier(table)}
|
||||
ORDER BY ${columns.map(quoteIdentifier).join(", ")};
|
||||
`
|
||||
);
|
||||
|
||||
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`];
|
||||
|
||||
for (const row of rowsResult.rows) {
|
||||
lines.push(
|
||||
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${columns.map((column) => quoteLiteral(row[column])).join(", ")});`
|
||||
);
|
||||
}
|
||||
|
||||
if (columns.includes("id")) {
|
||||
lines.push(
|
||||
`SELECT setval(pg_get_serial_sequence(${quoteLiteral(table)}, 'id'), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 1), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 0) > 0);`
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
|
||||
const lines = [
|
||||
"-- TicketTracker Datenbankexport",
|
||||
`-- Erstellt: ${new Date().toISOString()}`,
|
||||
"-- Restore: frische TicketTracker-Datenbank migrieren lassen, danach diese Datei mit psql einspielen.",
|
||||
"BEGIN;",
|
||||
`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`
|
||||
];
|
||||
|
||||
for (const table of databaseExportTables) {
|
||||
lines.push("", ...(await exportTableSql(table)));
|
||||
}
|
||||
|
||||
lines.push("", "COMMIT;", "");
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
res.setHeader("Content-Type", "application/sql; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`);
|
||||
res.send(lines.join("\n"));
|
||||
});
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const text = String(value);
|
||||
|
||||
if (/[;"\n\r]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
app.get("/api/export/months/:month", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
const user = currentUser(req);
|
||||
|
||||
await ensureRecurringSessionsForUserPeriod(user.id, month.startIso, month.endIso);
|
||||
|
||||
const result = await query<{
|
||||
ticket_number: string;
|
||||
organization_name: string;
|
||||
work_type: string;
|
||||
day: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
rounded_minutes: number;
|
||||
billing_status: string | null;
|
||||
teamspace_minutes: number | null;
|
||||
source: string;
|
||||
activity: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
t.ticket_number,
|
||||
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, '') AS organization_name,
|
||||
s.work_type,
|
||||
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS day,
|
||||
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS start_time,
|
||||
to_char(s.ended_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS end_time,
|
||||
s.rounded_minutes,
|
||||
s.billing_status,
|
||||
tdb.billed_minutes AS teamspace_minutes,
|
||||
CASE WHEN s.recurring_billing_id IS NULL THEN 'manuell' ELSE 'fix' END AS source,
|
||||
s.activity
|
||||
FROM sessions s
|
||||
JOIN tickets t ON t.id = s.ticket_id
|
||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
||||
LEFT JOIN organizations ot ON ot.id = t.organization_id
|
||||
LEFT JOIN ticket_day_billings tdb
|
||||
ON tdb.ticket_id = s.ticket_id
|
||||
AND tdb.user_id = s.user_id
|
||||
AND tdb.day = (s.started_at AT TIME ZONE 'Europe/Berlin')::date
|
||||
WHERE s.started_at >= $1::timestamptz
|
||||
AND s.started_at < $2::timestamptz
|
||||
AND s.user_id = $3
|
||||
ORDER BY s.started_at ASC, t.ticket_number ASC, s.id ASC;
|
||||
`,
|
||||
[month.startIso, month.endIso, user.id]
|
||||
);
|
||||
|
||||
const header = [
|
||||
"Ticket",
|
||||
"Organisation",
|
||||
"Art",
|
||||
"Datum",
|
||||
"Von",
|
||||
"Bis",
|
||||
"Session-Minuten",
|
||||
"Bewertung",
|
||||
"Teamspace-Minuten",
|
||||
"Quelle",
|
||||
"Taetigkeit"
|
||||
];
|
||||
const rows = result.rows.map((row) => [
|
||||
row.ticket_number,
|
||||
row.organization_name,
|
||||
row.work_type === "support" ? "Support" : "Consulting",
|
||||
row.day,
|
||||
row.start_time,
|
||||
row.end_time,
|
||||
row.rounded_minutes,
|
||||
row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen",
|
||||
row.teamspace_minutes ?? "",
|
||||
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
|
||||
row.activity
|
||||
]);
|
||||
const csv = [`\uFEFF${header.map(csvCell).join(";")}`, ...rows.map((row) => row.map(csvCell).join(";"))].join("\n");
|
||||
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-${user.username}-${month.label}.csv"`);
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
function parseRecurrenceType(value: unknown) {
|
||||
if (value !== "weekly" && value !== "every_n_weeks") {
|
||||
throw badRequest("recurrenceType must be weekly or every_n_weeks");
|
||||
@@ -1722,11 +1921,11 @@ async function recurringBillingResponse(userId: string) {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
app.get("/api/recurring-billings", async (req, res) => {
|
||||
app.get("/api/recurring-billings", requireUser, async (req, res) => {
|
||||
res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) });
|
||||
});
|
||||
|
||||
app.post("/api/recurring-billings", async (req, res) => {
|
||||
app.post("/api/recurring-billings", requireUser, async (req, res) => {
|
||||
const userId = currentUser(req).id;
|
||||
const payload = parseRecurringBillingPayload(req.body);
|
||||
|
||||
@@ -1789,7 +1988,7 @@ app.post("/api/recurring-billings", async (req, res) => {
|
||||
res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) });
|
||||
});
|
||||
|
||||
app.patch("/api/recurring-billings/:billingId", async (req, res) => {
|
||||
app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
|
||||
const billingId = parsePositiveInteger(req.params.billingId, "billingId");
|
||||
const userId = currentUser(req).id;
|
||||
const active = optionalBoolean(req.body.active, true);
|
||||
@@ -2045,7 +2244,7 @@ app.patch("/api/recurring-billings/:billingId", async (req, res) => {
|
||||
res.json({ recurringBillings: await recurringBillingResponse(userId) });
|
||||
});
|
||||
|
||||
app.delete("/api/recurring-billings/:billingId", async (req, res) => {
|
||||
app.delete("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
|
||||
const billingId = parsePositiveInteger(req.params.billingId, "billingId");
|
||||
const userId = currentUser(req).id;
|
||||
const result = await withTransaction(async (client) => {
|
||||
@@ -2097,247 +2296,18 @@ app.delete("/api/recurring-billings/:billingId", async (req, res) => {
|
||||
});
|
||||
|
||||
app.get("/api/admin/sessions", requireAdmin, async (_req, res) => {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.ticket_id,
|
||||
s.user_id,
|
||||
owner.username AS owner_username,
|
||||
owner.display_name AS owner_display_name,
|
||||
t.ticket_number,
|
||||
s.organization_id,
|
||||
so.name AS organization_name,
|
||||
COALESCE(so.name, s.customer_name) AS customer_name,
|
||||
s.activity,
|
||||
s.work_type,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.duration_seconds,
|
||||
s.rounded_minutes,
|
||||
s.billing_status,
|
||||
s.created_at,
|
||||
s.recurring_billing_id,
|
||||
s.recurring_billing_slot_id,
|
||||
s.recurring_occurrence_date
|
||||
FROM sessions s
|
||||
JOIN tickets t ON t.id = s.ticket_id
|
||||
JOIN users owner ON owner.id = s.user_id
|
||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT 500;
|
||||
`
|
||||
);
|
||||
|
||||
res.json({ sessions: result.rows });
|
||||
res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
|
||||
});
|
||||
|
||||
app.post("/api/admin/sessions", requireAdmin, async (req, res) => {
|
||||
const userId = parsePositiveInteger(req.body.userId, "userId");
|
||||
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
|
||||
const organizationId = parseOrganizationId(req.body.organizationId);
|
||||
const activity = requireString(req.body.activity, "activity");
|
||||
const workType = parseWorkType(req.body.workType);
|
||||
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
|
||||
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
|
||||
|
||||
if (endedAt <= startedAt) {
|
||||
throw badRequest("endedAt must be after startedAt");
|
||||
}
|
||||
|
||||
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||
|
||||
const created = await withTransaction(async (client) => {
|
||||
const userResult = await client.query<{ id: string }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
AND active = true;
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const organization = await getOrganizationById(client, organizationId);
|
||||
|
||||
if (!organization) {
|
||||
return "organization-not-found" as const;
|
||||
}
|
||||
|
||||
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
|
||||
`
|
||||
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (ticket_number)
|
||||
DO UPDATE SET
|
||||
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
|
||||
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
|
||||
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
|
||||
RETURNING id, ticket_number, organization_id, customer_name, work_type;
|
||||
`,
|
||||
[ticketNumber, organization.id, organization.name, workType]
|
||||
);
|
||||
|
||||
const sessionResult = await client.query<{ id: string }>(
|
||||
`
|
||||
INSERT INTO sessions (
|
||||
ticket_id,
|
||||
organization_id,
|
||||
customer_name,
|
||||
activity,
|
||||
work_type,
|
||||
user_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_seconds,
|
||||
rounded_minutes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id;
|
||||
`,
|
||||
[
|
||||
ticketResult.rows[0].id,
|
||||
organization.id,
|
||||
organization.name,
|
||||
activity,
|
||||
workType,
|
||||
userId,
|
||||
startedAt.toISOString(),
|
||||
endedAt.toISOString(),
|
||||
durationSeconds,
|
||||
roundedMinutes
|
||||
]
|
||||
);
|
||||
|
||||
await reopenPeriodsForSession(client, ticketResult.rows[0].id, startedAt, String(userId));
|
||||
|
||||
return sessionResult.rows[0].id;
|
||||
});
|
||||
|
||||
if (created === "organization-not-found") {
|
||||
res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.ticket_id,
|
||||
s.user_id,
|
||||
owner.username AS owner_username,
|
||||
owner.display_name AS owner_display_name,
|
||||
t.ticket_number,
|
||||
s.organization_id,
|
||||
so.name AS organization_name,
|
||||
COALESCE(so.name, s.customer_name) AS customer_name,
|
||||
s.activity,
|
||||
s.work_type,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.duration_seconds,
|
||||
s.rounded_minutes,
|
||||
s.billing_status,
|
||||
s.created_at,
|
||||
s.recurring_billing_id,
|
||||
s.recurring_billing_slot_id,
|
||||
s.recurring_occurrence_date
|
||||
FROM sessions s
|
||||
JOIN tickets t ON t.id = s.ticket_id
|
||||
JOIN users owner ON owner.id = s.user_id
|
||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
||||
WHERE s.id = $1;
|
||||
`,
|
||||
[created]
|
||||
);
|
||||
|
||||
res.status(201).json({ session: result.rows[0] });
|
||||
app.post("/api/admin/sessions", requireAdmin, async (_req, res) => {
|
||||
res.status(410).json({ error: "Der feste Admin erfasst keine Sessions" });
|
||||
});
|
||||
|
||||
app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (req, res) => {
|
||||
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
|
||||
const nextUserId = parsePositiveInteger(req.body.userId, "userId");
|
||||
|
||||
const reassigned = await withTransaction(async (client) => {
|
||||
const targetResult = await client.query<{ id: string }>(
|
||||
`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
AND active = true;
|
||||
`,
|
||||
[nextUserId]
|
||||
);
|
||||
|
||||
if (targetResult.rows.length === 0) {
|
||||
return { status: "target-not-found" as const };
|
||||
}
|
||||
|
||||
const currentResult = await client.query<{
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
started_at: string;
|
||||
}>(
|
||||
`
|
||||
SELECT id, ticket_id, user_id, started_at
|
||||
FROM sessions
|
||||
WHERE id = $1
|
||||
FOR UPDATE;
|
||||
`,
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
if (currentResult.rows.length === 0) {
|
||||
return { status: "session-not-found" as const };
|
||||
}
|
||||
|
||||
const session = currentResult.rows[0];
|
||||
|
||||
if (session.user_id === String(nextUserId)) {
|
||||
return { status: "ok" as const, session };
|
||||
}
|
||||
|
||||
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), session.user_id);
|
||||
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), String(nextUserId));
|
||||
|
||||
const updatedResult = await client.query(
|
||||
`
|
||||
UPDATE sessions
|
||||
SET user_id = $1
|
||||
WHERE id = $2
|
||||
RETURNING *;
|
||||
`,
|
||||
[nextUserId, sessionId]
|
||||
);
|
||||
|
||||
return { status: "ok" as const, session: updatedResult.rows[0] };
|
||||
});
|
||||
|
||||
if (reassigned.status === "target-not-found") {
|
||||
res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (reassigned.status === "session-not-found") {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ session: reassigned.session });
|
||||
app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res) => {
|
||||
res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
|
||||
});
|
||||
|
||||
app.post("/api/sessions", async (req, res) => {
|
||||
app.post("/api/sessions", requireUser, async (req, res) => {
|
||||
const user = currentUser(req);
|
||||
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
|
||||
const activity = requireString(req.body.activity, "activity");
|
||||
@@ -2438,17 +2408,17 @@ app.post("/api/sessions", async (req, res) => {
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
app.get("/api/periods/:periodType/:period/overview", async (req, res) => {
|
||||
const { config, period } = parsePeriod(req.params.periodType, req.params.period);
|
||||
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
|
||||
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
res.json(await getPeriodOverview(config, period, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/statistics/months/:month", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
res.json(await getStatisticsOverview(month, currentUser(req).id));
|
||||
});
|
||||
|
||||
app.get("/api/tickets/lookup", async (req, res) => {
|
||||
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||
const result = await query(
|
||||
`
|
||||
@@ -2471,7 +2441,7 @@ app.get("/api/tickets/lookup", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.patch("/api/tickets/:ticketId", async (req, res) => {
|
||||
app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
|
||||
const organizationId = parseOrganizationId(req.body.organizationId);
|
||||
@@ -2537,8 +2507,8 @@ app.patch("/api/tickets/:ticketId", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) => {
|
||||
const { config, period } = parsePeriod(req.params.periodType, req.params.period);
|
||||
app.get("/api/periods/:periodType/:period/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const result = await getPeriodTicket(config, period, ticketId, currentUser(req).id);
|
||||
|
||||
@@ -2550,9 +2520,9 @@ app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) =
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => {
|
||||
app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const day = parseDay(req.params.day);
|
||||
const day = parseDay(String(req.params.day));
|
||||
const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes);
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
@@ -2612,9 +2582,9 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => {
|
||||
res.json({ dayBilling: result.rows[0] });
|
||||
});
|
||||
|
||||
app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req, res) => {
|
||||
app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", requireUser, async (req, res) => {
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
const day = parseDay(req.params.day);
|
||||
const day = parseDay(String(req.params.day));
|
||||
const acknowledged = req.body.acknowledged !== false;
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
@@ -2664,20 +2634,20 @@ app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req
|
||||
res.json({ acknowledged: true, acknowledgement: result.rows[0] });
|
||||
});
|
||||
|
||||
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => {
|
||||
parsePeriod(req.params.periodType, req.params.period);
|
||||
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", requireUser, async (req, res) => {
|
||||
parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte Sessions bewerten und den Monat abschließen." });
|
||||
});
|
||||
|
||||
app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", async (req, res) => {
|
||||
parsePeriod(req.params.periodType, req.params.period);
|
||||
app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", requireUser, async (req, res) => {
|
||||
parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte den Monat öffnen." });
|
||||
});
|
||||
|
||||
app.post("/api/periods/:periodType/:period/close", async (req, res) => {
|
||||
const { config, period } = parsePeriod(req.params.periodType, req.params.period);
|
||||
app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) => {
|
||||
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
if (config.type !== "month") {
|
||||
@@ -2727,8 +2697,8 @@ app.post("/api/periods/:periodType/:period/close", async (req, res) => {
|
||||
res.json({ closure: result.rows[0] });
|
||||
});
|
||||
|
||||
app.post("/api/periods/:periodType/:period/reopen", async (req, res) => {
|
||||
const { config, period } = parsePeriod(req.params.periodType, req.params.period);
|
||||
app.post("/api/periods/:periodType/:period/reopen", requireUser, async (req, res) => {
|
||||
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
if (config.type !== "month") {
|
||||
@@ -2752,8 +2722,8 @@ app.use("/api/months", (_req, res) => {
|
||||
res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." });
|
||||
});
|
||||
|
||||
app.get("/api/months/:month/overview", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.get("/api/months/:month/overview", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
|
||||
const [ticketsResult, openResult, monthClosureResult] = await Promise.all([
|
||||
query(
|
||||
@@ -2824,8 +2794,8 @@ app.get("/api/months/:month/overview", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/months/:month/tickets/:ticketId", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
|
||||
const ticketResult = await query(
|
||||
@@ -2887,7 +2857,7 @@ app.get("/api/months/:month/tickets/:ticketId", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.patch("/api/sessions/:sessionId/billing", async (req, res) => {
|
||||
app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
|
||||
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
|
||||
const billingStatus = parseBillingStatus(req.body.billingStatus);
|
||||
const userId = currentUser(req).id;
|
||||
@@ -2949,7 +2919,7 @@ app.patch("/api/sessions/:sessionId/billing", async (req, res) => {
|
||||
res.json({ session: result.rows[0] });
|
||||
});
|
||||
|
||||
app.patch("/api/sessions/:sessionId/details", async (req, res) => {
|
||||
app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
||||
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
|
||||
const userId = currentUser(req).id;
|
||||
const organizationId = parseOrganizationId(req.body.organizationId);
|
||||
@@ -3042,7 +3012,7 @@ app.patch("/api/sessions/:sessionId/details", async (req, res) => {
|
||||
res.json({ session: updated });
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:sessionId", async (req, res) => {
|
||||
app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
|
||||
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
|
||||
const userId = currentUser(req).id;
|
||||
|
||||
@@ -3125,8 +3095,8 @@ app.delete("/api/sessions/:sessionId", async (req, res) => {
|
||||
res.json({ deleted });
|
||||
});
|
||||
|
||||
app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
|
||||
const statusResult = await query(
|
||||
@@ -3168,8 +3138,8 @@ app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => {
|
||||
res.json({ closure: closureResult.rows[0] });
|
||||
});
|
||||
|
||||
app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.post("/api/months/:month/tickets/:ticketId/reopen", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
@@ -3200,8 +3170,8 @@ app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.post("/api/months/:month/close", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.post("/api/months/:month/close", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
|
||||
const blockersResult = await query(
|
||||
`
|
||||
@@ -3244,8 +3214,8 @@ app.post("/api/months/:month/close", async (req, res) => {
|
||||
res.json({ closure: result.rows[0] });
|
||||
});
|
||||
|
||||
app.post("/api/months/:month/reopen", async (req, res) => {
|
||||
const month = parseMonth(req.params.month);
|
||||
app.post("/api/months/:month/reopen", requireUser, async (req, res) => {
|
||||
const month = parseMonth(String(req.params.month));
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
|
||||
@@ -76,6 +76,9 @@ export async function migrate() {
|
||||
|
||||
const adminId = await ensureMigrationAdmin();
|
||||
|
||||
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
|
||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';");
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user