Restrict admin to system management

This commit is contained in:
2026-08-06 08:07:26 +02:00
parent 0bef65ab75
commit 19e4621aeb
13 changed files with 634 additions and 882 deletions
+9
View File
@@ -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
View File
@@ -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(
`
+3
View File
@@ -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,