import "dotenv/config"; import cors from "cors"; import express from "express"; 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, requireUser, verifyPassword } from "./auth.js"; import { badRequest, parseDay, parseBillingStatus, parseIsoDate, parseMonth, parsePositiveInteger, parseTicketNumber, parseTrackableTicketNumber, parseUsername, parseWorkType, requireString } from "./validation.js"; const app = express(); const port = Number(process.env.PORT ?? 3000); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const serveFrontend = process.env.SERVE_FRONTEND !== "false"; app.use(cors()); app.use(express.json({ limit: "1mb" })); app.get("/api/health", async (_req, res) => { await query("SELECT 1"); res.json({ ok: true }); }); type PeriodConfig = { type: "month" | "day"; tablePrefix: "month" | "day"; closureTable: "month_closures" | "day_closures"; ticketClosureTable: "ticket_month_closures" | "ticket_day_closures"; column: "month" | "day"; }; type ParsedPeriod = { label: string; start: string; startIso: string; endIso: string; }; function optionalString(value: unknown) { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function optionalWorkType(value: unknown) { if (value === undefined || value === null || value === "") { return null; } return parseWorkType(value); } function parseOrganizationId(value: unknown) { return parsePositiveInteger(value, "organizationId"); } function parseOptionalBilledMinutes(value: unknown) { if (value === null || value === undefined || value === "") { return null; } const minutes = Number(value); if (!Number.isInteger(minutes) || minutes < 0) { throw badRequest("billedMinutes must be a non-negative integer or null"); } return minutes; } function parseZammadBaseUrl(value: unknown) { const raw = requireString(value, "baseUrl").replace(/\/+$/, ""); try { const url = new URL(raw); if (url.protocol !== "https:" && url.protocol !== "http:") { throw badRequest("baseUrl must use http or https"); } return url.toString().replace(/\/+$/, ""); } catch { throw badRequest("baseUrl must be a valid URL"); } } type OrganizationRow = { id: string; zammad_id: string; name: string; synced_at: string; }; const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; async function getAppSetting(key: string) { const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); return result.rows[0]?.value ?? null; } async function setAppSetting(key: string, value: string) { await query( ` INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(); `, [key, value] ); } async function getOrganizationById( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, organizationId: number ) { const result = await client.query( ` SELECT id, zammad_id, name, synced_at FROM organizations WHERE id = $1; `, [organizationId] ); return result.rows[0] ?? null; } function optionalBoolean(value: unknown, fallback: boolean) { if (value === undefined || value === null) { return fallback; } if (typeof value !== "boolean") { throw badRequest("active must be boolean"); } return value; } const periodConfigs: Record = { months: { type: "month", tablePrefix: "month", closureTable: "month_closures", ticketClosureTable: "ticket_month_closures", column: "month" }, days: { type: "day", tablePrefix: "day", closureTable: "day_closures", ticketClosureTable: "ticket_day_closures", column: "day" } }; function parsePeriod(periodType: string, value: string) { const config = periodConfigs[periodType]; if (!config) { throw badRequest("period type must be months or days"); } const period = config.type === "month" ? parseMonth(value) : parseDay(value); return { config, period }; } function ticketClosureMonthForPeriod(period: ParsedPeriod) { return parseMonth(period.label.slice(0, 7)); } function periodStartForDate(date: Date) { const dayStart = date.toISOString().slice(0, 10); const monthStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)).toISOString().slice(0, 10); return { dayStart, monthStart }; } function dateOnly(value: Date) { return value.toISOString().slice(0, 10); } function addDays(value: Date, days: number) { const next = new Date(value); next.setUTCDate(next.getUTCDate() + days); return next; } function parseClock(value: string) { if (!/^\d{2}:\d{2}$/.test(value)) { throw badRequest("time must use HH:mm"); } return value; } function easterSunday(year: number) { const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31); const day = ((h + l - 7 * m + 114) % 31) + 1; return new Date(Date.UTC(year, month - 1, day)); } function bavarianHolidayName(date: Date) { const year = date.getUTCFullYear(); const monthDay = date.toISOString().slice(5, 10); const fixed: Record = { "01-01": "Neujahr", "01-06": "Heilige Drei Könige", "05-01": "Tag der Arbeit", "08-15": "Mariä Himmelfahrt", "10-03": "Tag der Deutschen Einheit", "11-01": "Allerheiligen", "12-25": "1. Weihnachtstag", "12-26": "2. Weihnachtstag" }; if (fixed[monthDay]) { return fixed[monthDay]; } if (process.env.BAVARIA_INCLUDE_AUGSBURG_HOLIDAY === "true" && monthDay === "08-08") { return "Augsburger Friedensfest"; } const easter = easterSunday(year); const movable = new Map([ [dateOnly(addDays(easter, -2)), "Karfreitag"], [dateOnly(addDays(easter, 1)), "Ostermontag"], [dateOnly(addDays(easter, 39)), "Christi Himmelfahrt"], [dateOnly(addDays(easter, 50)), "Pfingstmontag"], [dateOnly(addDays(easter, 60)), "Fronleichnam"] ]); return movable.get(dateOnly(date)) ?? null; } type RecurringBillingRow = { id: string; user_id: string; ticket_number: string; configured_ticket_number: string | null; organization_id: string; organization_name: string; activity: string; work_type: "support" | "consulting"; recurrence_type: "weekly" | "every_n_weeks"; interval_value: number; valid_from: string; valid_until: string | null; start_time: string; }; type RecurringBillingSlotRow = { id: string; recurring_billing_id: string; weekday: number | null; start_time: string | null; duration_minutes: number; }; function occursOnDate(rule: RecurringBillingRow, slot: RecurringBillingSlotRow, date: Date) { const validFrom = new Date(`${rule.valid_from}T00:00:00.000Z`); const daysSinceStart = Math.floor((date.getTime() - validFrom.getTime()) / 86_400_000); if (daysSinceStart < 0) { return false; } if (rule.valid_until && dateOnly(date) > rule.valid_until) { return false; } if (rule.recurrence_type === "every_n_weeks") { return daysSinceStart % (rule.interval_value * 7) === 0; } const weekdayMatches = slot.weekday === date.getUTCDay(); return weekdayMatches; } async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: string, endIso: string) { const rulesResult = await query( ` SELECT rb.id, rb.user_id, COALESCE(rb.ticket_number, 'Fix#' || rb.id) AS ticket_number, rb.ticket_number AS configured_ticket_number, rb.organization_id, o.name AS organization_name, rb.activity, rb.work_type, rb.recurrence_type, rb.interval_value, rb.valid_from::text, rb.valid_until::text, rb.start_time::text FROM recurring_billings rb JOIN organizations o ON o.id = rb.organization_id WHERE rb.user_id = $1 AND rb.valid_from < $3::date AND (rb.valid_until IS NULL OR rb.valid_until >= $2::date); `, [userId, startIso.slice(0, 10), endIso.slice(0, 10)] ); if (rulesResult.rows.length === 0) { return; } const ruleIds = rulesResult.rows.map((rule) => rule.id); const [slotsResult, exceptionsResult, existingResult] = await Promise.all([ query( ` SELECT id, recurring_billing_id, weekday, start_time::text, duration_minutes FROM recurring_billing_slots WHERE recurring_billing_id = ANY($1::bigint[]); `, [ruleIds] ), query<{ key: string }>( ` SELECT recurring_billing_id || ':' || recurring_billing_slot_id || ':' || occurrence_date::text AS key FROM recurring_billing_exceptions WHERE recurring_billing_id = ANY($1::bigint[]) AND occurrence_date >= $2::date AND occurrence_date < $3::date; `, [ruleIds, startIso.slice(0, 10), endIso.slice(0, 10)] ), query<{ recurring_occurrence_key: string }>( ` SELECT recurring_occurrence_key FROM sessions WHERE recurring_occurrence_key IS NOT NULL AND recurring_billing_id = ANY($1::bigint[]); `, [ruleIds] ) ]); const slotsByRule = new Map(); for (const slot of slotsResult.rows) { slotsByRule.set(slot.recurring_billing_id, [...(slotsByRule.get(slot.recurring_billing_id) ?? []), slot]); } const exceptionKeys = new Set(exceptionsResult.rows.map((row) => row.key)); const existingKeys = new Set(existingResult.rows.map((row) => row.recurring_occurrence_key)); const start = new Date(`${startIso.slice(0, 10)}T00:00:00.000Z`); const end = new Date(`${endIso.slice(0, 10)}T00:00:00.000Z`); await withTransaction(async (client) => { for (let date = start; date < end; date = addDays(date, 1)) { if (bavarianHolidayName(date)) { continue; } const occurrenceDate = dateOnly(date); for (const rule of rulesResult.rows) { for (const slot of slotsByRule.get(rule.id) ?? []) { if (!occursOnDate(rule, slot, date)) { continue; } const occurrenceKey = `${rule.id}:${slot.id}:${occurrenceDate}`; if (exceptionKeys.has(occurrenceKey) || existingKeys.has(occurrenceKey)) { continue; } const startTime = parseClock((slot.start_time ?? rule.start_time).slice(0, 5)); const startedAt = new Date(`${occurrenceDate}T${startTime}:00`); const endedAt = new Date(startedAt.getTime() + slot.duration_minutes * 60_000); const ticketResult = await client.query<{ id: string }>( ` 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; `, [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type] ); await client.query( ` INSERT INTO sessions ( ticket_id, organization_id, customer_name, activity, work_type, user_id, started_at, ended_at, duration_seconds, rounded_minutes, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date, recurring_occurrence_key ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::date, $14) ON CONFLICT (recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL DO NOTHING; `, [ ticketResult.rows[0].id, rule.organization_id, rule.organization_name, rule.activity, rule.work_type, userId, startedAt.toISOString(), endedAt.toISOString(), slot.duration_minutes * 60, slot.duration_minutes, rule.id, slot.id, occurrenceDate, occurrenceKey ] ); existingKeys.add(occurrenceKey); } } } }); } async function cleanupEmptyTickets(client: { query: (text: string, params?: unknown[]) => Promise }, ticketIds: string[]) { const uniqueTicketIds = [...new Set(ticketIds)].filter(Boolean); if (uniqueTicketIds.length === 0) { return; } await client.query( ` DELETE FROM tickets t WHERE t.id = ANY($1::bigint[]) AND NOT EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = t.id ); `, [uniqueTicketIds] ); } async function cleanupTicketDayBillingIfEmpty( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, userId: string, startedAt: Date ) { await client.query( ` DELETE FROM ticket_day_billings tdb WHERE tdb.ticket_id = $1 AND tdb.user_id = $2 AND tdb.day = ($3::timestamptz AT TIME ZONE 'Europe/Berlin')::date AND NOT EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = tdb.ticket_id AND s.user_id = tdb.user_id AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day ); `, [ticketId, userId, startedAt.toISOString()] ); } async function reopenPeriodsForSession( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, startedAt: Date, userId: string ) { const { dayStart, monthStart } = periodStartForDate(startedAt); await client.query("DELETE FROM ticket_month_closures WHERE ticket_id = $1 AND month = $2::date AND user_id = $3;", [ticketId, monthStart, userId]); await client.query("DELETE FROM month_closures WHERE month = $1::date AND user_id = $2;", [monthStart, userId]); await client.query("DELETE FROM ticket_day_closures WHERE ticket_id = $1 AND day = $2::date AND user_id = $3;", [ticketId, dayStart, userId]); await client.query("DELETE FROM day_closures WHERE day = $1::date AND user_id = $2;", [dayStart, userId]); } async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); const bucketExpression = config.type === "month" ? "to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')" : "to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'HH24')"; const [ticketsResult, openResult, closureResult, activityResult, crmBillingResult] = await Promise.all([ query( ` SELECT t.id, t.ticket_number, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, COUNT(s.id)::int AS session_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count, COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count, false AS requires_closure, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS ticket_open_count, (COUNT(*) FILTER (WHERE s.billing_status IS NULL) = 0) AS closed, NULL::timestamptz AS closed_at FROM tickets t JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.id = t.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type ORDER BY t.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), query( ` SELECT s.id, s.ticket_id, 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.rounded_minutes FROM sessions s JOIN tickets t ON t.id = s.ticket_id LEFT JOIN organizations so ON so.id = s.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 AND s.billing_status IS NULL ORDER BY s.started_at ASC; `, [period.startIso, period.endIso, userId] ), config.type === "month" ? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) : Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }), query( ` SELECT ${bucketExpression} AS bucket_key, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS session_count, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes FROM sessions s WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY bucket_key ORDER BY bucket_key ASC; `, [period.startIso, period.endIso, userId] ), query<{ crm_billed_minutes: number }>( ` SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes FROM ticket_day_billings tdb WHERE tdb.user_id = $1 AND tdb.day >= $2::date AND tdb.day < $3::timestamptz::date AND EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = tdb.ticket_id AND s.user_id = tdb.user_id AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day AND s.billing_status = 'billed' ); `, [userId, period.start, period.endIso] ) ]); const tickets = ticketsResult.rows; const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0); const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0); const openSessions = openResult.rows; const periodClosed = config.type === "month" && closureResult.rows.length > 0; return { periodType: config.type, period: period.label, closed: periodClosed, closedAt: periodClosed ? closureResult.rows[0]?.closed_at ?? null : null, canClose: config.type === "month" && tickets.length > 0 && openSessions.length === 0, totals: { tickets: tickets.length, sessions: totalSessions, minutes: totalMinutes, crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0, nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0), openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0), openSessions: openSessions.length }, tickets, openSessions, activitySeries: activityResult.rows }; } async function getStatisticsOverview(period: ParsedPeriod, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); const baseCte = ` WITH session_base AS ( SELECT s.id, s.ticket_id, t.ticket_number, COALESCE(s.organization_id, t.organization_id) AS organization_id, COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, s.activity, s.work_type, s.started_at, (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day, s.rounded_minutes, s.billing_status, s.recurring_billing_id 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 WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 ), ticket_day AS ( SELECT ticket_id, ticket_number, day, SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed')::int AS tracked_minutes FROM session_base GROUP BY ticket_id, ticket_number, day HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0 ), ticket_day_billing AS ( SELECT td.ticket_id, td.ticket_number, td.day, td.tracked_minutes, COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes, tdb.billed_minutes IS NOT NULL AS has_crm_value, ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged, ack.acknowledged_at AS missing_crm_acknowledged_at FROM ticket_day td LEFT JOIN ticket_day_billings tdb ON tdb.ticket_id = td.ticket_id AND tdb.user_id = $3 AND tdb.day = td.day LEFT JOIN ticket_day_billing_acknowledgements ack ON ack.ticket_id = td.ticket_id AND ack.user_id = $3 AND ack.day = td.day ), session_group AS ( SELECT sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day, COUNT(*)::int AS session_count, COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes, SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes, COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions, COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count, COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count FROM session_base sb GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day ), session_group_with_crm AS ( SELECT sg.*, ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes FROM session_group sg LEFT JOIN ticket_day_billing tdb ON tdb.ticket_id = sg.ticket_id AND tdb.day = sg.day ) `; const [ totalsResult, dailyResult, organizationResult, workTypeResult, ticketResult, openResult, missingCrmResult, acknowledgedMissingCrmResult, mismatchResult, closureResult ] = await Promise.all([ query( ` ${baseCte} SELECT COUNT(DISTINCT ticket_id)::int AS tickets, COUNT(*)::int AS sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 0)::int AS manual_minutes, COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed')::int AS recurring_sessions, COUNT(*) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed')::int AS manual_sessions, COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes, COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days, COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes FROM session_base; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT td.day::text AS day, COALESCE(day_sessions.session_count, 0)::int AS sessions, td.tracked_minutes::int AS total_minutes, COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes, COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes, COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes, COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions, COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions, COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions, COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes FROM ( SELECT day, SUM(tracked_minutes)::int AS tracked_minutes FROM ticket_day GROUP BY day ) td LEFT JOIN ( SELECT day, SUM(session_count)::int AS session_count, SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes, SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes, SUM(COALESCE(open_minutes, 0))::int AS open_minutes, SUM(billed_sessions)::int AS billed_sessions, SUM(non_billable_sessions)::int AS non_billable_sessions, SUM(open_count)::int AS open_sessions FROM session_group GROUP BY day ) day_sessions ON day_sessions.day = td.day LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions ORDER BY td.day ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT organization_id::text AS organization_id, organization_name, COUNT(DISTINCT ticket_id)::int AS tickets, SUM(session_count)::int AS sessions, COALESCE(SUM(total_minutes), 0)::int AS total_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions, COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions, COALESCE(SUM(open_count), 0)::int AS open_sessions, COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes FROM session_group_with_crm GROUP BY organization_id, organization_name ORDER BY total_minutes DESC, organization_name ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT work_type, COUNT(DISTINCT ticket_id)::int AS tickets, SUM(session_count)::int AS sessions, COALESCE(SUM(total_minutes), 0)::int AS total_minutes, COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes, COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes, COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes, COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions, COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions, COALESCE(SUM(open_count), 0)::int AS open_sessions, COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes FROM session_group_with_crm GROUP BY work_type ORDER BY total_minutes DESC, work_type ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT ticket_id::text AS ticket_id, ticket_number, MIN(organization_id)::text AS organization_id, MIN(organization_name) AS organization_name, COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days, COUNT(*)::int AS sessions, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes, COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes, COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions, COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions, COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes FROM session_base GROUP BY ticket_id, ticket_number HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0 ORDER BY total_minutes DESC, ticket_number ASC LIMIT 12; `, [period.startIso, period.endIso, userId] ), query( ` SELECT s.id::text AS id, s.ticket_id::text AS ticket_id, t.ticket_number, COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name, s.activity, s.started_at, s.rounded_minutes 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 o ON o.id = t.organization_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 AND s.billing_status IS NULL ORDER BY s.started_at ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT tdb.ticket_id::text AS ticket_id, tdb.ticket_number, MIN(sb.organization_name) AS organization_name, tdb.day::text AS day, tdb.tracked_minutes, tdb.crm_billed_minutes, false AS acknowledged, NULL::text AS acknowledged_at FROM ticket_day_billing tdb JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day WHERE NOT tdb.has_crm_value AND NOT tdb.missing_crm_acknowledged GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes ORDER BY tdb.day ASC, tdb.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT tdb.ticket_id::text AS ticket_id, tdb.ticket_number, MIN(sb.organization_name) AS organization_name, tdb.day::text AS day, tdb.tracked_minutes, tdb.crm_billed_minutes, true AS acknowledged, MAX(tdb.missing_crm_acknowledged_at)::text AS acknowledged_at FROM ticket_day_billing tdb JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day WHERE NOT tdb.has_crm_value AND tdb.missing_crm_acknowledged GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes ORDER BY tdb.day ASC, tdb.ticket_number ASC; `, [period.startIso, period.endIso, userId] ), query( ` ${baseCte} SELECT tdb.ticket_id::text AS ticket_id, tdb.ticket_number, MIN(sb.organization_name) AS organization_name, tdb.day::text AS day, tdb.tracked_minutes, tdb.crm_billed_minutes, (tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes FROM ticket_day_billing tdb JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day WHERE tdb.has_crm_value AND tdb.tracked_minutes <> tdb.crm_billed_minutes GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC; `, [period.startIso, period.endIso, userId] ), query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId]) ]); const totals = totalsResult.rows[0] ?? { tickets: 0, sessions: 0, minutes: 0, billed_minutes: 0, non_billable_minutes: 0, open_minutes: 0, billed_sessions: 0, non_billable_sessions: 0, open_sessions: 0, recurring_minutes: 0, manual_minutes: 0, recurring_sessions: 0, manual_sessions: 0, average_session_minutes: 0, active_days: 0, crm_billed_minutes: 0 }; return { periodType: "month", period: period.label, closed: closureResult.rows.length > 0, closedAt: closureResult.rows[0]?.closed_at ?? null, totals: { tickets: totals.tickets, sessions: totals.sessions, minutes: totals.minutes, billedMinutes: totals.billed_minutes, nonBillableMinutes: totals.non_billable_minutes, openMinutes: totals.open_minutes, billedSessions: totals.billed_sessions, nonBillableSessions: totals.non_billable_sessions, openSessions: totals.open_sessions, recurringMinutes: totals.recurring_minutes, manualMinutes: totals.manual_minutes, recurringSessions: totals.recurring_sessions, manualSessions: totals.manual_sessions, averageSessionMinutes: totals.average_session_minutes, activeDays: totals.active_days, crmBilledMinutes: totals.crm_billed_minutes, crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes }, dailySeries: dailyResult.rows, organizations: organizationResult.rows.map((row: any) => ({ ...row, crm_delta_minutes: row.crm_billed_minutes - row.total_minutes })), workTypes: workTypeResult.rows.map((row: any) => ({ ...row, crm_delta_minutes: row.crm_billed_minutes - row.total_minutes })), tickets: ticketResult.rows.map((row: any) => ({ ...row, crm_delta_minutes: row.crm_billed_minutes - row.total_minutes })), attention: { openSessions: openResult.rows, missingCrmDays: missingCrmResult.rows, acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows, crmMismatches: mismatchResult.rows } }; } async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); const periodClosureJoin = config.type === "month" ? "LEFT JOIN month_closures pc ON pc.month = $3::date AND pc.user_id = $2" : "LEFT JOIN (SELECT $3::date AS period_start, NULL::timestamptz AS closed_at) pc ON true"; const ticketResult = await query( ` SELECT t.id, t.ticket_number, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, NULL::timestamptz AS closed_at, pc.closed_at AS period_closed_at, COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count, COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count, COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count FROM tickets t LEFT JOIN organizations o ON o.id = t.organization_id ${periodClosureJoin} LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count, COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count FROM sessions sm WHERE sm.ticket_id = t.id AND sm.user_id = $2 AND sm.started_at >= $4::timestamptz AND sm.started_at < $5::timestamptz ) period_sessions ON true WHERE t.id = $1; `, [ticketId, userId, period.start, period.startIso, period.endIso] ); if (ticketResult.rowCount === 0) { return null; } const sessionsResult = await query( ` SELECT id, organization_id, ( SELECT name FROM organizations WHERE id = sessions.organization_id ) AS organization_name, COALESCE( ( SELECT name FROM organizations WHERE id = sessions.organization_id ), customer_name ) AS customer_name, activity, work_type, started_at, ended_at, duration_seconds, rounded_minutes, billing_status, billing_updated_at, created_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date FROM sessions WHERE ticket_id = $1 AND started_at >= $2::timestamptz AND started_at < $3::timestamptz AND user_id = $4 ORDER BY started_at ASC; `, [ticketId, period.startIso, period.endIso, userId] ); const dayBillingResult = await query( ` SELECT day::text AS day, billed_minutes FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2 AND day >= $3::date AND day < $4::timestamptz::date ORDER BY day ASC; `, [ticketId, userId, period.start, period.endIso] ); const sessions = sessionsResult.rows; const openCount = sessions.filter((session: any) => session.billing_status === null).length; const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length; const recurringSessionCount = sessions.length - manualSessionCount; const ticket = ticketResult.rows[0] as any; const ticketOpenCount = Number(ticket.ticket_open_count ?? 0); return { periodType: config.type, period: period.label, ticket: { ...ticket, requires_closure: false, recurring_session_count: recurringSessionCount, manual_session_count: manualSessionCount }, closed: config.type === "month" && Boolean(ticketResult.rows[0].period_closed_at), closedAt: config.type === "month" ? ticketResult.rows[0].period_closed_at ?? null : null, sessions, dayBillings: dayBillingResult.rows, canClose: false, openCount, ticketOpenCount }; } app.post("/api/auth/login", async (req, res) => { const username = requireString(req.body.username, "username"); const password = requireString(req.body.password, "password"); const result = await query<{ id: string; username: string; display_name: string; role: "admin" | "user"; active: boolean; password_hash: string; }>( ` SELECT id, username, display_name, role, active, password_hash FROM users WHERE lower(username) = lower($1); `, [username] ); const user = result.rows[0]; if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) { res.status(401).json({ error: "Benutzername oder Passwort ist falsch" }); return; } await createAuthSession(res, user.id); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, active: user.active } }); }); app.use("/api", requireAuth); app.get("/api/auth/me", (req, res) => { res.json({ user: currentUser(req) }); }); app.post("/api/auth/logout", async (req, res) => { await clearAuthSession(req, res); res.json({ ok: true }); }); app.patch("/api/auth/me", async (req, res) => { const user = currentUser(req); const username = parseUsername(req.body.username); const displayName = requireString(req.body.displayName, "displayName"); const currentPassword = optionalString(req.body.currentPassword); const newPassword = optionalString(req.body.newPassword); if (newPassword && newPassword.length < 6) { throw badRequest("newPassword must be at least 6 characters"); } if (newPassword) { if (!currentPassword) { throw badRequest("currentPassword is required"); } const passwordResult = await query<{ password_hash: string }>("SELECT password_hash FROM users WHERE id = $1;", [user.id]); if (passwordResult.rows.length === 0 || !(await verifyPassword(currentPassword, passwordResult.rows[0].password_hash))) { res.status(401).json({ error: "Aktuelles Passwort ist falsch" }); return; } } try { const result = await query( ` UPDATE users SET username = $1, display_name = $2, password_hash = COALESCE($3, password_hash), updated_at = now() WHERE id = $4 RETURNING id, username, display_name, role, active; `, [username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id] ); if (result.rowCount === 0) { res.status(404).json({ error: "User not found" }); return; } res.json({ user: result.rows[0] }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Benutzername ist bereits vergeben" }); return; } if (error?.code === "23514") { res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" }); return; } throw error; } }); app.get("/api/admin/users", requireAdmin, async (_req, res) => { const result = await query( ` SELECT id, username, display_name, role, active, created_at, updated_at FROM users ORDER BY username ASC; ` ); res.json({ users: result.rows }); }); 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 active = optionalBoolean(req.body.active, true); if (password.length < 6) { throw badRequest("password must be at least 6 characters"); } try { const result = await query( ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, $4, $5) RETURNING id, username, display_name, role, active, created_at, updated_at; `, [username, displayName, await hashPassword(password), "user", active] ); res.status(201).json({ user: result.rows[0] }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Benutzername ist bereits vergeben" }); return; } if (error?.code === "23514") { res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" }); return; } throw error; } }); 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 active = optionalBoolean(req.body.active, true); const password = optionalString(req.body.password); if (password && password.length < 6) { throw badRequest("password must be at least 6 characters"); } 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; } try { const result = await query( ` UPDATE users SET username = $1, display_name = $2, role = role, active = $3, password_hash = COALESCE($4, password_hash), updated_at = now() WHERE id = $5 RETURNING id, username, display_name, role, active, created_at, updated_at; `, [username, displayName, active, password ? await hashPassword(password) : null, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "User not found" }); return; } if (!active) { await query("DELETE FROM auth_sessions WHERE user_id = $1;", [userId]); } res.json({ user: result.rows[0] }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Benutzername ist bereits vergeben" }); return; } if (error?.code === "23514") { res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" }); return; } throw error; } }); app.get("/api/organizations", async (req, res) => { const search = optionalString(req.query.search); const params: unknown[] = []; const where: string[] = []; if (search) { params.push(`%${search}%`); where.push(`name ILIKE $${params.length}`); } const result = await query( ` SELECT id, zammad_id, name, synced_at FROM organizations ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY lower(name) ASC LIMIT 500; `, params ); res.json({ organizations: result.rows }); }); app.get("/api/admin/zammad/settings", requireAdmin, async (_req, res) => { const [baseUrl, apiKey] = await Promise.all([getAppSetting(zammadBaseUrlSettingKey), getAppSetting(zammadApiKeySettingKey)]); res.json({ settings: { baseUrl: baseUrl ?? "", hasApiKey: Boolean(apiKey) } }); }); app.put("/api/admin/zammad/settings", requireAdmin, async (req, res) => { const baseUrl = parseZammadBaseUrl(req.body.baseUrl); const apiKey = optionalString(req.body.apiKey); await setAppSetting(zammadBaseUrlSettingKey, baseUrl); if (apiKey) { await setAppSetting(zammadApiKeySettingKey, apiKey); } res.json({ settings: { baseUrl, hasApiKey: Boolean(apiKey) || Boolean(await getAppSetting(zammadApiKeySettingKey)) } }); }); app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) => { const requestBaseUrl = optionalString(req.body.baseUrl); const requestApiKey = optionalString(req.body.apiKey); const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey); const savedApiKey = await getAppSetting(zammadApiKeySettingKey); const baseUrl = parseZammadBaseUrl(requestBaseUrl ?? savedBaseUrl); const apiKey = requestApiKey ?? savedApiKey; if (!apiKey) { throw badRequest("apiKey is required"); } const response = await fetch(`${baseUrl}/api/v1/organizations`, { headers: { Authorization: `Token token=${apiKey}`, Accept: "application/json" } }); if (!response.ok) { const body = await response.text().catch(() => ""); res.status(502).json({ error: `Zammad Organisationen konnten nicht geladen werden (${response.status})`, detail: body.slice(0, 500) }); return; } const payload = (await response.json()) as unknown; if (!Array.isArray(payload)) { throw badRequest("Zammad response must be an organization array"); } const organizations = payload .map((item) => { if (!item || typeof item !== "object") { return null; } const raw = item as Record; const zammadId = Number(raw.id); const name = typeof raw.name === "string" ? raw.name.trim() : ""; if (!Number.isInteger(zammadId) || zammadId <= 0 || name.length === 0) { return null; } return { zammadId, name }; }) .filter((organization): organization is NonNullable => Boolean(organization)); const syncResult = await withTransaction(async (client) => { let synced = 0; for (const organization of organizations) { await client.query( ` INSERT INTO organizations (zammad_id, name, synced_at, updated_at) VALUES ($1, $2, now(), now()) ON CONFLICT (zammad_id) DO UPDATE SET name = EXCLUDED.name, synced_at = now(), updated_at = now(); `, [organization.zammadId, organization.name] ); synced += 1; } const zammadIds = organizations.map((organization) => organization.zammadId); let removed = 0; let unlinkedTickets = 0; let unlinkedSessions = 0; { const staleResult = zammadIds.length > 0 ? await client.query<{ id: string }>( ` SELECT id FROM organizations WHERE NOT (zammad_id = ANY($1::bigint[])); `, [zammadIds] ) : await client.query<{ id: string }>( ` SELECT id FROM organizations; ` ); const staleIds = staleResult.rows.map((row) => row.id); if (staleIds.length > 0) { const ticketResult = await client.query( ` UPDATE tickets SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, [staleIds] ); const sessionResult = await client.query( ` UPDATE sessions SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, [staleIds] ); const deleteResult = await client.query( ` DELETE FROM organizations WHERE id = ANY($1::bigint[]); `, [staleIds] ); unlinkedTickets = ticketResult.rowCount ?? 0; unlinkedSessions = sessionResult.rowCount ?? 0; removed = deleteResult.rowCount ?? 0; } } return { synced, removed, unlinkedTickets, unlinkedSessions }; }); await setAppSetting(zammadBaseUrlSettingKey, baseUrl); if (requestApiKey) { await setAppSetting(zammadApiKeySettingKey, requestApiKey); } res.json({ ...syncResult, skipped: payload.length - syncResult.synced }); }); 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>( ` 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"); } return value; } function parseNullableDay(value: unknown, field: string) { if (value === null || value === undefined || value === "") { return null; } return parseDay(requireString(value, field)).start; } function parseOptionalTicketNumber(value: unknown) { const ticketNumber = optionalString(value); return ticketNumber ? parseTicketNumber(ticketNumber) : null; } function parseWeekday(value: unknown) { const weekday = parsePositiveInteger(value, "weekday"); if (weekday > 6) { throw badRequest("weekday must be between 0 and 6"); } return weekday; } function parseRecurringSlots(value: unknown, recurrenceType: "weekly" | "every_n_weeks") { if (!Array.isArray(value) || value.length === 0) { throw badRequest("slots are required"); } return value.map((raw) => { if (!raw || typeof raw !== "object") { throw badRequest("slot must be an object"); } const slot = raw as Record; const durationMinutes = parsePositiveInteger(slot.durationMinutes ?? slot.duration_minutes, "durationMinutes"); const id = slot.id === undefined || slot.id === null || slot.id === "" ? null : parsePositiveInteger(slot.id, "slotId"); const weekday = recurrenceType === "weekly" ? parseWeekday(slot.weekday) : null; const startTimeValue = requireString(slot.startTime ?? slot.start_time, "startTime"); if (durationMinutes < 1) { throw badRequest("durationMinutes must be greater than 0"); } return { id, weekday, startTime: parseClock(startTimeValue), durationMinutes }; }); } function parseRecurringBillingPayload(body: Record) { const ticketNumber = parseOptionalTicketNumber(body.ticketNumber); const organizationId = parseOrganizationId(body.organizationId); const activity = requireString(body.activity, "activity"); const workType = parseWorkType(body.workType); const recurrenceType = parseRecurrenceType(body.recurrenceType); const intervalValue = recurrenceType === "weekly" ? 1 : Math.max(1, parsePositiveInteger(body.intervalValue ?? 1, "intervalValue")); const validFrom = parseDay(requireString(body.validFrom, "validFrom")).start; const validUntil = parseNullableDay(body.validUntil, "validUntil"); const slots = parseRecurringSlots(body.slots, recurrenceType); const startTime = slots[0].startTime; return { ticketNumber, organizationId, activity, workType, recurrenceType, intervalValue, validFrom, validUntil, slots, startTime }; } async function recurringBillingResponse(userId: string) { const result = await query( ` SELECT rb.id, rb.user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, COALESCE(rb.ticket_number, 'Fix#' || rb.id) AS ticket_number, rb.ticket_number AS configured_ticket_number, rb.organization_id, o.name AS organization_name, rb.activity, rb.work_type, rb.recurrence_type, rb.interval_value, rb.valid_from::text, rb.valid_until::text, rb.start_time::text, rb.active, rb.created_at, rb.updated_at, COALESCE( json_agg( json_build_object( 'id', rbs.id::text, 'weekday', rbs.weekday, 'start_time', rbs.start_time::text, 'duration_minutes', rbs.duration_minutes ) ORDER BY rbs.weekday NULLS LAST, rbs.start_time ) FILTER (WHERE rbs.id IS NOT NULL), '[]'::json ) AS slots FROM recurring_billings rb JOIN users owner ON owner.id = rb.user_id JOIN organizations o ON o.id = rb.organization_id LEFT JOIN recurring_billing_slots rbs ON rbs.recurring_billing_id = rb.id WHERE rb.user_id = $1 GROUP BY rb.id, owner.username, owner.display_name, o.name ORDER BY rb.id DESC; `, [userId] ); return result.rows; } app.get("/api/recurring-billings", requireUser, async (req, res) => { res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) }); }); app.post("/api/recurring-billings", requireUser, async (req, res) => { const userId = currentUser(req).id; const payload = parseRecurringBillingPayload(req.body); const created = await withTransaction(async (client) => { const organization = await getOrganizationById(client, payload.organizationId); if (!organization) { return "organization-not-found" as const; } const billingResult = await client.query<{ id: string }>( ` INSERT INTO recurring_billings ( user_id, ticket_number, organization_id, activity, work_type, recurrence_type, interval_value, valid_from, valid_until, start_time ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::date, $9::date, $10::time) RETURNING id; `, [ userId, payload.ticketNumber, organization.id, payload.activity, payload.workType, payload.recurrenceType, payload.intervalValue, payload.validFrom, payload.validUntil, payload.startTime ] ); for (const slot of payload.slots) { await client.query( ` INSERT INTO recurring_billing_slots (recurring_billing_id, weekday, start_time, duration_minutes) VALUES ($1, $2, $3::time, $4); `, [billingResult.rows[0].id, slot.weekday, slot.startTime, slot.durationMinutes] ); } return billingResult.rows[0].id; }); if (created === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden" }); return; } res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) }); }); 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); const body = req.body as Record; const isActiveOnlyUpdate = body.ticketNumber === undefined && body.organizationId === undefined && body.activity === undefined && body.workType === undefined && body.recurrenceType === undefined && body.intervalValue === undefined && body.validFrom === undefined && body.validUntil === undefined && body.slots === undefined; if (isActiveOnlyUpdate) { const result = await query( ` UPDATE recurring_billings SET active = $1, updated_at = now() WHERE id = $2 AND user_id = $3 RETURNING id; `, [active, billingId, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "Wiederkehrende Abrechnung nicht gefunden" }); return; } res.json({ recurringBillings: await recurringBillingResponse(userId) }); return; } const payload = parseRecurringBillingPayload(body); const result = await withTransaction(async (client) => { const billingResult = await client.query<{ id: string }>( ` SELECT id FROM recurring_billings WHERE s.id = $1 AND s.user_id = $2 FOR UPDATE OF s; `, [billingId, userId] ); if (billingResult.rowCount === 0) { return { status: "not-found" as const }; } const organization = await getOrganizationById(client, payload.organizationId); if (!organization) { return { status: "organization-not-found" as const }; } const previousSessionResult = await client.query<{ ticket_id: string; started_at: string }>( ` SELECT ticket_id, started_at FROM sessions WHERE recurring_billing_id = $1 AND user_id = $2; `, [billingId, userId] ); const previousTicketIds = previousSessionResult.rows.map((session) => session.ticket_id); await client.query( ` UPDATE recurring_billings SET ticket_number = $1, organization_id = $2, activity = $3, work_type = $4, recurrence_type = $5, interval_value = $6, valid_from = $7::date, valid_until = $8::date, start_time = $9::time, active = $10, updated_at = now() WHERE id = $11 AND user_id = $12; `, [ payload.ticketNumber, organization.id, payload.activity, payload.workType, payload.recurrenceType, payload.intervalValue, payload.validFrom, payload.validUntil, payload.startTime, active, billingId, userId ] ); const existingSlotsResult = await client.query<{ id: string }>( ` SELECT id FROM recurring_billing_slots WHERE recurring_billing_id = $1; `, [billingId] ); const existingSlotIds = new Set(existingSlotsResult.rows.map((slot) => slot.id)); const keptSlotIds: string[] = []; for (const slot of payload.slots) { if (slot.id !== null && existingSlotIds.has(String(slot.id))) { const updatedSlot = await client.query<{ id: string }>( ` UPDATE recurring_billing_slots SET weekday = $1, start_time = $2::time, duration_minutes = $3 WHERE id = $4 AND recurring_billing_id = $5 RETURNING id; `, [slot.weekday, slot.startTime, slot.durationMinutes, slot.id, billingId] ); keptSlotIds.push(updatedSlot.rows[0].id); } else { const insertedSlot = await client.query<{ id: string }>( ` INSERT INTO recurring_billing_slots (recurring_billing_id, weekday, start_time, duration_minutes) VALUES ($1, $2, $3::time, $4) RETURNING id; `, [billingId, slot.weekday, slot.startTime, slot.durationMinutes] ); keptSlotIds.push(insertedSlot.rows[0].id); } } const removedSlotIds = [...existingSlotIds].filter((slotId) => !keptSlotIds.includes(slotId)); const deletedRows: Array<{ ticket_id: string; started_at: string }> = []; if (removedSlotIds.length > 0) { const deletedRemovedSlots = await client.query<{ ticket_id: string; started_at: string }>( ` DELETE FROM sessions WHERE recurring_billing_id = $1 AND user_id = $2 AND recurring_billing_slot_id = ANY($3::bigint[]) RETURNING ticket_id, started_at; `, [billingId, userId, removedSlotIds] ); deletedRows.push(...deletedRemovedSlots.rows); await client.query( ` DELETE FROM recurring_billing_slots WHERE recurring_billing_id = $1 AND id = ANY($2::bigint[]); `, [billingId, removedSlotIds] ); } const deletedOutOfRange = await client.query<{ ticket_id: string; started_at: string }>( ` DELETE FROM sessions WHERE recurring_billing_id = $1 AND user_id = $2 AND ( recurring_occurrence_date < $3::date OR ($4::date IS NOT NULL AND recurring_occurrence_date > $4::date) ) RETURNING ticket_id, started_at; `, [billingId, userId, payload.validFrom, payload.validUntil] ); deletedRows.push(...deletedOutOfRange.rows); const targetTicketNumber = payload.ticketNumber ?? `Fix#${billingId}`; const targetTicketResult = await client.query<{ id: string }>( ` 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; `, [targetTicketNumber, organization.id, organization.name, payload.workType] ); const updatedSessions = await client.query<{ ticket_id: string; started_at: string }>( ` UPDATE sessions s SET ticket_id = $3, organization_id = $4, customer_name = $5, activity = $6, work_type = $7, started_at = (s.recurring_occurrence_date::timestamp + slot.start_time)::timestamptz, ended_at = ((s.recurring_occurrence_date::timestamp + slot.start_time) + (slot.duration_minutes || ' minutes')::interval)::timestamptz, duration_seconds = slot.duration_minutes * 60, rounded_minutes = slot.duration_minutes FROM recurring_billing_slots slot WHERE s.recurring_billing_id = $1 AND s.user_id = $2 AND s.recurring_billing_slot_id = slot.id AND slot.recurring_billing_id = $1 AND s.recurring_occurrence_date >= $8::date AND ($9::date IS NULL OR s.recurring_occurrence_date <= $9::date) RETURNING s.ticket_id, s.started_at; `, [ billingId, userId, targetTicketResult.rows[0].id, organization.id, organization.name, payload.activity, payload.workType, payload.validFrom, payload.validUntil ] ); for (const session of [...previousSessionResult.rows, ...deletedRows, ...updatedSessions.rows]) { await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId); } await cleanupEmptyTickets(client, [...previousTicketIds, ...deletedRows.map((session) => session.ticket_id)]); return { status: "ok" as const }; }); if (result.status === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden" }); return; } if (result.status === "not-found") { res.status(404).json({ error: "Wiederkehrende Abrechnung nicht gefunden" }); return; } res.json({ recurringBillings: await recurringBillingResponse(userId) }); }); 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) => { const billingResult = await client.query<{ id: string }>( ` SELECT id FROM recurring_billings WHERE id = $1 AND user_id = $2 FOR UPDATE; `, [billingId, userId] ); if (billingResult.rowCount === 0) { return { status: "not-found" as const }; } const deletedSessions = await client.query<{ ticket_id: string; started_at: string }>( ` DELETE FROM sessions WHERE recurring_billing_id = $1 AND user_id = $2 RETURNING ticket_id, started_at; `, [billingId, userId] ); await client.query("DELETE FROM recurring_billings WHERE id = $1 AND user_id = $2;", [billingId, userId]); for (const session of deletedSessions.rows) { await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId); } await cleanupEmptyTickets(client, deletedSessions.rows.map((session) => session.ticket_id)); return { status: "ok" as const, deletedSessions: deletedSessions.rowCount ?? 0 }; }); if (result.status === "not-found") { res.status(404).json({ error: "Wiederkehrende Abrechnung nicht gefunden" }); return; } res.json({ recurringBillings: await recurringBillingResponse(userId), deletedSessions: result.deletedSessions }); }); app.get("/api/admin/sessions", requireAdmin, async (_req, res) => { res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" }); }); 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) => { res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" }); }); 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"); const requestedOrganizationId = req.body.organizationId === undefined || req.body.organizationId === null || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); const requestedWorkType = optionalWorkType(req.body.workType); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); if (endedAt < startedAt) { throw badRequest("endedAt must be after startedAt"); } const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const created = await withTransaction(async (client) => { const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( ` SELECT id, organization_id, customer_name, work_type FROM tickets WHERE ticket_number = $1 FOR UPDATE; `, [ticketNumber] ); const existingTicket = existingTicketResult.rows[0] ?? null; const effectiveOrganizationId = requestedOrganizationId ?? (existingTicket?.organization_id ? Number(existingTicket.organization_id) : null); if (!effectiveOrganizationId) { throw badRequest("organizationId is required"); } const organization = await getOrganizationById(client, effectiveOrganizationId); if (!organization) { throw badRequest("organizationId must reference an existing organization"); } 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, requestedWorkType] ); const ticket = ticketResult.rows[0]; const workType = requestedWorkType ?? ticket.work_type; if (!workType) { throw badRequest("workType must be support or consulting"); } const sessionResult = await client.query( ` 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 *; `, [ ticket.id, organization.id, organization.name, activity, workType, user.id, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes ] ); await reopenPeriodsForSession(client, ticket.id, startedAt, user.id); return { ticket, session: sessionResult.rows[0] }; }); res.status(201).json(created); }); 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", requireUser, async (req, res) => { const month = parseMonth(String(req.params.month)); res.json(await getStatisticsOverview(month, currentUser(req).id)); }); app.get("/api/tickets/lookup", requireUser, async (req, res) => { const ticketNumber = parseTicketNumber(req.query.ticketNumber); const result = await query( ` SELECT t.id, t.ticket_number, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type FROM tickets t LEFT JOIN organizations o ON o.id = t.organization_id WHERE t.ticket_number = $1; `, [ticketNumber] ); res.json({ ticket: result.rows[0] ?? null }); }); 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); const workType = parseWorkType(req.body.workType); try { const result = await withTransaction(async (client) => { const organization = await getOrganizationById(client, organizationId); if (!organization) { return "organization-not-found" as const; } const ticketResult = await client.query( ` UPDATE tickets SET ticket_number = $1, organization_id = $2, customer_name = $3, work_type = $4 WHERE id = $5 RETURNING id, ticket_number, organization_id, customer_name, work_type; `, [ticketNumber, organization.id, organization.name, workType, ticketId] ); if (ticketResult.rowCount === 0) { return null; } await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2, work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END WHERE ticket_id = $4; `, [organization.id, organization.name, workType, ticketId] ); return ticketResult.rows[0]; }); if (result === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" }); return; } if (!result) { res.status(404).json({ error: "Ticket not found" }); return; } res.json({ ticket: result }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Ticketnummer ist bereits vergeben" }); return; } throw error; } }); 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); if (!result) { res.status(404).json({ error: "Ticket not found" }); return; } res.json(result); }); app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const day = parseDay(String(req.params.day)); const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); const userId = currentUser(req).id; const sessionResult = await query<{ id: string }>( ` SELECT id FROM sessions WHERE ticket_id = $1 AND user_id = $2 AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date LIMIT 1; `, [ticketId, userId, day.start] ); if (sessionResult.rowCount === 0) { res.status(404).json({ error: "No sessions found for this ticket and day" }); return; } if (billedMinutes === null) { await query( ` DELETE FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2 AND day = $3::date; `, [ticketId, userId, day.start] ); res.json({ dayBilling: null }); return; } const result = await query( ` INSERT INTO ticket_day_billings (ticket_id, user_id, day, billed_minutes) VALUES ($1, $2, $3::date, $4) ON CONFLICT (ticket_id, user_id, day) DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now() RETURNING day::text AS day, billed_minutes; `, [ticketId, userId, day.start, billedMinutes] ); await query( ` DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1 AND user_id = $2 AND day = $3::date; `, [ticketId, userId, day.start] ); res.json({ dayBilling: result.rows[0] }); }); app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const day = parseDay(String(req.params.day)); const acknowledged = req.body.acknowledged !== false; const userId = currentUser(req).id; const sessionResult = await query<{ id: string }>( ` SELECT id FROM sessions WHERE ticket_id = $1 AND user_id = $2 AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date LIMIT 1; `, [ticketId, userId, day.start] ); if (sessionResult.rowCount === 0) { res.status(404).json({ error: "No sessions found for this ticket and day" }); return; } if (!acknowledged) { await query( ` DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1 AND user_id = $2 AND day = $3::date; `, [ticketId, userId, day.start] ); res.json({ acknowledged: false }); return; } const result = await query( ` INSERT INTO ticket_day_billing_acknowledgements (ticket_id, user_id, day) VALUES ($1, $2, $3::date) ON CONFLICT (ticket_id, user_id, day) DO UPDATE SET acknowledged_at = now() RETURNING day::text AS day, acknowledged_at; `, [ticketId, userId, day.start] ); res.json({ acknowledged: true, acknowledgement: result.rows[0] }); }); 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", 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", 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") { res.status(400).json({ error: "Tagesabschlüsse werden nicht verwendet. Bitte den Monat abschließen." }); return; } const blockersResult = await query( ` SELECT t.id AS ticket_id, t.ticket_number, COUNT(s.id)::int AS session_count, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count, false AS ticket_closed FROM tickets t JOIN sessions s ON s.ticket_id = t.id WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz AND s.user_id = $4 GROUP BY t.id, t.ticket_number HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 ORDER BY t.ticket_number ASC; `, [period.start, period.startIso, period.endIso, userId] ); if (blockersResult.rows.length > 0) { res.status(409).json({ error: "Period has open sessions", blockers: blockersResult.rows }); return; } const result = await query( ` INSERT INTO month_closures (month, user_id) VALUES ($1::date, $2) ON CONFLICT (month, user_id) DO UPDATE SET closed_at = now() RETURNING *; `, [period.start, userId] ); res.json({ closure: result.rows[0] }); }); 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") { res.status(400).json({ error: "Tagesabschlüsse werden nicht verwendet. Bitte den Monat öffnen." }); return; } const result = await query( ` DELETE FROM month_closures WHERE month = $1::date AND user_id = $2 RETURNING *; `, [period.start, userId] ); res.json({ reopened: result.rows.length > 0 }); }); 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", requireUser, async (req, res) => { const month = parseMonth(String(req.params.month)); const [ticketsResult, openResult, monthClosureResult] = await Promise.all([ query( ` SELECT t.id, t.ticket_number, t.customer_name, t.work_type, COUNT(s.id)::int AS session_count, COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count, COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count, tm.closed_at IS NOT NULL AS closed, tm.closed_at FROM tickets t JOIN sessions s ON s.ticket_id = t.id LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz GROUP BY t.id, t.ticket_number, t.customer_name, t.work_type, tm.closed_at ORDER BY t.ticket_number ASC; `, [month.start, month.startIso, month.endIso] ), query( ` SELECT s.id, s.ticket_id, t.ticket_number, s.customer_name, s.activity, s.work_type, s.started_at, s.rounded_minutes FROM sessions s JOIN tickets t ON t.id = s.ticket_id WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.billing_status IS NULL ORDER BY s.started_at ASC; `, [month.startIso, month.endIso] ), query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date;", [month.start]) ]); const tickets = ticketsResult.rows; const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0); const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0); const openSessions = openResult.rows; const allTicketsClosed = tickets.length > 0 && tickets.every((ticket: any) => ticket.closed); res.json({ month: month.label, monthClosed: monthClosureResult.rows.length > 0, monthClosedAt: monthClosureResult.rows[0]?.closed_at ?? null, canCloseMonth: tickets.length > 0 && allTicketsClosed && openSessions.length === 0, totals: { tickets: tickets.length, sessions: totalSessions, minutes: totalMinutes, openSessions: openSessions.length }, tickets, openSessions }); }); 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( ` SELECT t.id, t.ticket_number, t.customer_name, t.work_type, tm.closed_at, mc.closed_at AS month_closed_at FROM tickets t LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date LEFT JOIN month_closures mc ON mc.month = $1::date WHERE t.id = $2; `, [month.start, ticketId] ); if (ticketResult.rowCount === 0) { res.status(404).json({ error: "Ticket not found" }); return; } const sessionsResult = await query( ` SELECT id, customer_name, activity, work_type, started_at, ended_at, duration_seconds, rounded_minutes, billing_status, billing_updated_at, created_at FROM sessions WHERE ticket_id = $1 AND started_at >= $2::timestamptz AND started_at < $3::timestamptz ORDER BY started_at ASC; `, [ticketId, month.startIso, month.endIso] ); const sessions = sessionsResult.rows; const openCount = sessions.filter((session: any) => session.billing_status === null).length; res.json({ month: month.label, ticket: ticketResult.rows[0], monthClosed: Boolean(ticketResult.rows[0].month_closed_at), monthClosedAt: ticketResult.rows[0].month_closed_at ?? null, sessions, canClose: sessions.length > 0 && openCount === 0, openCount }); }); 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; const lockResult = await query( ` SELECT s.ticket_id, date_trunc('month', s.started_at)::date AS month, date_trunc('day', s.started_at)::date AS day, tm.closed_at AS ticket_month_closed_at FROM sessions s LEFT JOIN ticket_month_closures tm ON tm.ticket_id = s.ticket_id AND tm.month = date_trunc('month', s.started_at)::date AND tm.user_id = s.user_id WHERE s.id = $1 AND s.user_id = $2; `, [sessionId, userId] ); if (lockResult.rowCount === 0) { res.status(404).json({ error: "Session not found" }); return; } if (billingStatus !== null && lockResult.rows[0].ticket_month_closed_at) { res.status(409).json({ error: "Ticket is closed for this period" }); return; } const result = await withTransaction(async (client) => { const updated = await client.query( ` UPDATE sessions SET billing_status = $1, billing_updated_at = CASE WHEN $1::text IS NULL THEN NULL ELSE now() END WHERE id = $2 AND user_id = $3 RETURNING *; `, [billingStatus, sessionId, userId] ); if (billingStatus === null) { await reopenPeriodsForSession(client, lockResult.rows[0].ticket_id, new Date(lockResult.rows[0].day), userId); } return updated; }); if (result.rowCount === 0) { res.status(404).json({ error: "Session not found" }); return; } res.json({ session: result.rows[0] }); }); 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); 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 updated = await withTransaction(async (client) => { const organization = await getOrganizationById(client, organizationId); if (!organization) { return "organization-not-found" as const; } const currentResult = await client.query<{ id: string; ticket_id: string; started_at: string; work_type: "support" | "consulting"; recurring_billing_id: string | null; recurring_work_type: "support" | "consulting" | null; }>( ` SELECT s.id, s.ticket_id, s.started_at, s.work_type, s.recurring_billing_id, rb.work_type AS recurring_work_type FROM sessions s LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id WHERE s.id = $1 AND s.user_id = $2 FOR UPDATE OF s; `, [sessionId, userId] ); if (currentResult.rows.length === 0) { return null; } const current = currentResult.rows[0]; const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId); await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId); const result = await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2, activity = $3, work_type = $4, started_at = $5, ended_at = $6, duration_seconds = $7, rounded_minutes = $8 WHERE id = $9 AND user_id = $10 RETURNING *; `, [organization.id, organization.name, activity, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, sessionId, userId] ); await cleanupTicketDayBillingIfEmpty(client, current.ticket_id, userId, new Date(current.started_at)); return result.rows[0]; }); if (updated === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" }); return; } if (!updated) { res.status(404).json({ error: "Session not found" }); return; } res.json({ session: updated }); }); app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => { const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const userId = currentUser(req).id; const deleted = await withTransaction(async (client) => { const sessionResult = await client.query<{ id: string; ticket_id: string; started_at: string; recurring_billing_id: string | null; recurring_billing_slot_id: string | null; recurring_occurrence_date: string | null; }>( ` DELETE FROM sessions WHERE id = $1 AND user_id = $2 RETURNING id, ticket_id, started_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date; `, [sessionId, userId] ); if (sessionResult.rows.length === 0) { return null; } const session = sessionResult.rows[0]; if (session.recurring_billing_id && session.recurring_billing_slot_id && session.recurring_occurrence_date) { await client.query( ` INSERT INTO recurring_billing_exceptions (recurring_billing_id, recurring_billing_slot_id, occurrence_date, action) VALUES ($1, $2, $3::date, 'cancelled') ON CONFLICT (recurring_billing_id, recurring_billing_slot_id, occurrence_date) DO UPDATE SET action = 'cancelled'; `, [session.recurring_billing_id, session.recurring_billing_slot_id, session.recurring_occurrence_date] ); } await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId); await cleanupTicketDayBillingIfEmpty(client, session.ticket_id, userId, new Date(session.started_at)); const remainingUserResult = await client.query<{ count: string }>( ` SELECT COUNT(*) AS count FROM sessions WHERE ticket_id = $1 AND user_id = $2; `, [session.ticket_id, userId] ); const remainingResult = await client.query<{ count: string }>( ` SELECT COUNT(*) AS count FROM sessions WHERE ticket_id = $1; `, [session.ticket_id] ); const remainingSessions = Number(remainingResult.rows[0]?.count ?? 0); if (remainingSessions === 0) { await client.query("DELETE FROM tickets WHERE id = $1;", [session.ticket_id]); } return { ...session, userTicketEmpty: Number(remainingUserResult.rows[0]?.count ?? 0) === 0, ticketDeleted: remainingSessions === 0 }; }); if (!deleted) { res.status(404).json({ error: "Session not found" }); return; } res.json({ deleted }); }); 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( ` SELECT COUNT(*)::int AS session_count, COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_count FROM sessions WHERE ticket_id = $1 AND started_at >= $2::timestamptz AND started_at < $3::timestamptz; `, [ticketId, month.startIso, month.endIso] ); const status = statusResult.rows[0] as { session_count: number; open_count: number }; if (status.session_count === 0) { res.status(404).json({ error: "No sessions found for this ticket and month" }); return; } if (status.open_count > 0) { res.status(409).json({ error: "Ticket has open sessions", openCount: status.open_count }); return; } const closureResult = await query( ` INSERT INTO ticket_month_closures (ticket_id, month) VALUES ($1, $2::date) ON CONFLICT (ticket_id, month) DO UPDATE SET closed_at = now() RETURNING *; `, [ticketId, month.start] ); res.json({ closure: closureResult.rows[0] }); }); 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) => { const ticketClosureResult = await client.query( ` DELETE FROM ticket_month_closures WHERE ticket_id = $1 AND month = $2::date RETURNING *; `, [ticketId, month.start] ); const monthClosureResult = await client.query( ` DELETE FROM month_closures WHERE month = $1::date RETURNING *; `, [month.start] ); return { ticketReopened: ticketClosureResult.rows.length > 0, monthReopened: monthClosureResult.rows.length > 0 }; }); res.json(result); }); app.post("/api/months/:month/close", requireUser, async (req, res) => { const month = parseMonth(String(req.params.month)); const blockersResult = await query( ` SELECT t.id AS ticket_id, t.ticket_number, COUNT(s.id)::int AS session_count, COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count, tm.closed_at IS NOT NULL AS ticket_closed FROM tickets t JOIN sessions s ON s.ticket_id = t.id LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz GROUP BY t.id, t.ticket_number, tm.closed_at HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 OR tm.closed_at IS NULL ORDER BY t.ticket_number ASC; `, [month.start, month.startIso, month.endIso] ); if (blockersResult.rows.length > 0) { res.status(409).json({ error: "Month has unfinished tickets", blockers: blockersResult.rows }); return; } const result = await query( ` INSERT INTO month_closures (month) VALUES ($1::date) ON CONFLICT (month) DO UPDATE SET closed_at = now() RETURNING *; `, [month.start] ); res.json({ closure: result.rows[0] }); }); app.post("/api/months/:month/reopen", requireUser, async (req, res) => { const month = parseMonth(String(req.params.month)); const result = await query( ` DELETE FROM month_closures WHERE month = $1::date RETURNING *; `, [month.start] ); res.json({ reopened: result.rows.length > 0 }); }); if (serveFrontend) { app.use(express.static(frontendDist)); app.get(/.*/, (_req, res) => { res.sendFile(path.join(frontendDist, "index.html")); }); } app.use((error: Error & { status?: number }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(error); res.status(error.status ?? 500).json({ error: error.status ? error.message : "Internal server error" }); }); async function start() { await migrate(); const server = app.listen(port, () => { console.log(`TicketTracker listening on port ${port}`); }); const shutdown = async () => { server.close(); await pool.end(); process.exit(0); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); } start().catch((error) => { console.error(error); process.exit(1); });