import "dotenv/config"; import cors from "cors"; import express from "express"; import { randomUUID, timingSafeEqual } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type pg from "pg"; 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 { decryptSecret, encryptSecret } from "./secrets.js"; import { databaseExportTables as backupTables, exportDatabaseSql, quoteIdentifier as quoteBackupIdentifier, restoreSqlText as backupRestoreSqlText, validateTicketTrackerBackup as validateBackupSql } from "./databaseExport.js"; import { badRequest, parseDay, parseBillingStatus, parseIsoDate, parseMonth, parsePositiveInteger, parseTicketNumber, parseTrackableTicketNumber, parseUsername, parseWorkType, requireString, ticketPattern } 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"; const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "250mb"; const zammadEmailWebhookToken = process.env.ZAMMAD_EMAIL_WEBHOOK_TOKEN ?? process.env.ZAMMAD_WEBHOOK_TOKEN ?? ""; app.use(cors()); app.use(express.text({ type: ["text/plain", "text/sql"], limit: requestBodyLimit })); app.use(express.json({ limit: requestBodyLimit })); type RestoreDebugState = { id: string; startedAt: string; finishedAt: string | null; phase: string; ok: boolean | null; error: string | null; sqlBytes: number; bodyBytes: number | null; checkedTables: number | null; issueCount: number | null; adminCount: number | null; }; let lastRestoreDebugState: RestoreDebugState | null = null; function logDebug(event: string, details: Record = {}) { console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`); } process.on("uncaughtException", (error) => { logDebug("process", { event: "uncaughtException", message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : null }); process.exit(1); }); process.on("unhandledRejection", (reason) => { logDebug("process", { event: "unhandledRejection", message: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : null }); }); app.use("/api", (req, res, next) => { const requestId = randomUUID(); const startedAt = Date.now(); logDebug("request-start", { requestId, method: req.method, path: req.originalUrl }); res.on("finish", () => { logDebug("request-finish", { requestId, method: req.method, path: req.originalUrl, status: res.statusCode, durationMs: Date.now() - startedAt }); }); next(); }); function updateRestoreDebug(id: string, update: Partial) { if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) { return; } lastRestoreDebugState = { ...lastRestoreDebugState, ...update }; logDebug("restore", { id, phase: lastRestoreDebugState.phase, ok: lastRestoreDebugState.ok, sqlBytes: lastRestoreDebugState.sqlBytes, bodyBytes: lastRestoreDebugState.bodyBytes, checkedTables: lastRestoreDebugState.checkedTables, issueCount: lastRestoreDebugState.issueCount, adminCount: lastRestoreDebugState.adminCount, error: lastRestoreDebugState.error }); } app.get("/api/health", async (_req, res) => { await query("SELECT 1"); res.json({ ok: true }); }); app.get("/api/debug/status", async (_req, res) => { const startedAt = Date.now(); const tableCounts: Record = {}; const adminResult = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); for (const table of backupTables) { const countResult = await query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${quoteBackupIdentifier(table)};`); tableCounts[table] = Number(countResult.rows[0]?.count ?? 0); } res.json({ ok: true, timestamp: new Date().toISOString(), uptimeSeconds: Math.round(process.uptime()), database: { ok: true, adminCount: Number(adminResult.rows[0]?.count ?? 0), tableCounts }, restore: lastRestoreDebugState, requestBodyLimit, durationMs: Date.now() - startedAt }); }); 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 optionalNullableString(value: unknown) { if (value === null || value === undefined) { return null; } if (typeof value !== "string") { throw badRequest("value must be a string or 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 parseOptionalTeamspaceEntryId(value: unknown) { if (value === undefined || value === null || value === "") { return null; } return parsePositiveInteger(value, "projectTeamspaceEntryId"); } function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) { if (value === undefined || value === null || value === "") { return fallback; } const minutes = Number(value); if (![5, 10, 15, 30].includes(minutes)) { throw badRequest("zammadTimeUnitRoundingMinutes must be 5, 10, 15 or 30"); } return minutes; } function parseZammadArticleTypeId(value: unknown, fallback = 6) { if (value === undefined || value === null || value === "") { return fallback ?? 6; } const typeId = Number(value); if (![1, 2, 3, 4, 5, 6].includes(typeId)) { throw badRequest("zammadArticleTypeId must be 1, 2, 3, 4, 5 or 6"); } return typeId; } function optionalTicketSessionMonth(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return null; } return parseMonth(String(value)); } function optionalTicketSessionStatus(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return "all" as const; } if (value === "open" || value === "billable" || value === "billed" || value === "non_billable") { return value; } throw badRequest("status must be all, open, billable, billed or non_billable"); } function optionalTicketSessionSource(value: unknown) { if (value === undefined || value === null || value === "" || value === "all") { return "all" as const; } if (value === "manual" || value === "recurring") { return value; } throw badRequest("source must be all, manual or recurring"); } async function hasAdminUser() { const result = await query<{ exists: boolean }>("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin') AS exists;"); return Boolean(result.rows[0]?.exists); } 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; }; type ProjectRow = { id: string; user_id?: string; owner_username?: string | null; owner_display_name?: string | null; can_edit?: boolean; ticket_id: string; ticket_number: string; title: string | null; organization_id: string | null; organization_name: string | null; customer_name: string | null; work_type: "support" | "consulting" | null; zammad_priority_id?: string | null; zammad_state_id?: string | null; zammad_pending_time?: string | null; teamspace_project: string | null; description: string; goals: string; starts_on: string | null; ends_on: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntryRow[]; closed_at: string | null; close_review_required?: boolean; created_at: string; updated_at: string; }; type ProjectTeamspaceEntryRow = { id: string; project_id?: string; teamspace_project: string | null; is_default: boolean; hour_budget_minutes: number | null; budget_sync_disabled?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; display_order?: number; }; type ProjectWithEntries = ProjectRow & { teamspace_entries: ProjectTeamspaceEntryRow[]; teamspace_entry_count: number; }; const zammadBaseUrlSettingKey = "zammad.base_url"; const zammadApiKeySettingKey = "zammad.api_key"; type QueryClient = { query: (text: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number | null }>; }; type ZammadTicketImport = { zammadTicketId: number; ticketNumber: string; zammadGroupId: number | null; zammadPriorityId: number | null; zammadStateId: number | null; organizationId: number; title: string | null; zammadOwnerId: number | null; workType: "support" | "consulting" | null; preferences: Record; teamspaceBudgetMinutes: number | null; teamspaceProject: string | null; teamspaceProjects: string[]; pendingTime: string | null; }; type ZammadSyncExistingFields = { title: boolean; organization: boolean; type: boolean; projectName: boolean; budget: boolean; overrideBudget: boolean; pendingTodo: boolean; }; type ZammadSyncChange = { field: string; from: unknown; to: unknown; }; type ZammadSyncItem = { ticketNumber: string; action: "update" | "create" | "skip" | "error"; reason: string | null; local: Record | null; zammad: Record | null; changes: ZammadSyncChange[]; }; type ZammadSyncResult = { mode: "preview" | "applied"; checked: number; updated: number; created: number; skipped: number; missingInZammad: number; organizationMissing: number; markedForCloseReview: number; convertedToConsulting: number; convertedToSupport: number; errors: Array<{ ticketNumber: string; error: string }>; items: ZammadSyncItem[]; }; async function getAppSetting(key: string) { const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); const value = result.rows[0]?.value ?? null; return key === zammadApiKeySettingKey ? decryptSecret(value) : value; } async function setAppSetting(key: string, value: string) { const storedValue = key === zammadApiKeySettingKey ? encryptSecret(value) : value; 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, storedValue] ); } async function fetchZammadUserIdForToken(apiToken: string) { const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey); if (!savedBaseUrl) { throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); } const baseUrl = parseZammadBaseUrl(savedBaseUrl); const response = await fetch(`${baseUrl}/api/v1/users/me`, { headers: { Authorization: `Token token=${apiToken}`, Accept: "application/json" } }); if (!response.ok) { const body = await response.text().catch(() => ""); throw Object.assign(new Error(`Zammad Token konnte nicht geprüft werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); } const payload = (await response.json()) as { id?: unknown }; const zammadUserId = Number(payload.id); if (!Number.isInteger(zammadUserId) || zammadUserId < 1) { throw Object.assign(new Error("Zammad Antwort enthält keine gültige User-ID"), { status: 502 }); } return zammadUserId; } function isPlainObject(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function numberOrNull(value: unknown) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; } function zammadStateIdOrNull(value: unknown) { const stateId = numberOrNull(value); return stateId === 1 ? 2 : stateId; } const zammadConsultingGroupId = 2; const zammadSupportGroupIds = [3, 4]; function isSupportedZammadGroupId(groupId: number | null) { return groupId === zammadConsultingGroupId || (groupId !== null && zammadSupportGroupIds.includes(groupId)); } function zammadWorkType(groupId: number | null) { if (groupId === zammadConsultingGroupId) { return "consulting" as const; } if (groupId !== null && zammadSupportGroupIds.includes(groupId)) { return "support" as const; } return null; } function parseZammadBudgetMinutes(value: unknown) { if (value === null || value === undefined || value === "") { return null; } const raw = typeof value === "string" ? value.trim().replace(",", ".") : value; const hours = Number(raw); if (!Number.isFinite(hours) || hours < 0) { return null; } return Math.round(hours * 60); } function parseZammadTeamspaceProjects(value: unknown) { if (typeof value !== "string") { return []; } return value.match(/[^\s+]+/g) ?? []; } function parseZammadPendingTime(value: unknown) { if (typeof value !== "string" || !value.trim()) { return null; } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function zammadPreferenceValue(ticket: Record, key: string) { const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {}; return preferences[key] ?? ticket[key]; } async function getZammadCredentialsForUser(userId: string) { const [savedBaseUrl, userResult] = await Promise.all([ getAppSetting(zammadBaseUrlSettingKey), query<{ zammad_api_token: string | null }>("SELECT zammad_api_token FROM users WHERE id = $1;", [userId]) ]); if (!savedBaseUrl) { throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); } const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token); if (!apiToken) { throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden"); } return { baseUrl: parseZammadBaseUrl(savedBaseUrl), apiToken }; } async function fetchZammadJson(baseUrl: string, apiToken: string, path: string) { const response = await fetch(`${baseUrl}${path}`, { headers: { Authorization: `Token token=${apiToken}`, Accept: "application/json" } }); if (!response.ok) { const body = await response.text().catch(() => ""); throw Object.assign(new Error(`Zammad Anfrage fehlgeschlagen (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); } return response.json() as Promise; } async function postZammadJson(baseUrl: string, apiToken: string, path: string, payload: Record) { const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { Authorization: `Token token=${apiToken}`, Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!response.ok) { const body = await response.text().catch(() => ""); throw Object.assign(new Error(`Zammad Article konnte nicht erstellt werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 }); } return response.json() as Promise; } function zammadTimeUnit(durationSeconds: number, roundingEnabled: boolean, roundingMinutes: number) { const minutes = durationSeconds / 60; const roundedMinutes = roundingEnabled ? Math.ceil(minutes / roundingMinutes) * roundingMinutes : minutes; return Number((roundedMinutes / 60).toFixed(2)); } function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } async function zammadTimeUnitForUser(userId: string, durationSeconds: number) { const userResult = await query<{ zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; }>( ` SELECT zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes FROM users WHERE id = $1; `, [userId] ); const userSettings = userResult.rows[0] ?? { zammad_time_unit_rounding_enabled: false, zammad_time_unit_rounding_minutes: 15 }; return zammadTimeUnit( durationSeconds, Boolean(userSettings.zammad_time_unit_rounding_enabled), Number(userSettings.zammad_time_unit_rounding_minutes ?? 15) ); } type ZammadSessionArticlePayload = { ticket_id: number; type: "note"; internal: true; subject: string; body: string; content_type: "text/plain"; time_unit: number; accounted_time_type_id: number; }; function zammadSessionArticlePayload(input: { zammadTicketId: string | number; activity: string; timeUnit: number; articleTypeId: number; }): ZammadSessionArticlePayload { const ticketId = Number(input.zammadTicketId); if (!Number.isInteger(ticketId) || ticketId < 1) { throw badRequest("Ticket hat keine gültige Zammad-Ticket-ID"); } return { ticket_id: ticketId, type: "note", internal: true, subject: "TicketTracker Session", body: input.activity, content_type: "text/plain", time_unit: input.timeUnit, accounted_time_type_id: input.articleTypeId }; } async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) { const credentials = await getZammadCredentialsForUser(userId); return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload); } async function enqueueZammadSessionArticle(client: pg.PoolClient, input: { userId: string; sessionId: string | number; ticketId: string | number; ticketNumber: string; zammadTicketId: string | number | null; activity: string; timeUnit: number; articleTypeId: number; error: string; }) { await client.query( ` INSERT INTO zammad_session_article_queue ( user_id, session_id, ticket_id, ticket_number, zammad_ticket_id, activity, time_unit, zammad_article_type_id, last_error ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (session_id) WHERE status = 'pending' DO UPDATE SET zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id), activity = EXCLUDED.activity, time_unit = EXCLUDED.time_unit, zammad_article_type_id = EXCLUDED.zammad_article_type_id, last_error = EXCLUDED.last_error, updated_at = now(); `, [ input.userId, input.sessionId, input.ticketId, input.ticketNumber, input.zammadTicketId, input.activity, input.timeUnit, input.articleTypeId, input.error.slice(0, 1000) ] ); } async function recordZammadSessionArticleSent(client: pg.PoolClient, input: { userId: string; sessionId: string | number; ticketId: string | number; ticketNumber: string; zammadTicketId: string | number; activity: string; timeUnit: number; articleTypeId: number; articleId: number | null; }) { await client.query( ` INSERT INTO zammad_session_article_queue ( user_id, session_id, ticket_id, ticket_number, zammad_ticket_id, activity, time_unit, zammad_article_type_id, status, sent_at, zammad_article_id, last_error ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'sent', now(), $9, NULL); `, [ input.userId, input.sessionId, input.ticketId, input.ticketNumber, input.zammadTicketId, input.activity, input.timeUnit, input.articleTypeId, input.articleId ] ); } function zammadArticleId(payload: unknown) { return isPlainObject(payload) ? numberOrNull(payload.id) : null; } type ZammadSessionArticleQueueRow = { id: string; user_id: string; session_id: string; ticket_id: string; ticket_number: string; zammad_ticket_id: string | null; activity: string; time_unit: string | number; zammad_article_type_id: number | null; }; async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) { let zammadTicketId = row.zammad_ticket_id; if (!zammadTicketId) { const zammadTicket = await fetchZammadTicketByNumber(row.user_id, row.ticket_number); if (!zammadTicket) { throw new Error("Ticket wurde in Zammad nicht gefunden"); } await withTransaction(async (client) => { await importZammadTicketForUser(client, zammadTicket, row.user_id); }); zammadTicketId = String(zammadTicket.zammadTicketId); await query( ` UPDATE zammad_session_article_queue SET zammad_ticket_id = $1, updated_at = now() WHERE id = $2; `, [zammadTicketId, row.id] ); } const payload = zammadSessionArticlePayload({ zammadTicketId, activity: row.activity, timeUnit: Number(row.time_unit), articleTypeId: Number(row.zammad_article_type_id ?? 6) }); const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload); const articleId = zammadArticleId(zammadResponse); await query( ` UPDATE zammad_session_article_queue SET status = 'sent', sent_at = now(), zammad_article_id = $1, last_error = NULL, last_attempt_at = now(), updated_at = now() WHERE id = $2; `, [articleId, row.id] ); return articleId; } function zammadTicketCandidates(payload: unknown): Record[] { if (Array.isArray(payload)) { return payload.filter(isPlainObject); } if (isPlainObject(payload)) { const nestedCandidates: Record[] = []; if (Array.isArray(payload.tickets)) { nestedCandidates.push(...payload.tickets.filter(isPlainObject)); } if (Array.isArray(payload.items)) { nestedCandidates.push(...payload.items.filter(isPlainObject)); } for (const key of ["assets", "ticket", "Ticket", "tickets"]) { const source = payload[key]; if (!isPlainObject(source) || Array.isArray(source)) { continue; } for (const value of Object.values(source)) { if (isPlainObject(value) && (Object.prototype.hasOwnProperty.call(value, "number") || Object.prototype.hasOwnProperty.call(value, "group_id"))) { nestedCandidates.push(value); } else if (isPlainObject(value)) { for (const nestedValue of Object.values(value)) { if (isPlainObject(nestedValue) && (Object.prototype.hasOwnProperty.call(nestedValue, "number") || Object.prototype.hasOwnProperty.call(nestedValue, "group_id"))) { nestedCandidates.push(nestedValue); } } } } } if (nestedCandidates.length > 0) { return nestedCandidates; } return Object.prototype.hasOwnProperty.call(payload, "number") || Object.prototype.hasOwnProperty.call(payload, "group_id") ? [payload] : []; } return []; } function zammadTicketCandidateIds(payload: unknown) { const ids = new Set(); const addId = (value: unknown) => { const id = numberOrNull(value); if (id) { ids.add(id); } }; if (Array.isArray(payload)) { payload.forEach((item) => { if (isPlainObject(item)) { addId(item.id); } else { addId(item); } }); } if (isPlainObject(payload)) { addId(payload.id); for (const key of ["tickets", "ticket_ids", "ids", "items", "result", "results"]) { const source = payload[key]; if (Array.isArray(source)) { source.forEach((item) => { if (isPlainObject(item)) { addId(item.id); } else { addId(item); } }); } } const assets = payload.assets; if (isPlainObject(assets)) { for (const [assetKey, value] of Object.entries(assets)) { if (!assetKey.toLowerCase().includes("ticket")) { continue; } if (isPlainObject(value)) { Object.keys(value).forEach(addId); } } } } return [...ids]; } function normalizedTicketNumberDigits(value: unknown) { if (typeof value !== "string" && typeof value !== "number") { return null; } const digits = String(value).replace(/^Ticket#/i, "").replace(/\D/g, ""); return digits.length > 0 ? digits : null; } function ticketNumberMatchesZammadNumber(localTicketNumber: string, zammadNumber: unknown) { if (typeof zammadNumber !== "string" && typeof zammadNumber !== "number") { return false; } const local = localTicketNumber.replace(/^Ticket#/i, "").trim(); const remote = String(zammadNumber).replace(/^Ticket#/i, "").trim(); const localDigits = normalizedTicketNumberDigits(local); const remoteDigits = normalizedTicketNumberDigits(remote); if (localDigits && remoteDigits) { return remoteDigits === localDigits || remoteDigits.replace(/^0+/, "") === localDigits.replace(/^0+/, ""); } return remote === local || remote.replace(/^0+/, "") === local.replace(/^0+/, ""); } function zammadTicketImportFromRaw(rawTicket: Record, fallbackTicketNumber: string | null = null): ZammadTicketImport | null { const zammadTicketId = numberOrNull(rawTicket.id); const zammadNumber = rawTicket.number; const ticketNumber = fallbackTicketNumber ?? (typeof zammadNumber === "string" || typeof zammadNumber === "number" ? `Ticket#${String(zammadNumber).trim()}` : null); const organizationId = numberOrNull(rawTicket.organization_id); if (!zammadTicketId || !ticketNumber || !organizationId || !ticketNumberMatchesZammadNumber(ticketNumber, zammadNumber)) { return null; } const groupId = numberOrNull(rawTicket.group_id); const workType = zammadWorkType(groupId); if (!workType) { return null; } const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project")); const pendingTime = parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time")); const storedPreferences = pendingTime ? { ...preferences, pending_time: pendingTime } : preferences; return { zammadTicketId, ticketNumber, zammadGroupId: groupId, zammadPriorityId: numberOrNull(rawTicket.priority_id), zammadStateId: zammadStateIdOrNull(rawTicket.state_id), organizationId, title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, zammadOwnerId: numberOrNull(rawTicket.owner_id), workType, preferences: storedPreferences, teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), teamspaceProject: teamspaceProjects[0] ?? null, teamspaceProjects, pendingTime }; } async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): Promise { const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); const zammadNumber = normalizedTicketNumberDigits(ticketNumber) ?? ticketNumber.replace(/^Ticket#/i, "").trim(); const supportGroupQueries = zammadSupportGroupIds.map((groupId) => `group_id:${groupId}`); for (const path of [ `/api/v1/ticket_by_number/${encodeURIComponent(zammadNumber)}`, `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber} group_id:${zammadConsultingGroupId}`)}`, ...supportGroupQueries.map((groupQuery) => `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber} ${groupQuery}`)}`), `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber}`)}`, `/api/v1/tickets/search?query=${encodeURIComponent(zammadNumber)}`, `/api/v1/search?query=${encodeURIComponent(`number:${zammadNumber}`)}&objects=Ticket`, `/api/v1/search?query=${encodeURIComponent(zammadNumber)}&objects=Ticket`, `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber} group_id:${zammadConsultingGroupId}`)}`, ...supportGroupQueries.map((groupQuery) => `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber} ${groupQuery}`)}`), `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber}`)}`, `/api/v1/tickets?query=${encodeURIComponent(zammadNumber)}`, "/api/v1/tickets" ]) { let payload: unknown; try { payload = await fetchZammadJson(baseUrl, apiToken, path); } catch (error: any) { if (path === "/api/v1/tickets") { throw error; } continue; } const candidate = zammadTicketCandidates(payload).find((ticket) => { const candidateGroupId = numberOrNull(ticket.group_id); return ticketNumberMatchesZammadNumber(ticketNumber, ticket.number) && (!candidateGroupId || isSupportedZammadGroupId(candidateGroupId)); }); const zammadTicketId = numberOrNull(candidate?.id); if (zammadTicketId) { const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${zammadTicketId}`); const rawTicket = isPlainObject(detail) ? detail : candidate; if (!rawTicket) { return null; } return zammadTicketImportFromRaw(rawTicket, ticketNumber); } for (const candidateId of zammadTicketCandidateIds(payload).slice(0, 25)) { const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${candidateId}`); const rawTicket = isPlainObject(detail) ? detail : null; const zammadTicket = rawTicket ? zammadTicketImportFromRaw(rawTicket, ticketNumber) : null; if (zammadTicket) { return zammadTicket; } } } return null; } type SessionCreationInput = { userId: string; ticketNumber: string; activity: string; requestedOrganizationId: number | null; requestedWorkType: "support" | "consulting" | null; useZammadLookup: boolean; requestedProjectTeamspaceEntryId: number | null; billingStatus: "billable" | "billed" | "non_billable" | null; createZammadArticle: boolean; zammadArticleTypeId: number; startedAt: Date; endedAt: Date; durationSeconds: number; zammadTicket?: ZammadTicketImport | null; sourceEvent?: { zammadArticleId: number | null; deliveryId: string | null; }; }; async function createSessionForUser(input: SessionCreationInput) { const { userId, ticketNumber, activity, requestedOrganizationId, requestedWorkType, useZammadLookup, requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, zammadArticleTypeId, startedAt, endedAt, durationSeconds, zammadTicket: providedZammadTicket, sourceEvent } = input; if (endedAt < startedAt) { throw badRequest("endedAt must be after startedAt"); } if (ticketNumber.startsWith("Fix#") || (await ticketHasRecurringBilling({ query }, ticketNumber, userId))) { throw Object.assign(new Error("Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten."), { status: 409 }); } const monthClosureResult = await query<{ closed_at: string }>( ` SELECT closed_at FROM month_closures WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date AND user_id = $2; `, [startedAt.toISOString(), userId] ); if (monthClosureResult.rows.length > 0) { throw Object.assign(new Error("Monat ist abgeschlossen"), { status: 409 }); } const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; const zammadTicket = providedZammadTicket !== undefined ? providedZammadTicket : useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(userId, ticketNumber) : null; if (useZammadLookup && !existingLocalTicket && !zammadTicket) { throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 }); } if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { await query( ` DELETE FROM zammad_email_session_events WHERE user_id = $1 AND session_id IS NULL AND ( ($2::bigint IS NOT NULL AND zammad_article_id = $2) OR ($3::text IS NOT NULL AND delivery_id = $3) ); `, [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] ); } return withTransaction(async (client) => { let eventId: string | null = null; if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { const existingEventResult = await client.query<{ id: string }>( ` SELECT id::text AS id FROM zammad_email_session_events WHERE user_id = $1 AND session_id IS NOT NULL AND ( ($2::bigint IS NOT NULL AND zammad_article_id = $2) OR ($3::text IS NOT NULL AND delivery_id = $3) ) LIMIT 1; `, [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] ); if (existingEventResult.rows.length > 0) { return { duplicate: true as const }; } const eventResult = await client.query<{ id: string }>( ` INSERT INTO zammad_email_session_events (user_id, zammad_article_id, delivery_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING id::text AS id; `, [userId, sourceEvent.zammadArticleId, sourceEvent.deliveryId] ); if (eventResult.rows.length === 0) { return { duplicate: true as const }; } eventId = eventResult.rows[0].id; } if (zammadTicket) { await importZammadTicketForUser(client, zammadTicket, userId); } const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null; project_id: string | null }>( ` SELECT t.id, t.organization_id, t.customer_name, t.work_type, p.id AS project_id FROM tickets t LEFT JOIN projects p ON p.ticket_id = t.id WHERE t.ticket_number = $1 ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC LIMIT 1 FOR UPDATE OF t; `, [ticketNumber, userId] ); const existingTicket = existingTicketResult.rows[0] ?? null; const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; if (!effectiveOrganizationId) { throw badRequest("organizationId is required"); } const organization = await getOrganizationByZammadId(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; zammad_ticket_id: 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 = CASE WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' WHEN EXISTS ( SELECT 1 FROM projects p WHERE p.ticket_id = tickets.id AND p.user_id = $5 ) THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id; `, [ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] ); const ticket = ticketResult.rows[0]; const workType = existingTicket?.project_id ? "consulting" : ticket.work_type ?? requestedWorkType; if (!workType) { throw badRequest("workType must be support or consulting"); } let projectTeamspaceEntryId: number | null = null; if (workType === "consulting") { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner( client, zammadTicket?.zammadOwnerId ?? null, userId ); await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { teamspaceProject: zammadTicket?.teamspaceProject ?? null, teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, closeReviewRequired: zammadTicket?.zammadStateId === 4 }); projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( client, ticket.id, userId, workType, requestedProjectTeamspaceEntryId, zammadTicket?.zammadOwnerId ?? null ); } 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, project_teamspace_entry_id, billing_status, billing_updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CASE WHEN $12::text IS NULL THEN NULL ELSE now() END) RETURNING *; `, [ ticket.id, organization.id, organization.name, activity, workType, userId, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, projectTeamspaceEntryId, billingStatus ] ); const session = sessionResult.rows[0]; const zammadArticle: { requested: boolean; status: "not_requested" | "sent" | "queued"; error: string | null; articleId: number | null; } = { requested: createZammadArticle, status: createZammadArticle ? "queued" : "not_requested", error: null, articleId: null }; if (createZammadArticle) { const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds); let zammadArticleTicketId = ticket.zammad_ticket_id; let zammadArticleError: string | null = null; if (!zammadArticleTicketId && useZammadLookup) { try { const articleZammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); if (articleZammadTicket) { await importZammadTicketForUser(client, articleZammadTicket, userId); zammadArticleTicketId = String(articleZammadTicket.zammadTicketId); } else { zammadArticleError = "Ticket wurde in Zammad nicht gefunden"; } } catch (error) { zammadArticleError = errorMessage(error); } } if (!zammadArticleError && zammadArticleTicketId) { try { const payload = zammadSessionArticlePayload({ zammadTicketId: zammadArticleTicketId, activity, timeUnit, articleTypeId: zammadArticleTypeId }); const zammadResponse = await postZammadSessionArticleForUser(userId, payload); zammadArticle.status = "sent"; zammadArticle.articleId = zammadArticleId(zammadResponse); await recordZammadSessionArticleSent(client, { userId, sessionId: session.id, ticketId: ticket.id, ticketNumber, zammadTicketId: zammadArticleTicketId, activity, timeUnit, articleTypeId: zammadArticleTypeId, articleId: zammadArticle.articleId }); } catch (error) { zammadArticleError = errorMessage(error); } } else if (!zammadArticleError) { zammadArticleError = "Ticket hat keine Zammad-Ticket-ID"; } if (zammadArticleError) { zammadArticle.status = "queued"; zammadArticle.error = zammadArticleError; await enqueueZammadSessionArticle(client, { userId, sessionId: session.id, ticketId: ticket.id, ticketNumber, zammadTicketId: zammadArticleTicketId, activity, timeUnit, articleTypeId: zammadArticleTypeId, error: zammadArticleError }); } } await reopenPeriodsForSession(client, ticket.id, startedAt, userId); if (workType === "consulting") { await reopenProjectsForTicket(client, ticket.id); await showProjectsForUserTicket(client, ticket.id, userId); } if (eventId) { await client.query( ` UPDATE zammad_email_session_events SET session_id = $1 WHERE id = $2; `, [session.id, eventId] ); } return { ticket, session, zammadArticle }; }); } function timingSafeStringEqual(left: string, right: string) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); } function verifyZammadEmailWebhookAuth(req: express.Request) { if (!zammadEmailWebhookToken) { return false; } const authorization = req.header("authorization") ?? ""; const bearerPrefix = "Bearer "; const token = authorization.startsWith(bearerPrefix) ? authorization.slice(bearerPrefix.length).trim() : optionalString(req.header("x-tickettracker-webhook-token")); return Boolean(token && timingSafeStringEqual(token, zammadEmailWebhookToken)); } function stringValue(value: unknown) { if (typeof value === "string") { const trimmed = value.trim(); return /^#\{.+\/\s*no such (?:method|object)\}$/i.test(trimmed) ? "" : trimmed; } if (typeof value === "number") { return String(value); } return ""; } function lowerString(value: unknown) { return stringValue(value).toLowerCase(); } function zammadWebhookObject(payload: unknown, key: string) { const value = isPlainObject(payload) ? payload[key] : null; return isPlainObject(value) ? value : {}; } function normalizeZammadWebhookTicketNumber(value: unknown) { const raw = stringValue(value); if (!raw) { return null; } if (ticketPattern.test(raw)) { return raw; } const digits = raw.replace(/^Ticket#/i, "").replace(/\D/g, ""); return digits ? `Ticket#${digits.padStart(6, "0")}` : null; } function parseZammadWebhookTicketNumber(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const candidates = [ isPlainObject(payload) ? payload.ticketNumber : null, isPlainObject(payload) ? payload.ticket_number : null, ticket.number, ticket.ticket_number ]; for (const candidate of candidates) { const ticketNumber = normalizeZammadWebhookTicketNumber(candidate); if (ticketNumber && ticketPattern.test(ticketNumber)) { return ticketNumber; } } return null; } function parseZammadWebhookDate(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const article = zammadWebhookObject(payload, "article"); const candidates = [ isPlainObject(payload) ? payload.updatedAt : null, isPlainObject(payload) ? payload.updated_at : null, ticket.updated_at, article.updated_at, article.created_at ]; for (const candidate of candidates) { const raw = stringValue(candidate); const date = raw ? new Date(raw) : null; if (date && !Number.isNaN(date.getTime())) { return date; } } return null; } function parsePositiveNumber(value: unknown) { if (value === null || value === undefined || value === "") { return null; } const number = Number(typeof value === "string" ? value.trim().replace(",", ".") : value); return Number.isFinite(number) && number > 0 ? number : null; } function parseZammadWebhookDurationSeconds(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const article = zammadWebhookObject(payload, "article"); const durationSeconds = parsePositiveNumber(isPlainObject(payload) ? payload.durationSeconds ?? payload.duration_seconds : null); if (durationSeconds) { return Math.round(durationSeconds); } const accountedTime = parsePositiveNumber(isPlainObject(payload) ? payload.accounted_time : null) ?? parsePositiveNumber(article.accounted_time); if (accountedTime) { return Math.round(accountedTime * 60 * 60); } const minuteCandidates = [ isPlainObject(payload) ? payload.durationMinutes : null, isPlainObject(payload) ? payload.duration_minutes : null, article.time_unit, ticket.time_unit ]; for (const candidate of minuteCandidates) { const minutes = parsePositiveNumber(candidate); if (minutes) { return Math.round(minutes * 60); } } return null; } function extractRecipientAddress(value: unknown) { const raw = Array.isArray(value) ? value.map(stringValue).filter(Boolean).join(", ") : stringValue(value); if (!raw) { return null; } const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); return match?.[0] ?? raw; } function zammadWebhookRecipient(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const article = zammadWebhookObject(payload, "article"); const customer = isPlainObject(ticket.customer) ? ticket.customer : {}; return extractRecipientAddress( isPlainObject(payload) ? payload.to ?? payload.recipient ?? payload.email : null ) ?? extractRecipientAddress(article.to) ?? extractRecipientAddress(customer.email); } function parseZammadWebhookAgentId(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); const article = zammadWebhookObject(payload, "article"); const user = zammadWebhookObject(payload, "user"); const createdBy = isPlainObject(article.created_by) ? article.created_by : {}; const updatedBy = isPlainObject(article.updated_by) ? article.updated_by : {}; const candidates = [ isPlainObject(payload) ? payload.zammadUserId : null, isPlainObject(payload) ? payload.zammad_user_id : null, isPlainObject(payload) ? payload.user_id : null, isPlainObject(payload) ? payload.created_by_id : null, isPlainObject(payload) ? payload.updatedById : null, isPlainObject(payload) ? payload.updated_by_id : null, user.id, article.created_by_id, article.updated_by_id, createdBy.id, updatedBy.id, ticket.updated_by_id ]; for (const candidate of candidates) { const id = numberOrNull(candidate); if (id) { return id; } } return null; } function parseZammadWebhookArticleId(payload: unknown) { const article = zammadWebhookObject(payload, "article"); return numberOrNull(isPlainObject(payload) ? payload.article_id : null) ?? numberOrNull(article.id); } function parseZammadWebhookGroupId(payload: unknown) { const ticket = zammadWebhookObject(payload, "ticket"); return numberOrNull(isPlainObject(payload) ? payload.group_id : null) ?? numberOrNull(ticket.group_id); } function zammadTicketImportFromWebhookPayload(payload: unknown, fallbackTicketNumber: string) { const ticket = zammadWebhookObject(payload, "ticket"); const group = isPlainObject(ticket.group) ? ticket.group : {}; const organization = isPlainObject(ticket.organization) ? ticket.organization : {}; const owner = isPlainObject(ticket.owner) ? ticket.owner : {}; const priority = isPlainObject(ticket.priority) ? ticket.priority : {}; const rawTicket = { ...ticket, id: ticket.id, number: ticket.number, group_id: ticket.group_id ?? group.id, organization_id: ticket.organization_id ?? organization.id, owner_id: ticket.owner_id ?? owner.id, priority_id: ticket.priority_id ?? priority.id, state_id: ticket.state_id, title: ticket.title, preferences: isPlainObject(ticket.preferences) ? ticket.preferences : {} }; return zammadTicketImportFromRaw(rawTicket, fallbackTicketNumber); } function zammadWebhookIsOutgoingEmail(payload: unknown) { const article = zammadWebhookObject(payload, "article"); const type = lowerString(isPlainObject(payload) ? payload.article_type ?? payload.type : null) || lowerString(article.type); const sender = lowerString(isPlainObject(payload) ? payload.sender : null) || lowerString(article.sender); if (type && type !== "email") { return false; } if (sender && sender !== "agent") { return false; } return true; } function zammadWebhookPayloadKeys(payload: unknown) { return isPlainObject(payload) ? Object.keys(payload).slice(0, 50) : []; } function zammadWebhookDebugSummary(payload: unknown) { const article = zammadWebhookObject(payload, "article"); return { payloadKeys: zammadWebhookPayloadKeys(payload), ticketNumber: parseZammadWebhookTicketNumber(payload), zammadUserId: parseZammadWebhookAgentId(payload), articleId: parseZammadWebhookArticleId(payload), groupId: parseZammadWebhookGroupId(payload), recipient: zammadWebhookRecipient(payload), endedAt: parseZammadWebhookDate(payload)?.toISOString() ?? null, accountedTime: isPlainObject(payload) ? payload.accounted_time ?? article.accounted_time ?? null : article.accounted_time ?? null, durationSeconds: parseZammadWebhookDurationSeconds(payload), outgoingEmail: zammadWebhookIsOutgoingEmail(payload) }; } async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId: string | null) { if (!zammadWebhookIsOutgoingEmail(payload)) { return { created: false, skipped: true, reason: "not-outgoing-email" }; } const zammadUserId = parseZammadWebhookAgentId(payload); if (!zammadUserId) { return { created: false, skipped: true, reason: "missing-zammad-user-id" }; } const userResult = await query<{ id: string; enabled: boolean; has_zammad_api_token: boolean }>( ` SELECT id::text AS id, zammad_email_session_webhook_enabled AS enabled, (zammad_api_token IS NOT NULL) AS has_zammad_api_token FROM users WHERE role = 'user' AND active AND zammad_user_id = $1 ORDER BY zammad_email_session_webhook_enabled DESC, id ASC; `, [zammadUserId] ); const user = userResult.rows[0] ?? null; if (!user) { return { created: false, skipped: true, reason: "unknown-user" }; } if (!user.enabled) { return { created: false, skipped: true, reason: "disabled" }; } if (!user.has_zammad_api_token) { return { created: false, skipped: true, reason: "missing-zammad-token" }; } const ticketNumber = parseZammadWebhookTicketNumber(payload); const endedAt = parseZammadWebhookDate(payload); const durationSeconds = parseZammadWebhookDurationSeconds(payload); const recipient = zammadWebhookRecipient(payload); if (!ticketNumber) { return { created: false, skipped: true, reason: "missing-ticket-number" }; } if (!endedAt) { return { created: false, skipped: true, reason: "missing-updated-at" }; } if (!durationSeconds) { return { created: false, skipped: true, reason: "missing-duration" }; } if (!recipient) { return { created: false, skipped: true, reason: "missing-recipient" }; } const payloadGroupId = parseZammadWebhookGroupId(payload); if (payloadGroupId && !isSupportedZammadGroupId(payloadGroupId)) { return { created: false, skipped: true, reason: "unsupported-zammad-type" }; } const existingLocalTicket = await ticketExists(ticketNumber); const payloadZammadTicket = zammadTicketImportFromWebhookPayload(payload, ticketNumber); const zammadTicket = existingLocalTicket ? null : payloadZammadTicket ?? await fetchZammadTicketByNumber(user.id, ticketNumber); if (!existingLocalTicket && !zammadTicket) { return { created: false, skipped: true, reason: "unsupported-or-missing-zammad-ticket" }; } const startedAt = new Date(endedAt.getTime() - durationSeconds * 1000); const result = await createSessionForUser({ userId: user.id, ticketNumber, activity: `eMail an ${recipient} gesendet`, requestedOrganizationId: null, requestedWorkType: null, useZammadLookup: true, requestedProjectTeamspaceEntryId: null, billingStatus: null, createZammadArticle: false, zammadArticleTypeId: 6, startedAt, endedAt, durationSeconds, zammadTicket, sourceEvent: { zammadArticleId: parseZammadWebhookArticleId(payload), deliveryId } }); if ("duplicate" in result) { return { created: false, skipped: true, reason: "duplicate" }; } return { created: true, ticket: result.ticket, session: result.session }; } async function fetchZammadConsultingTicketsForOwner(userId: string): Promise { const userResult = await query<{ zammad_user_id: string | null }>("SELECT zammad_user_id::text AS zammad_user_id FROM users WHERE id = $1;", [userId]); const zammadUserId = userResult.rows[0]?.zammad_user_id; if (!zammadUserId) { throw badRequest("Zammad User-ID fehlt. Bitte den persönlichen Zammad-Token im Profil speichern."); } const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); const candidates = new Map>(); for (const path of [ `/api/v1/tickets/search?query=${encodeURIComponent(`owner_id:${zammadUserId} group_id:${zammadConsultingGroupId}`)}`, `/api/v1/tickets?query=${encodeURIComponent(`owner_id:${zammadUserId} group_id:${zammadConsultingGroupId}`)}`, "/api/v1/tickets" ]) { let payload: unknown; try { payload = await fetchZammadJson(baseUrl, apiToken, path); } catch (error) { if (path === "/api/v1/tickets") { throw error; } continue; } for (const ticket of zammadTicketCandidates(payload)) { const id = numberOrNull(ticket.id); if ( id && (path !== "/api/v1/tickets" || (numberOrNull(ticket.owner_id) === Number(zammadUserId) && numberOrNull(ticket.group_id) === zammadConsultingGroupId)) ) { candidates.set(id, ticket); } } if (candidates.size > 0 && path !== "/api/v1/tickets") { break; } } const tickets: ZammadTicketImport[] = []; for (const [id, candidate] of candidates.entries()) { const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${id}`); const rawTicket = isPlainObject(detail) ? detail : candidate; const zammadTicket = zammadTicketImportFromRaw(rawTicket); if (zammadTicket && zammadTicket.workType === "consulting" && zammadTicket.zammadOwnerId === Number(zammadUserId) && zammadTicket.zammadStateId !== 4) { tickets.push(zammadTicket); } } return tickets; } async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { if (!zammadOwnerId) { return fallbackUserId; } const result = await client.query<{ id: string }>( ` SELECT id::text AS id FROM users WHERE zammad_user_id = $1 AND active ORDER BY id ASC LIMIT 1; `, [zammadOwnerId] ); return result.rows[0]?.id ?? fallbackUserId; } async function getOrganizationByZammadId( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> }, zammadId: number ) { const result = await client.query( ` SELECT zammad_id::text AS id, zammad_id::text AS zammad_id, name, synced_at FROM organizations WHERE zammad_id = $1; `, [zammadId] ); return result.rows[0] ?? null; } async function importZammadTicketForUser(client: QueryClient, zammadTicket: ZammadTicketImport, fallbackUserId: string) { const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); if (!organization) { throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); } const result = await client.query<{ id: string }>( ` INSERT INTO tickets ( ticket_number, title, organization_id, customer_name, work_type, zammad_ticket_id, zammad_group_id, zammad_priority_id, zammad_state_id, zammad_owner_id, zammad_preferences ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb) ON CONFLICT (ticket_number) DO UPDATE SET ticket_number = tickets.ticket_number RETURNING id::text; `, [ zammadTicket.ticketNumber, zammadTicket.title, organization.id, organization.name, zammadTicket.workType, zammadTicket.zammadTicketId, zammadTicket.zammadGroupId, zammadTicket.zammadPriorityId, zammadTicket.zammadStateId, zammadTicket.zammadOwnerId, JSON.stringify(zammadTicket.preferences) ] ); if (zammadTicket.workType === "consulting") { const ownerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, fallbackUserId); await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4 }); } return result.rows[0].id; } async function ticketExists(ticketNumber: string) { const result = await query<{ exists: boolean }>( ` SELECT EXISTS ( SELECT 1 FROM tickets WHERE ticket_number = $1 ) AS exists; `, [ticketNumber] ); return Boolean(result.rows[0]?.exists); } 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 dashboardPreferenceKey = "dashboard"; function parseDashboardPreferenceValue(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw badRequest("dashboard preference must be an object"); } const encoded = JSON.stringify(value); if (encoded.length > 100_000) { throw badRequest("dashboard preference is too large"); } return value as Record; } async function getUserPreference(userId: string, key: string) { const result = await query<{ value: Record }>( "SELECT value FROM user_preferences WHERE user_id = $1 AND key = $2;", [userId, key] ); return result.rows[0]?.value ?? {}; } async function setUserPreference(userId: string, key: string, value: Record) { const result = await query<{ value: Record }>( ` INSERT INTO user_preferences (user_id, key, value, updated_at) VALUES ($1, $2, $3::jsonb, now()) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now() RETURNING value; `, [userId, key, JSON.stringify(value)] ); return result.rows[0]?.value ?? value; } function readDashboardFavorites(preference: Record) { const favorites = Array.isArray(preference.favorites) ? preference.favorites : []; return favorites .map((favorite, index) => { if (!favorite || typeof favorite !== "object" || Array.isArray(favorite)) { return null; } const entry = favorite as Record; const ticketNumber = typeof entry.ticketNumber === "string" ? entry.ticketNumber : null; if (!ticketNumber) { return null; } return { ticketNumber, addedAt: typeof entry.addedAt === "string" ? entry.addedAt : new Date().toISOString(), sortOrder: Number.isInteger(entry.sortOrder) ? Number(entry.sortOrder) : index }; }) .filter((favorite): favorite is { ticketNumber: string; addedAt: string; sortOrder: number } => Boolean(favorite)); } function parseOptionalDateOnly(value: unknown, field: string) { if (value === undefined || value === null || value === "") { return null; } return parseDay(String(value)).start; } function parseOptionalNonNegativeInteger(value: unknown, field: string) { if (value === undefined || value === null || value === "") { return null; } const number = Number(value); if (!Number.isInteger(number) || number < 0) { throw badRequest(`${field} must be a non-negative integer`); } return number; } function parseProjectTeamspaceEntries( value: unknown, fallback: { teamspaceProject: string | null; hourBudgetMinutes: number | null; manualConsumptionMinutes: number } ) { const source = Array.isArray(value) && value.length > 0 ? value : [ { teamspaceProject: fallback.teamspaceProject, isDefault: true, hourBudgetMinutes: fallback.hourBudgetMinutes, manualConsumptionMinutes: fallback.manualConsumptionMinutes } ]; if (source.length > 20) { throw badRequest("teamspaceEntries must contain at most 20 entries"); } let hasDefault = false; const entries = source.map((item, index) => { if (!item || typeof item !== "object" || Array.isArray(item)) { throw badRequest("teamspaceEntries must be an array of objects"); } const entry = item as Record; const isDefault = entry.isDefault === undefined ? index === 0 : optionalBoolean(entry.isDefault, index === 0); if (isDefault) { if (hasDefault) { throw badRequest("Only one teamspace entry can be default"); } hasDefault = true; } return { id: parseOptionalTeamspaceEntryId(entry.id), teamspaceProject: optionalString(entry.teamspaceProject), isDefault, hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"), budgetSyncDisabled: optionalBoolean(entry.budgetSyncDisabled, false), manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0, displayOrder: index * 1024 }; }); if (!hasDefault) { entries[0] = { ...entries[0], isDefault: true }; } return entries; } type ParsedProjectTeamspaceEntry = ReturnType[number]; function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) { return (left ?? null) !== (right ?? null); } function protectNewManualBudgetEntries(entries: ParsedProjectTeamspaceEntry[]) { return entries.map((entry) => entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry); } function protectChangedManualBudgetEntries( entries: ParsedProjectTeamspaceEntry[], currentEntries: Array<{ id: string; hour_budget_minutes: number | null }> ) { const currentById = new Map(currentEntries.map((entry) => [entry.id, entry.hour_budget_minutes])); return entries.map((entry) => { const currentBudget = entry.id ? currentById.get(String(entry.id)) : undefined; const manuallyChanged = entry.id ? currentBudget !== undefined && budgetMinutesChanged(currentBudget, entry.hourBudgetMinutes) : entry.hourBudgetMinutes !== null; return manuallyChanged ? { ...entry, budgetSyncDisabled: true } : entry; }); } function parseTodoPriority(value: unknown) { if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") { throw badRequest("priority must be low, medium, high or urgent"); } return value; } function parseTodoStatus(value: unknown) { if (value !== "open" && value !== "done") { throw badRequest("status must be open or done"); } return value; } function parseTodoOrderIds(value: unknown) { if (!Array.isArray(value)) { throw badRequest("todoIds must be an array"); } return value.map((item) => parsePositiveInteger(item, "todoId")); } 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.zammad_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 = CASE WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id; `, [rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type] ); if (rule.work_type === "consulting") { await ensureProjectForTicket(client, ticketResult.rows[0].id, userId); } 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 ) AND NOT EXISTS ( SELECT 1 FROM projects p WHERE p.ticket_id = t.id ) AND NOT EXISTS ( SELECT 1 FROM ticket_todos tt WHERE tt.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 ensureProjectForTicket( client: QueryClient, ticketId: string, userId: string, defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {} ) { const teamspaceProjects = defaults.teamspaceProjects?.length ? defaults.teamspaceProjects : defaults.teamspaceProject ? [defaults.teamspaceProject] : []; const teamspaceProjectDefault = teamspaceProjects[0] ?? (Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject") ? defaults.teamspaceProject ?? "" : null); const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( ` INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required) SELECT $2, t.id, COALESCE($3, t.title, ''), '', '', $4::boolean FROM tickets t WHERE t.id = $1 ON CONFLICT (user_id, ticket_id) DO NOTHING RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes; `, [ticketId, userId, teamspaceProjectDefault, Boolean(defaults.closeReviewRequired)] ); if ("rows" in result && result.rows[0]) { if (defaults.hourBudgetMinutes !== undefined && defaults.hourBudgetMinutes !== null) { await client.query( ` UPDATE projects SET hour_budget_minutes = $1, updated_at = now() WHERE id = $2; `, [defaults.hourBudgetMinutes, result.rows[0].id] ); result.rows[0].hour_budget_minutes = defaults.hourBudgetMinutes; } await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, true, $3, false, $4, 0) ON CONFLICT DO NOTHING; `, [ result.rows[0].id, result.rows[0].teamspace_project, result.rows[0].hour_budget_minutes, result.rows[0].manual_consumption_minutes ?? 0 ] ); } const projectResult = result.rows[0] ? { rows: [{ id: result.rows[0].id }] } : await client.query<{ id: string }>( ` SELECT id::text AS id FROM projects WHERE ticket_id = $1 AND user_id = $2 LIMIT 1; `, [ticketId, userId] ); const projectId = projectResult.rows[0]?.id; if (result.rows[0] && projectId && teamspaceProjects.length > 1) { await syncProjectTeamspaceEntriesFromZammad(client, projectId, teamspaceProjects, defaults.hourBudgetMinutes ?? null, false); } if (defaults.closeReviewRequired) { await client.query( ` UPDATE projects SET close_review_required = true, updated_at = now() WHERE ticket_id = $1 AND user_id = $2; `, [ticketId, userId] ); } } async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string, viewerUserId: string) { const result = await client.query( ` SELECT p.id::text AS id, p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, (p.user_id = $3::bigint) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $3 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(viewer_sessions.session_count, 0)::int AS session_count, COALESCE(viewer_sessions.recurring_session_count, 0)::int AS recurring_session_count, COALESCE(viewer_sessions.manual_session_count, 0)::int AS manual_session_count, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, COALESCE(todo_totals.open_todos, 0)::int AS open_todos, COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos, p.closed_at, p.close_review_required, p.created_at, p.updated_at FROM projects p JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT COUNT(*)::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 FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $3 ) viewer_sessions ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( json_agg( json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC ), '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $3 AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL ) unassigned_sessions ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos, COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos FROM ticket_todos tt WHERE tt.ticket_id = p.ticket_id AND tt.user_id = $3 ) todo_totals ON true WHERE p.ticket_id = $1 AND p.user_id = $2; `, [ticketId, projectOwnerUserId, viewerUserId] ); return result.rows[0] ?? null; } async function resolveProjectTeamspaceEntryForSession( client: QueryClient, ticketId: string, userId: string, workType: "support" | "consulting", requestedEntryId: number | null, zammadOwnerId: number | null = null ) { if (workType !== "consulting") { return null; } const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadOwnerId, userId); const projectResult = await client.query<{ id: string }>( ` SELECT p.id::text AS id FROM projects p WHERE p.ticket_id = $1 ORDER BY (p.user_id = $2::bigint) DESC, (p.user_id = $3::bigint) DESC, p.id ASC LIMIT 1; `, [ticketId, projectOwnerUserId, userId] ); const projectId = projectResult.rows[0]?.id; if (!projectId) { return null; } const entriesResult = await client.query<{ id: string }>( ` SELECT id::text AS id FROM project_teamspace_entries WHERE project_id = $1 ORDER BY is_default DESC, display_order ASC, id ASC; `, [projectId] ); if (entriesResult.rows.length === 0) { return null; } if (entriesResult.rows.length === 1) { return Number(entriesResult.rows[0].id); } if (!requestedEntryId) { throw badRequest("projectTeamspaceEntryId is required when a project has multiple Teamspace entries"); } if (!entriesResult.rows.some((entry) => entry.id === String(requestedEntryId))) { throw badRequest("projectTeamspaceEntryId must belong to the project"); } return requestedEntryId; } async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticketId: string, userId: string | null = null) { await client.query( ` WITH single_entry_projects AS ( SELECT p.user_id, p.ticket_id, MIN(pte.id) AS project_teamspace_entry_id FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id WHERE p.ticket_id = $1 AND ($2::bigint IS NULL OR p.user_id = $2) GROUP BY p.id, p.user_id, p.ticket_id HAVING COUNT(pte.id) = 1 ) UPDATE sessions s SET project_teamspace_entry_id = sep.project_teamspace_entry_id FROM single_entry_projects sep WHERE s.ticket_id = sep.ticket_id AND s.user_id = sep.user_id AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL; `, [ticketId, userId] ); await client.query( ` WITH single_entry_tickets AS ( SELECT p.ticket_id, MIN(pte.id) AS project_teamspace_entry_id FROM projects p JOIN project_teamspace_entries pte ON pte.project_id = p.id WHERE p.ticket_id = $1 GROUP BY p.ticket_id HAVING COUNT(pte.id) = 1 ) UPDATE sessions s SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id FROM single_entry_tickets single_ticket WHERE s.ticket_id = single_ticket.ticket_id AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL AND ($2::bigint IS NULL OR s.user_id = $2); `, [ticketId, userId] ); } async function reopenProjectsForTicket(client: QueryClient, ticketId: string) { await client.query( ` UPDATE projects SET closed_at = NULL, updated_at = now() WHERE ticket_id = $1 AND closed_at IS NOT NULL; `, [ticketId] ); } async function showProjectsForUserTicket(client: QueryClient, ticketId: string, userId: string) { await client.query( ` DELETE FROM hidden_user_projects hup USING projects p WHERE p.id = hup.project_id AND p.ticket_id = $1 AND hup.user_id = $2; `, [ticketId, userId] ); } async function deleteProjectDataForTicket( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, userId: string ) { await client.query( ` DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1 AND user_id = $2; `, [ticketId, userId] ); await client.query( ` DELETE FROM ticket_day_billings WHERE ticket_id = $1 AND user_id = $2; `, [ticketId, userId] ); await client.query( ` DELETE FROM projects WHERE ticket_id = $1 AND user_id = $2; `, [ticketId, userId] ); } async function deleteAllProjectDataForTicket(client: QueryClient, ticketId: string) { await client.query("DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1;", [ticketId]); await client.query("DELETE FROM ticket_day_billings WHERE ticket_id = $1;", [ticketId]); await client.query("DELETE FROM projects WHERE ticket_id = $1;", [ticketId]); } async function ticketHasRecurringBilling( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: { exists: boolean }[] }> }, ticketNumber: string, userId: string ) { const result = await client.query( ` SELECT EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = $1 ) AS exists; `, [ticketNumber, userId] ); return Boolean(result.rows[0]?.exists); } function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields { const source = isPlainObject(value) ? value : {}; return { title: optionalBoolean(source.title, false), organization: optionalBoolean(source.organization, false), type: optionalBoolean(source.type, false), projectName: optionalBoolean(source.projectName, false), budget: optionalBoolean(source.budget, false), overrideBudget: optionalBoolean(source.overrideBudget, false), pendingTodo: optionalBoolean(source.pendingTodo, false) }; } function zammadSyncSelectedTicketNumbers(value: unknown) { if (value === undefined || value === null) { return null; } if (!Array.isArray(value)) { throw badRequest("ticketNumbers must be an array"); } return new Set(value.map((item) => requireString(item, "ticketNumber"))); } function emptyZammadSyncResult(mode: "preview" | "applied" = "applied"): ZammadSyncResult { return { mode, checked: 0, updated: 0, created: 0, skipped: 0, missingInZammad: 0, organizationMissing: 0, markedForCloseReview: 0, convertedToConsulting: 0, convertedToSupport: 0, errors: [], items: [] }; } type LocalZammadSyncTicket = { id: string; ticket_number: string; title: string | null; organization_id: string | null; organization_name: string | null; customer_name: string | null; work_type: "support" | "consulting" | null; zammad_ticket_id: number | null; zammad_group_id: number | null; zammad_priority_id: number | null; zammad_state_id: number | null; zammad_owner_id: number | null; zammad_preferences: Record | null; }; type SyncProjectSnapshot = { id: string; user_id: string; teamspace_project: string | null; hour_budget_minutes: number | null; close_review_required: boolean; closed_at: string | null; default_teamspace_project: string | null; default_hour_budget_minutes: number | null; default_budget_sync_disabled: boolean; teamspace_entries: Array<{ id: string; teamspace_project: string | null; is_default: boolean; hour_budget_minutes: number | null; budget_sync_disabled: boolean; display_order: number; }>; }; function syncComparableValue(value: unknown) { if (typeof value === "string" && value.trim().length === 0) { return null; } return value ?? null; } function syncIdComparableValue(value: unknown) { const comparable = syncComparableValue(value); if (typeof comparable === "number" && Number.isInteger(comparable)) { return String(comparable); } if (typeof comparable === "bigint") { return comparable.toString(); } if (typeof comparable === "string" && /^\d+$/.test(comparable.trim())) { return comparable.trim().replace(/^0+(?=\d)/, ""); } return comparable; } function syncComparableValueForField(field: string, value: unknown) { if (field === "Zammad-Status") { const stateId = zammadStateIdOrNull(value); return stateId === null ? null : String(stateId); } if (field === "Organisation" || field === "Zammad-Priorität") { return syncIdComparableValue(value); } if (field === "Teamspace-Projekte") { const entries = Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry : null)) : parseZammadTeamspaceProjects(value); return normalizedTeamspaceProjectList(entries).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" })); } return syncComparableValue(value); } function syncValueChanged(field: string, left: unknown, right: unknown) { return JSON.stringify(syncComparableValueForField(field, left)) !== JSON.stringify(syncComparableValueForField(field, right)); } function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) { if (syncValueChanged(field, from, to)) { changes.push({ field, from: syncComparableValueForField(field, from), to: syncComparableValueForField(field, to) }); } } function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { return { ticketId: zammadTicket.zammadTicketId, ticketNumber: zammadTicket.ticketNumber, groupId: zammadTicket.zammadGroupId, priorityId: zammadTicket.zammadPriorityId, stateId: zammadTicket.zammadStateId, organizationId: zammadTicket.organizationId, title: zammadTicket.title, ownerId: zammadTicket.zammadOwnerId, workType: zammadTicket.workType, preferences: zammadTicket.preferences, teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, pendingTime: zammadTicket.pendingTime }; } function zammadTicketFromSyncSnapshot(snapshot: Record | null): ZammadTicketImport | null { if (!snapshot) { return null; } const zammadTicketId = numberOrNull(snapshot.ticketId); const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null; const zammadGroupId = numberOrNull(snapshot.groupId); const zammadPriorityId = numberOrNull(snapshot.priorityId); const zammadStateId = zammadStateIdOrNull(snapshot.stateId); const organizationId = numberOrNull(snapshot.organizationId); const zammadOwnerId = numberOrNull(snapshot.ownerId); const workType = zammadWorkType(zammadGroupId); if (!zammadTicketId || !ticketNumber || !organizationId || !workType) { return null; } return { zammadTicketId, ticketNumber, zammadGroupId, zammadPriorityId, zammadStateId, organizationId, title: typeof snapshot.title === "string" && snapshot.title.trim() ? snapshot.title.trim() : null, zammadOwnerId, workType, preferences: isPlainObject(snapshot.preferences) ? snapshot.preferences : {}, teamspaceBudgetMinutes: typeof snapshot.teamspaceBudgetMinutes === "number" ? snapshot.teamspaceBudgetMinutes : null, teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null, teamspaceProjects: Array.isArray(snapshot.teamspaceProjects) ? snapshot.teamspaceProjects.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) : parseZammadTeamspaceProjects(snapshot.teamspaceProject), pendingTime: parseZammadPendingTime(snapshot.pendingTime) }; } function localSyncSnapshot( localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null, options: { zammadPendingTodoExists?: boolean | null } = {} ) { return { ticketId: localTicket.id, ticketNumber: localTicket.ticket_number, title: localTicket.title, organizationId: localTicket.organization_id, organizationName: localTicket.organization_name ?? localTicket.customer_name, workType: localTicket.work_type, zammadTicketId: localTicket.zammad_ticket_id, zammadGroupId: localTicket.zammad_group_id, zammadPriorityId: localTicket.zammad_priority_id, zammadStateId: localTicket.zammad_state_id, zammadOwnerId: localTicket.zammad_owner_id, zammadPendingTodoExists: options.zammadPendingTodoExists ?? null, project: project ? { id: project.id, userId: project.user_id, teamspaceProject: project.teamspace_project, hourBudgetMinutes: project.hour_budget_minutes, closeReviewRequired: project.close_review_required, closedAt: project.closed_at, defaultTeamspaceProject: project.default_teamspace_project, defaultHourBudgetMinutes: project.default_hour_budget_minutes, defaultBudgetSyncDisabled: project.default_budget_sync_disabled, teamspaceProjects: project.teamspace_entries.map((entry) => entry.teamspace_project).filter(Boolean), teamspaceEntries: project.teamspace_entries } : null }; } async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string) { const result = await client.query( ` SELECT p.id::text AS id, p.user_id::text AS user_id, p.teamspace_project, p.hour_budget_minutes, p.close_review_required, p.closed_at, pte.teamspace_project AS default_teamspace_project, pte.hour_budget_minutes AS default_hour_budget_minutes, COALESCE(pte.budget_sync_disabled, false) AS default_budget_sync_disabled, COALESCE(entries.teamspace_entries, '[]'::json) AS teamspace_entries FROM projects p LEFT JOIN LATERAL ( SELECT teamspace_project, hour_budget_minutes, budget_sync_disabled FROM project_teamspace_entries WHERE project_id = p.id AND is_default ORDER BY id ASC LIMIT 1 ) pte ON true LEFT JOIN LATERAL ( SELECT json_agg( json_build_object( 'id', ordered.id::text, 'teamspace_project', ordered.teamspace_project, 'is_default', ordered.is_default, 'hour_budget_minutes', ordered.hour_budget_minutes, 'budget_sync_disabled', ordered.budget_sync_disabled, 'display_order', ordered.display_order ) ) AS teamspace_entries FROM ( SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order FROM project_teamspace_entries WHERE project_id = p.id ORDER BY is_default DESC, display_order ASC, id ASC ) ordered ) entries ON true WHERE p.ticket_id = $1 AND p.user_id = $2 LIMIT 1; `, [ticketId, ownerUserId] ); return result.rows[0] ?? null; } async function ticketHasAnyProject(client: QueryClient, ticketId: string) { const result = await client.query<{ exists: boolean }>( ` SELECT EXISTS ( SELECT 1 FROM projects WHERE ticket_id = $1 ) AS exists; `, [ticketId] ); return Boolean(result.rows[0]?.exists); } function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) { return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : []; } function normalizedTeamspaceProjectList(projects: Array) { const seen = new Set(); const normalized: string[] = []; for (const project of projects) { const value = project?.trim(); if (!value || seen.has(value)) { continue; } seen.add(value); normalized.push(value); } return normalized; } function formatTeamspaceProjectList(projects: Array) { return normalizedTeamspaceProjectList(projects).join(" + "); } function teamspaceProjectDiff(localProjects: Array, zammadProjects: Array) { const local = normalizedTeamspaceProjectList(localProjects); const zammad = normalizedTeamspaceProjectList(zammadProjects); const localSet = new Set(local); const zammadSet = new Set(zammad); return { local, zammad, additions: zammad.filter((project) => !localSet.has(project)), missingInZammad: local.filter((project) => !zammadSet.has(project)) }; } function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjects: Array, zammadProjects: Array) { const diff = teamspaceProjectDiff(localProjects, zammadProjects); if (diff.additions.length > 0) { changes.push({ field: "Teamspace-Projekte", from: diff.local, to: [...diff.local, ...diff.additions] }); } if (diff.missingInZammad.length > 0) { changes.push({ field: "Teamspace-Prüfung", from: diff.missingInZammad, to: "nicht mehr aus Zammad; bitte manuell prüfen" }); } } function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) { if ((zammadTicket.zammadStateId !== 3 && zammadTicket.zammadStateId !== 7) || !zammadTicket.pendingTime) { return null; } const kind = zammadTicket.zammadStateId === 7 ? "Abschluss" : "Erinnerung"; const day = zammadTicket.pendingTime.slice(0, 10); const ticketLabel = zammadTicket.ticketNumber.replace(/^Ticket#?/i, "").replace(/^#/, ""); return { day, title: `${kind} ${ticketLabel ? `Ticket#${ticketLabel}` : "Ticket"}`, description: `${kind} für dieses Ticket prüfen.` }; } async function zammadPendingTodoExists(client: QueryClient, ticketId: string, userId: string, zammadTicket: ZammadTicketImport) { const payload = zammadPendingTodoPayload(zammadTicket); if (!payload) { return true; } const result = await client.query<{ exists: boolean }>( ` SELECT EXISTS ( SELECT 1 FROM ticket_todos WHERE ticket_id = $1 AND user_id = $2 AND title = $3 AND starts_on = $4::date AND ends_on = $4::date ) AS exists; `, [ticketId, userId, payload.title, payload.day] ); return Boolean(result.rows[0]?.exists); } async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, userId: string, zammadTicket: ZammadTicketImport) { const payload = zammadPendingTodoPayload(zammadTicket); if (!payload || await zammadPendingTodoExists(client, ticketId, userId, zammadTicket)) { return; } await client.query( ` INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) SELECT $1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2) WHERE NOT EXISTS ( SELECT 1 FROM ticket_todos WHERE ticket_id = $1 AND user_id = $2 AND title = $3 AND starts_on = $5::date AND ends_on = $5::date ); `, [ticketId, userId, payload.title, payload.description, payload.day] ); } async function syncProjectTeamspaceEntriesFromZammad( client: QueryClient, projectId: string, teamspaceProjects: string[], budgetMinutes: number | null, syncBudget: boolean ) { const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects); if (zammadProjects.length === 0) { return; } const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>( ` SELECT id::text AS id, teamspace_project, is_default, display_order FROM project_teamspace_entries WHERE project_id = $1 ORDER BY is_default DESC, display_order ASC, id ASC FOR UPDATE; `, [projectId] ); if (existingResult.rows.length === 0) { for (const [index, teamspaceProject] of zammadProjects.entries()) { await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, $3, $4, false, 0, $5); `, [projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024] ); } await client.query( ` UPDATE projects SET teamspace_project = $2, hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, updated_at = now() WHERE id = $1; `, [projectId, zammadProjects[0] ?? null, syncBudget, budgetMinutes] ); return; } let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0]; if (!defaultEntry.is_default) { await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]); defaultEntry = { ...defaultEntry, is_default: true }; } const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project))); let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; for (const teamspaceProject of zammadProjects) { if (existingTeamspaceProjects.has(teamspaceProject)) { continue; } await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, false, NULL, false, 0, $3); `, [projectId, teamspaceProject, nextDisplayOrder] ); nextDisplayOrder += 1024; } if (syncBudget) { await client.query( ` UPDATE project_teamspace_entries SET hour_budget_minutes = $1, budget_sync_disabled = false, updated_at = now() WHERE id = $2 AND project_id = $3; `, [budgetMinutes, defaultEntry.id, projectId] ); } await client.query( ` UPDATE projects SET teamspace_project = $2, hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, updated_at = now() WHERE id = $1; `, [projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes] ); } async function planExistingZammadTicketSync( client: QueryClient, localTicket: LocalZammadSyncTicket, zammadTicket: ZammadTicketImport, userId: string, fields: ZammadSyncExistingFields ): Promise { const changes: ZammadSyncChange[] = []; const pendingTodo = zammadPendingTodoPayload(zammadTicket); const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null; addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); if (fields.pendingTodo && pendingTodo && !pendingTodoExists) { changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); } if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { return changes.length > 0 ? { ticketNumber: localTicket.ticket_number, action: "update", reason: "Zammad-Metadaten werden aktualisiert.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes } : null; } const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; if ((fields.organization || !localTicket.work_type) && !organization) { return { ticketNumber: localTicket.ticket_number, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes: [] }; } if (fields.title) { addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title); } if (fields.organization && organization) { addSyncChange(changes, "Organisation", localTicket.organization_id, organization.id); addSyncChange(changes, "Kunde", localTicket.customer_name ?? localTicket.organization_name, organization.name); } if (fields.type) { addSyncChange(changes, "Typ", localTicket.work_type, zammadTicket.workType); } if (fields.type && zammadTicket.workType === "support") { if (await ticketHasAnyProject(client, localTicket.id)) { changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); } return { ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes }; } const shouldManageConsultingProject = zammadTicket.workType === "consulting" && ((fields.type && zammadTicket.workType === "consulting") || localTicket.work_type === "consulting"); let project: SyncProjectSnapshot | null = null; if (shouldManageConsultingProject) { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const localTeamspaceProjects = project?.teamspace_entries?.length ? project.teamspace_entries.map((entry) => entry.teamspace_project) : project?.teamspace_project ? [project.teamspace_project] : []; const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); if (zammadTicket.zammadStateId === 4 && project?.closed_at) { return null; } if (!project) { changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); } if (fields.projectName) { addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects); } if (canSyncBudget) { addSyncChange(changes, "Teamspace-Budget", project?.default_hour_budget_minutes ?? project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes); } if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) { changes.push({ field: "TS/TT-Prüfung", from: project?.close_review_required ?? false, to: true }); } } else if (zammadTicket.workType === "consulting" && zammadTicket.zammadStateId === 4) { const hasProject = await ticketHasAnyProject(client, localTicket.id); if (hasProject) { changes.push({ field: "TS/TT-Prüfung", from: false, to: true }); } } return { ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes }; } async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTicketImport): Promise { if (await ticketExists(zammadTicket.ticketNumber)) { return { ticketNumber: zammadTicket.ticketNumber, action: "skip", reason: "Ticket existiert bereits in TicketTracker.", local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }; } const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); if (!organization) { return { ticketNumber: zammadTicket.ticketNumber, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }; } return { ticketNumber: zammadTicket.ticketNumber, action: "create", reason: zammadTicket.zammadStateId === 4 ? "Consulting-Ticket wird angelegt und zur TS/TT-Prüfung markiert." : "Consulting-Ticket wird angelegt.", local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [ { field: "Ticket", from: null, to: zammadTicket.ticketNumber }, { field: "Titel", from: null, to: zammadTicket.title }, { field: "Organisation", from: null, to: organization.id }, { field: "Typ", from: null, to: zammadTicket.workType }, { field: "Projekt", from: null, to: formatTeamspaceProjectList(zammadTeamspaceProjectList(zammadTicket)) }, { field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes }, ...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : []) ] }; } function updateZammadSyncResultFromItem(result: ZammadSyncResult, item: ZammadSyncItem, localWorkType: "support" | "consulting" | null = null) { result.items.push(item); if (item.action === "create") { result.created += 1; } else if (item.action === "update") { result.updated += 1; } else if (item.action === "skip") { result.skipped += 1; } else if (item.action === "error") { result.skipped += 1; result.errors.push({ ticketNumber: item.ticketNumber, error: item.reason ?? "Unbekannter Fehler" }); } if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert.") { result.organizationMissing += 1; } if (item.changes.some((change) => change.field === "TS/TT-Prüfung")) { result.markedForCloseReview += 1; } const targetTypeChange = item.changes.find((change) => change.field === "Typ"); if (targetTypeChange?.to === "consulting" && localWorkType !== "consulting") { result.convertedToConsulting += 1; } if (targetTypeChange?.to === "support" && localWorkType === "consulting") { result.convertedToSupport += 1; } } async function updateDefaultProjectTeamspaceEntry( client: QueryClient, projectId: string, updates: { projectName?: string | null; budgetMinutes?: number | null } ) { const setParts: string[] = []; const params: unknown[] = []; if (Object.prototype.hasOwnProperty.call(updates, "projectName")) { params.push(updates.projectName ?? null); setParts.push(`teamspace_project = $${params.length}`); } if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) { params.push(updates.budgetMinutes ?? null); setParts.push(`hour_budget_minutes = $${params.length}`); setParts.push("budget_sync_disabled = false"); } if (setParts.length === 0) { return; } params.push(projectId); await client.query( ` UPDATE project_teamspace_entries SET ${setParts.join(", ")}, updated_at = now() WHERE project_id = $${params.length} AND is_default; `, params ); } async function applyZammadConsultingProjectSync( client: QueryClient, ticketId: string, projectOwnerUserId: string, zammadTicket: ZammadTicketImport, fields: ZammadSyncExistingFields ) { await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4 }); const project = await getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId); if (!project) { return; } const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget); if (fields.projectName) { await syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget); } else if (syncBudget) { await updateDefaultProjectTeamspaceEntry(client, project.id, { budgetMinutes: zammadTicket.teamspaceBudgetMinutes }); await client.query( ` UPDATE projects SET hour_budget_minutes = $1, updated_at = now() WHERE id = $2; `, [zammadTicket.teamspaceBudgetMinutes, project.id] ); } if (zammadTicket.zammadStateId === 4) { await client.query( ` UPDATE projects SET close_review_required = true, updated_at = now() WHERE id = $1; `, [project.id] ); } } async function applyZammadTicketSync( client: QueryClient, localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null }, zammadTicket: ZammadTicketImport, userId: string, fields: ZammadSyncExistingFields ) { const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4; const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type); const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; if (shouldResolveOrganization && !organization) { return "organization-missing" as const; } await client.query( ` UPDATE tickets SET title = CASE WHEN $2::boolean THEN $3 ELSE title END, organization_id = CASE WHEN $4::boolean THEN $5 ELSE organization_id END, customer_name = CASE WHEN $4::boolean THEN $6 ELSE customer_name END, work_type = CASE WHEN $7::boolean THEN $8 ELSE work_type END, zammad_ticket_id = $9, zammad_group_id = $10, zammad_priority_id = $11, zammad_state_id = $12, zammad_owner_id = $13, zammad_preferences = $14::jsonb WHERE id = $1; `, [ localTicket.id, !metadataOnly && fields.title, zammadTicket.title, !metadataOnly && fields.organization, organization?.id ?? null, organization?.name ?? null, !metadataOnly && fields.type, zammadTicket.workType, zammadTicket.zammadTicketId, zammadTicket.zammadGroupId, zammadTicket.zammadPriorityId, zammadTicket.zammadStateId, zammadTicket.zammadOwnerId, JSON.stringify(zammadTicket.preferences) ] ); if (!metadataOnly && fields.organization && organization) { await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2 WHERE ticket_id = $3; `, [organization.id, organization.name, localTicket.id] ); } if (fields.pendingTodo) { await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); } if (!metadataOnly && fields.type && zammadTicket.workType === "support") { await deleteAllProjectDataForTicket(client, localTicket.id); await client.query( ` UPDATE sessions SET work_type = CASE WHEN recurring_billing_id IS NULL THEN 'support' ELSE work_type END, project_teamspace_entry_id = NULL WHERE ticket_id = $1; `, [localTicket.id] ); return "support" as const; } if (fields.type && zammadTicket.workType === "consulting") { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await client.query( ` UPDATE sessions SET work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END WHERE ticket_id = $1; `, [localTicket.id] ); await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); return zammadTicket.zammadStateId === 4 ? "consulting-close-review" as const : "consulting" as const; } if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const; } if (zammadTicket.workType === "consulting" && zammadTicket.zammadStateId === 4) { await client.query( ` UPDATE projects SET close_review_required = true, updated_at = now() WHERE ticket_id = $1; `, [localTicket.id] ); return "close-review" as const; } return "updated" as const; } 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.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, p.id::text AS project_id, 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, ticket_crm.crm_billed_minutes, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billable'), 0)::int AS billable_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 = 'billable')::int AS billable_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.zammad_id = t.organization_id LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $3 LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = t.id AND tdb.user_id = $3 AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date ) ticket_crm ON true 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.title, t.organization_id, o.name, t.customer_name, t.work_type, p.id, ticket_crm.crm_billed_minutes 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, s.recurring_billing_id::text AS recurring_billing_id FROM sessions s JOIN tickets t ON t.id = s.ticket_id LEFT JOIN organizations so ON so.zammad_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, billableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.billable_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, COALESCE(pte.teamspace_project, p.teamspace_project) AS project_teamspace_project, 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.zammad_id = s.organization_id LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_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 = 'billable')::int AS billable_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 = 'billable')::int AS billable_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, teamspaceDayResult, 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 = 'billable'), 0)::int AS billable_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 = 'billable')::int AS billable_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.billable_minutes, 0)::int AS billable_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.billable_sessions, 0)::int AS billable_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(billable_minutes, 0))::int AS billable_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(billable_sessions)::int AS billable_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.billable_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.billable_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(billable_minutes, 0)), 0)::int AS billable_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(billable_sessions), 0)::int AS billable_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(billable_minutes, 0)), 0)::int AS billable_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(billable_sessions), 0)::int AS billable_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 = 'billable'), 0)::int AS billable_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 = 'billable')::int AS billable_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.zammad_id = s.organization_id LEFT JOIN organizations o ON o.zammad_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( ` ${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, COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS session_count, NULLIF( string_agg(DISTINCT NULLIF(sb.project_teamspace_project, ''), ' + ' ORDER BY NULLIF(sb.project_teamspace_project, '')) FILTER (WHERE sb.billing_status = 'billed'), '' ) AS project_teamspace_project 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 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<{ 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, billable_minutes: 0, non_billable_minutes: 0, open_minutes: 0, billed_sessions: 0, billable_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, billableMinutes: totals.billable_minutes, nonBillableMinutes: totals.non_billable_minutes, openMinutes: totals.open_minutes, billedSessions: totals.billed_sessions, billableSessions: totals.billable_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 }, teamspaceDays: teamspaceDayResult.rows }; } async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); 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.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, 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, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_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 zammad_id = sessions.organization_id ) AS organization_name, COALESCE( ( SELECT name FROM organizations WHERE zammad_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, ( SELECT status FROM zammad_session_article_queue WHERE session_id = sessions.id ORDER BY updated_at DESC LIMIT 1 ) AS zammad_article_status, ( SELECT zammad_article_id::text FROM zammad_session_article_queue WHERE session_id = sessions.id ORDER BY updated_at DESC LIMIT 1 ) AS zammad_article_id, ( SELECT zammad_article_type_id FROM zammad_session_article_queue WHERE session_id = sessions.id ORDER BY updated_at DESC LIMIT 1 ) AS zammad_article_type_id, project_teamspace_entry_id::text AS project_teamspace_entry_id, ( SELECT teamspace_project FROM project_teamspace_entries WHERE id = sessions.project_teamspace_entry_id ) AS project_teamspace_project 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 }; } async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billable" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) { if (filters.month) { await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso); } await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); const ticketResult = await query( ` SELECT t.id, t.ticket_number, t.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, NULL::timestamptz AS closed_at, NULL::timestamptz AS period_closed_at, COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, COALESCE(all_sessions.open_count, 0)::int AS ticket_open_count, COALESCE(all_sessions.manual_session_count, 0)::int AS ticket_manual_session_count, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id 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 ) all_sessions ON true WHERE t.id = $1; `, [ticketId, userId] ); if (ticketResult.rowCount === 0) { return null; } const params: unknown[] = [ticketId, userId]; const where = ["s.ticket_id = $1", "s.user_id = $2"]; if (filters.month) { params.push(filters.month.startIso, filters.month.endIso); where.push(`s.started_at >= $${params.length - 1}::timestamptz`); where.push(`s.started_at < $${params.length}::timestamptz`); } if (filters.status === "open") { where.push("s.billing_status IS NULL"); } else if (filters.status !== "all") { params.push(filters.status); where.push(`s.billing_status = $${params.length}`); } if (filters.source === "manual") { where.push("s.recurring_billing_id IS NULL"); } else if (filters.source === "recurring") { where.push("s.recurring_billing_id IS NOT NULL"); } const sessionWhere = where.join(" AND "); const [sessionsResult, dayBillingResult, monthsResult, closureResult, projectResult, todosResult] = await Promise.all([ query( ` SELECT s.id, s.organization_id, ( SELECT name FROM organizations WHERE zammad_id = s.organization_id ) AS organization_name, COALESCE( ( SELECT name FROM organizations WHERE zammad_id = s.organization_id ), 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.billing_updated_at, s.created_at, s.recurring_billing_id, s.recurring_billing_slot_id, s.recurring_occurrence_date, za.status AS zammad_article_status, za.zammad_article_id::text AS zammad_article_id, za.zammad_article_type_id, s.project_teamspace_entry_id::text AS project_teamspace_entry_id, pte.teamspace_project AS project_teamspace_project FROM sessions s LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id LEFT JOIN LATERAL ( SELECT status, zammad_article_id, zammad_article_type_id FROM zammad_session_article_queue WHERE session_id = s.id ORDER BY updated_at DESC LIMIT 1 ) za ON true WHERE ${sessionWhere} ORDER BY s.started_at ASC; `, params ), query( ` WITH filtered_days AS ( SELECT DISTINCT (s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day FROM sessions s WHERE ${sessionWhere} ) SELECT tdb.day::text AS day, tdb.billed_minutes FROM ticket_day_billings tdb JOIN filtered_days fd ON fd.day = tdb.day WHERE tdb.ticket_id = $1 AND tdb.user_id = $2 ORDER BY tdb.day ASC; `, params ), query( ` SELECT to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS month, COUNT(*)::int AS session_count FROM sessions s WHERE s.ticket_id = $1 AND s.user_id = $2 GROUP BY month ORDER BY month DESC; `, [ticketId, userId] ), filters.month ? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [filters.month.start, userId]) : Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }), query( ` SELECT p.id::text AS id, p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, (p.user_id = $2::bigint) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, p.teamspace_project, p.description, p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, p.close_review_required, p.created_at, p.updated_at FROM projects p JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( json_agg( json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC ), '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $2 AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL ) unassigned_sessions ON true WHERE p.ticket_id = $1 AND ( p.user_id = $2 OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = p.ticket_id AND viewer_sessions.user_id = $2 ) ) ORDER BY (p.user_id = $2::bigint) DESC, p.id ASC LIMIT 1; `, [ticketId, userId] ), query( ` SELECT tt.id::text AS id, tt.ticket_id::text AS ticket_id, t.ticket_number, COALESCE(o.name, t.customer_name) AS organization_name, p.id::text AS project_id, p.teamspace_project, tt.title, tt.description, tt.priority, tt.status, tt.sort_order, tt.starts_on::text AS starts_on, tt.ends_on::text AS ends_on, tt.completed_at, tt.created_at, tt.updated_at FROM ticket_todos tt JOIN tickets t ON t.id = tt.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id WHERE tt.ticket_id = $1 AND tt.user_id = $2 ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, tt.sort_order ASC, tt.id ASC; `, [ticketId, userId] ) ]); 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 closed = Boolean(filters.month && closureResult.rows.length > 0); return { periodType: "all", period: filters.month?.label ?? "all", closed, closedAt: closed ? closureResult.rows[0]?.closed_at ?? null : null, filters: { month: filters.month?.label ?? "all", status: filters.status, source: filters.source }, availableMonths: monthsResult.rows, ticket: { ...ticket, requires_closure: false, recurring_session_count: recurringSessionCount, manual_session_count: manualSessionCount }, sessions, dayBillings: dayBillingResult.rows, project: projectResult.rows[0] ?? null, todos: todosResult.rows, canClose: false, openCount, ticketOpenCount: Number(ticket.ticket_open_count ?? 0) }; } app.get("/api/setup/status", async (_req, res) => { res.json({ required: !(await hasAdminUser()) }); }); function restoreSqlFromRequestBody(body: unknown) { if (typeof body === "string") { return requireString(body, "sql"); } if (isPlainObject(body)) { return requireString(body.sql, "sql"); } throw badRequest("sql is required"); } app.post("/api/setup/admin", async (req, res) => { if (await hasAdminUser()) { res.status(409).json({ error: "Setup wurde bereits abgeschlossen" }); return; } const username = parseUsername(req.body.username); const displayName = requireString(req.body.displayName, "displayName"); const password = requireString(req.body.password, "password"); if (password.length < 6) { throw badRequest("password must be at least 6 characters"); } try { const result = await query<{ id: string; username: string; display_name: string; role: "admin" | "user"; active: boolean; zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; zammad_article_type_id: number | null; }>( ` INSERT INTO users (username, display_name, password_hash, role, active) VALUES ($1, $2, $3, 'admin', true) RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id; `, [username, displayName, await hashPassword(password)] ); await createAuthSession(res, result.rows[0].id); 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; } throw error; } }); app.post("/api/setup/restore", async (req, res) => { if (await hasAdminUser()) { res.status(409).json({ error: "Setup wurde bereits abgeschlossen. Restore ist im Adminbereich verfügbar." }); return; } const sql = restoreSqlFromRequestBody(req.body); const result = await restoreTicketTrackerBackup(sql); res.status(201).json(result); }); app.post("/api/setup/restore/validate", async (req, res) => { if (await hasAdminUser()) { res.status(409).json({ error: "Setup wurde bereits abgeschlossen. Restore ist im Adminbereich verfügbar." }); return; } const sql = restoreSqlFromRequestBody(req.body); res.json(await validateBackupSql({ query }, sql)); }); app.post("/api/auth/login", async (req, res) => { const startedAt = Date.now(); const username = requireString(req.body.username, "username"); const password = requireString(req.body.password, "password"); logDebug("login", { phase: "start", username }); const result = await query<{ id: string; username: string; display_name: string; role: "admin" | "user"; active: boolean; password_hash: string; zammad_user_id: string | null; has_zammad_api_token: boolean; zammad_email_session_webhook_enabled: boolean; session_billing_prompt_enabled: boolean; zammad_session_note_default_enabled: boolean; zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_minutes: number; zammad_article_type_id: number | null; }>( ` SELECT id, username, display_name, role, active, password_hash, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id FROM users WHERE lower(username) = lower($1); `, [username] ); const user = result.rows[0]; logDebug("login", { phase: "user-loaded", username, found: Boolean(user), userId: user?.id ?? null, role: user?.role ?? null, active: user?.active ?? null }); const passwordValid = user && user.active ? await verifyPassword(password, user.password_hash) : false; logDebug("login", { phase: "password-checked", username, userId: user?.id ?? null, ok: passwordValid }); if (!user || !user.active || !passwordValid) { res.status(401).json({ error: "Benutzername oder Passwort ist falsch" }); return; } await createAuthSession(res, user.id); logDebug("login", { phase: "session-created", username, userId: user.id, durationMs: Date.now() - startedAt }); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, active: user.active, zammad_user_id: user.zammad_user_id, has_zammad_api_token: user.has_zammad_api_token, zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled, session_billing_prompt_enabled: user.session_billing_prompt_enabled, zammad_session_note_default_enabled: user.zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, zammad_article_type_id: user.zammad_article_type_id } }); }); app.post("/api/zammad/email-session-webhook", async (req, res) => { const deliveryId = optionalString(req.header("x-zammad-delivery")); logDebug("zammad-email-session-webhook-received", { deliveryId, trigger: optionalString(req.header("x-zammad-trigger")), contentType: optionalString(req.header("content-type")), authorizationPresent: Boolean(req.header("authorization")), ...zammadWebhookDebugSummary(req.body) }); if (!verifyZammadEmailWebhookAuth(req)) { logDebug("zammad-email-session-webhook-auth-failed", { deliveryId, configured: Boolean(zammadEmailWebhookToken), authorizationPresent: Boolean(req.header("authorization")) }); res.status(zammadEmailWebhookToken ? 401 : 503).json({ error: zammadEmailWebhookToken ? "Webhook-Token ist ungültig" : "Zammad Webhook-Token ist nicht konfiguriert" }); return; } const payload = req.body; const result = await createZammadEmailSessionFromWebhook(payload, deliveryId); logDebug("zammad-email-session-webhook", { deliveryId, created: result.created, skipped: "skipped" in result ? result.skipped : false, reason: "reason" in result ? result.reason : null }); res.status(200).json(result); }); app.use("/api", requireAuth); app.get("/api/auth/me", (req, res) => { res.json({ user: currentUser(req) }); }); app.get("/api/dashboard/preferences", requireUser, async (req, res) => { const preference = await getUserPreference(currentUser(req).id, dashboardPreferenceKey); res.json({ preference }); }); app.put("/api/dashboard/preferences", requireUser, async (req, res) => { const preference = parseDashboardPreferenceValue(req.body.preference); const saved = await setUserPreference(currentUser(req).id, dashboardPreferenceKey, preference); res.json({ preference: saved }); }); app.get("/api/dashboard/day-control", requireUser, async (req, res) => { const userId = currentUser(req).id; const result = await query( ` SELECT t.id::text AS ticket_id, t.ticket_number, t.title, COALESCE(o.name, t.customer_name) AS organization_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, MAX(s.started_at) FILTER (WHERE s.user_id = $1)::text AS latest_started_at, EXISTS ( SELECT 1 FROM projects own_projects WHERE own_projects.ticket_id = t.id AND own_projects.user_id = $1 ) AS has_project, COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS own_session_count, COUNT(s.id) FILTER (WHERE s.user_id = $1 AND s.billing_status IS NULL)::int AS open_session_count, COALESCE(todo_stats.open_todo_count, 0)::int AS open_todo_count, COALESCE(todo_stats.due_todo_count, 0)::int AS due_todo_count FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN sessions s ON s.ticket_id = t.id LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE tt.status = 'open')::int AS open_todo_count, COUNT(*) FILTER ( WHERE tt.status = 'open' AND ( tt.ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date OR tt.ends_on = (now() AT TIME ZONE 'Europe/Berlin')::date OR tt.starts_on = (now() AT TIME ZONE 'Europe/Berlin')::date OR (tt.starts_on <= (now() AT TIME ZONE 'Europe/Berlin')::date AND tt.ends_on >= (now() AT TIME ZONE 'Europe/Berlin')::date) ) )::int AS due_todo_count FROM ticket_todos tt WHERE tt.ticket_id = t.id AND tt.user_id = $1 ) todo_stats ON true WHERE ( EXISTS ( SELECT 1 FROM sessions own_sessions WHERE own_sessions.ticket_id = t.id AND own_sessions.user_id = $1 ) OR EXISTS ( SELECT 1 FROM projects own_projects WHERE own_projects.ticket_id = t.id AND own_projects.user_id = $1 ) OR EXISTS ( SELECT 1 FROM ticket_todos own_todos WHERE own_todos.ticket_id = t.id AND own_todos.user_id = $1 ) ) AND ( t.zammad_state_id IN (1, 2, 3, 7) OR EXISTS ( SELECT 1 FROM sessions open_sessions WHERE open_sessions.ticket_id = t.id AND open_sessions.user_id = $1 AND open_sessions.billing_status IS NULL ) ) GROUP BY t.id, t.ticket_number, t.title, o.name, t.customer_name, t.work_type, t.zammad_priority_id, t.zammad_state_id, t.zammad_preferences, todo_stats.open_todo_count, todo_stats.due_todo_count ORDER BY CASE t.zammad_priority_id WHEN 3 THEN 0 WHEN 2 THEN 1 WHEN 1 THEN 2 ELSE 3 END, MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST, t.ticket_number ASC LIMIT 200; `, [userId] ); res.json({ tickets: result.rows }); }); app.post("/api/dashboard/favorites", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const preference = await getUserPreference(userId, dashboardPreferenceKey); const favorites = readDashboardFavorites(preference); const existing = favorites.find((favorite) => favorite.ticketNumber === ticketNumber); if (!existing) { favorites.push({ ticketNumber, addedAt: new Date().toISOString(), sortOrder: favorites.length }); } const saved = await setUserPreference(userId, dashboardPreferenceKey, { ...preference, favorites }); res.status(existing ? 200 : 201).json({ preference: saved }); }); app.delete("/api/dashboard/favorites/:ticketNumber", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTrackableTicketNumber(req.params.ticketNumber); const preference = await getUserPreference(userId, dashboardPreferenceKey); const favorites = readDashboardFavorites(preference) .filter((favorite) => favorite.ticketNumber !== ticketNumber) .map((favorite, index) => ({ ...favorite, sortOrder: index })); const saved = await setUserPreference(userId, dashboardPreferenceKey, { ...preference, favorites }); res.json({ preference: saved }); }); 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); const updatesZammadToken = Object.prototype.hasOwnProperty.call(req.body, "zammadApiToken") && user.role === "user"; const zammadApiToken = updatesZammadToken ? optionalString(req.body.zammadApiToken) : null; const zammadUserId = zammadApiToken ? await fetchZammadUserIdForToken(zammadApiToken) : null; const zammadEmailSessionWebhookEnabled = user.role === "user" ? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled) : false; const sessionBillingPromptEnabled = user.role === "user" ? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled) : false; const zammadSessionNoteDefaultEnabled = user.role === "user" ? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled) : false; const zammadTimeUnitRoundingEnabled = user.role === "user" ? optionalBoolean(req.body.zammadTimeUnitRoundingEnabled, user.zammad_time_unit_rounding_enabled) : false; const zammadTimeUnitRoundingMinutes = user.role === "user" ? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes) : 15; const zammadArticleTypeId = user.role === "user" ? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id) : 6; 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), zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END, zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END, zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled END, session_billing_prompt_enabled = CASE WHEN $8::boolean THEN $10 ELSE session_billing_prompt_enabled END, zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_enabled END, zammad_time_unit_rounding_enabled = CASE WHEN $8::boolean THEN $12 ELSE zammad_time_unit_rounding_enabled END, zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END, zammad_article_type_id = CASE WHEN $8::boolean THEN $14 ELSE zammad_article_type_id END, updated_at = now() WHERE id = $4 RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id; `, [ username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken ? encryptSecret(zammadApiToken) : null, zammadUserId, user.role === "user", zammadEmailSessionWebhookEnabled, sessionBillingPromptEnabled, zammadSessionNoteDefaultEnabled, zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingMinutes, zammadArticleTypeId ] ); 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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, 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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, 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, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_article_type_id, 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 zammad_id::text AS id, zammad_id::text AS 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<{ zammad_id: string }>( ` SELECT zammad_id::text AS zammad_id FROM organizations WHERE NOT (zammad_id = ANY($1::bigint[])); `, [zammadIds] ) : await client.query<{ zammad_id: string }>( ` SELECT zammad_id::text AS zammad_id FROM organizations; ` ); const staleZammadIds = staleResult.rows.map((row) => row.zammad_id); if (staleZammadIds.length > 0) { const ticketResult = await client.query( ` UPDATE tickets SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, [staleZammadIds] ); const sessionResult = await client.query( ` UPDATE sessions SET organization_id = NULL WHERE organization_id = ANY($1::bigint[]); `, [staleZammadIds] ); const deleteResult = await client.query( ` DELETE FROM organizations WHERE zammad_id = ANY($1::bigint[]); `, [staleZammadIds] ); 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 }); }); async function restoreTicketTrackerBackup(sql: string) { const restoreId = randomUUID(); lastRestoreDebugState = { id: restoreId, startedAt: new Date().toISOString(), finishedAt: null, phase: "started", ok: null, error: null, sqlBytes: Buffer.byteLength(sql, "utf8"), bodyBytes: null, checkedTables: null, issueCount: null, adminCount: null }; updateRestoreDebug(restoreId, { phase: "validating" }); const validation = await validateBackupSql({ query }, sql); updateRestoreDebug(restoreId, { checkedTables: validation.checkedTables, issueCount: validation.issues.length }); if (!validation.valid) { updateRestoreDebug(restoreId, { phase: "validation-failed", ok: false, finishedAt: new Date().toISOString(), error: validation.issues[0] ?? "Restore-Datei passt nicht zur aktuellen Datenbankstruktur" }); throw badRequest(`Restore-Datei passt nicht zur aktuellen Datenbankstruktur: ${validation.issues[0]}`); } updateRestoreDebug(restoreId, { phase: "preparing-sql" }); const body = backupRestoreSqlText(sql); updateRestoreDebug(restoreId, { phase: "transaction-start", bodyBytes: Buffer.byteLength(body, "utf8") }); try { await withTransaction(async (client) => { updateRestoreDebug(restoreId, { phase: "lock-timeouts" }); await client.query("SET LOCAL lock_timeout = '10s';"); await client.query("SET LOCAL statement_timeout = '15min';"); updateRestoreDebug(restoreId, { phase: "truncate" }); await client.query(`TRUNCATE TABLE ${[...backupTables].reverse().map(quoteBackupIdentifier).join(", ")} RESTART IDENTITY CASCADE;`); if (body) { updateRestoreDebug(restoreId, { phase: "import-body" }); await client.query(body); } updateRestoreDebug(restoreId, { phase: "verify-admin" }); const adminResult = await client.query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); const adminCount = Number(adminResult.rows[0]?.count ?? 0); updateRestoreDebug(restoreId, { adminCount }); if (adminCount !== 1) { throw badRequest("Restore muss genau einen Admin-Benutzer enthalten"); } }); } catch (error) { updateRestoreDebug(restoreId, { ok: false, finishedAt: new Date().toISOString(), error: error instanceof Error ? error.message : "Unbekannter Restore-Fehler" }); throw error; } updateRestoreDebug(restoreId, { phase: "finished", ok: true, finishedAt: new Date().toISOString() }); return { ok: true }; } app.get("/api/admin/export/database", requireAdmin, async (_req, res) => { const sql = await withTransaction(async (client) => { await client.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;"); return exportDatabaseSql(client); }); 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(sql); }); app.post("/api/admin/restore/database/validate", requireAdmin, async (req, res) => { const sql = restoreSqlFromRequestBody(req.body); res.json(await validateBackupSql({ query }, sql)); }); app.post("/api/admin/restore/database", requireAdmin, async (req, res) => { const sql = restoreSqlFromRequestBody(req.body); const result = await restoreTicketTrackerBackup(sql); res.clearCookie("tickettracker_session", { sameSite: "lax", secure: process.env.COOKIE_SECURE === "true", path: "/" }); res.json(result); }); 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.zammad_id = s.organization_id LEFT JOIN organizations ot ON ot.zammad_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 === "billable" ? "Abrechenbar" : 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.zammad_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 getOrganizationByZammadId(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 id = $1 AND user_id = $2 FOR UPDATE; `, [billingId, userId] ); if (billingResult.rowCount === 0) { return { status: "not-found" as const }; } const organization = await getOrganizationByZammadId(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 = CASE WHEN EXCLUDED.work_type = 'consulting' THEN 'consulting' ELSE COALESCE(tickets.work_type, EXCLUDED.work_type) END RETURNING id; `, [targetTicketNumber, organization.id, organization.name, payload.workType] ); if (payload.workType === "consulting") { await ensureProjectForTicket(client, targetTicketResult.rows[0].id, userId); } 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 useZammadLookup = optionalBoolean(req.body.zammadLookup, false); const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus); const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false); const zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id); const startedAt = parseIsoDate(req.body.startedAt, "startedAt"); const endedAt = parseIsoDate(req.body.endedAt, "endedAt"); const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds"); const created = await createSessionForUser({ userId: user.id, ticketNumber, activity, requestedOrganizationId, requestedWorkType, useZammadLookup, requestedProjectTeamspaceEntryId, billingStatus, createZammadArticle, zammadArticleTypeId, startedAt, endedAt, durationSeconds }); res.status(201).json(created); }); async function zammadSessionArticleQueueResponse(userId: string) { const result = await query( ` SELECT q.id::text AS id, q.session_id::text AS session_id, q.ticket_id::text AS ticket_id, q.ticket_number, q.zammad_ticket_id::text AS zammad_ticket_id, q.activity, q.time_unit::float AS time_unit, q.zammad_article_type_id, q.attempt_count, q.last_error, q.last_attempt_at, q.created_at, s.started_at, s.rounded_minutes FROM zammad_session_article_queue q JOIN sessions s ON s.id = q.session_id WHERE q.user_id = $1 AND q.status = 'pending' ORDER BY q.created_at ASC; `, [userId] ); return result.rows; } app.get("/api/zammad/session-article-queue", requireUser, async (req, res) => { const userId = currentUser(req).id; res.json({ queue: await zammadSessionArticleQueueResponse(userId) }); }); app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res) => { const userId = currentUser(req).id; const pendingResult = await query( ` SELECT id::text AS id, user_id::text AS user_id, session_id::text AS session_id, ticket_id::text AS ticket_id, ticket_number, zammad_ticket_id::text AS zammad_ticket_id, activity, time_unit::text AS time_unit, zammad_article_type_id FROM zammad_session_article_queue WHERE user_id = $1 AND status = 'pending' ORDER BY created_at ASC LIMIT 100; `, [userId] ); const results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }> = []; for (const row of pendingResult.rows) { try { const articleId = await retryZammadSessionArticleQueueRow(row); results.push({ id: row.id, status: "sent", zammadArticleId: articleId }); } catch (error) { const message = errorMessage(error); await query( ` UPDATE zammad_session_article_queue SET attempt_count = attempt_count + 1, last_error = $1, last_attempt_at = now(), updated_at = now() WHERE id = $2 AND status = 'pending'; `, [message.slice(0, 1000), row.id] ); results.push({ id: row.id, status: "failed", error: message }); } } res.json({ sent: results.filter((result) => result.status === "sent").length, failed: results.filter((result) => result.status === "failed").length, results, queue: await zammadSessionArticleQueueResponse(userId) }); }); 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 userId = currentUser(req).id; const source = req.query.source === "zammad" ? "zammad" : "local"; const lookupSql = ` SELECT t.id, t.ticket_number, t.title, t.organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_ticket_id::text AS zammad_ticket_id, t.zammad_group_id::text AS zammad_group_id, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_owner_id::text AS zammad_owner_id, p.id::text AS project_id, (p.id IS NOT NULL) AS is_project, p.teamspace_project AS project_name, p.hour_budget_minutes AS project_hour_budget_minutes, COALESCE(project_entries.teamspace_entry_count, 0)::int AS project_teamspace_entry_count, COALESCE(project_entries.teamspace_entries, '[]'::json) AS project_teamspace_entries, p.user_id::text AS project_owner_user_id, pu.display_name AS project_owner_display_name, (p.user_id = $2::bigint) AS can_edit_project, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT projects.* FROM projects WHERE projects.ticket_id = t.id ORDER BY (projects.user_id = $2::bigint) DESC, projects.id ASC LIMIT 1 ) p ON true LEFT JOIN users pu ON pu.id = p.user_id LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS teamspace_entry_count, COALESCE( json_agg( json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC ), '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) project_entries ON true WHERE t.ticket_number = $1; `; let result = await query(lookupSql, [ticketNumber, userId]); if (result.rowCount === 0 && source === "zammad") { const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); if (zammadTicket) { const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId); if (!organization) { throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); } const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const previewEntries = zammadTicket.workType === "consulting" && (zammadTeamspaceProjects.length > 0 || zammadTicket.teamspaceBudgetMinutes !== null) ? (zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects : [zammadTicket.teamspaceProject]).map((teamspaceProject, index) => ({ id: `zammad-${zammadTicket.zammadTicketId}-${index}`, teamspace_project: teamspaceProject, is_default: index === 0, hour_budget_minutes: index === 0 ? zammadTicket.teamspaceBudgetMinutes : null, budget_sync_disabled: false, manual_consumption_minutes: 0, display_order: index * 1024 })) : []; res.json({ ticket: { id: `zammad-${zammadTicket.zammadTicketId}`, ticket_number: zammadTicket.ticketNumber, title: zammadTicket.title, organization_id: organization.id, organization_name: organization.name, customer_name: organization.name, work_type: zammadTicket.workType, zammad_ticket_id: String(zammadTicket.zammadTicketId), zammad_group_id: zammadTicket.zammadGroupId === null ? null : String(zammadTicket.zammadGroupId), zammad_priority_id: zammadTicket.zammadPriorityId === null ? null : String(zammadTicket.zammadPriorityId), zammad_state_id: zammadTicket.zammadStateId === null ? null : String(zammadTicket.zammadStateId), zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId), project_id: null, is_project: zammadTicket.workType === "consulting", project_name: formatTeamspaceProjectList(zammadTeamspaceProjects), project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes, project_teamspace_entry_count: previewEntries.length, project_teamspace_entries: previewEntries, project_owner_user_id: null, project_owner_display_name: null, can_edit_project: true, has_recurring_billing: false } }); return; } } res.json({ ticket: result.rows[0] ?? null }); }); app.get("/api/tickets/search", requireUser, async (req, res) => { const user = currentUser(req); const rawQuery = typeof req.query.q === "string" ? req.query.q.trim() : ""; if (rawQuery.length < 2) { res.json({ tickets: [] }); return; } const normalizedTicketQuery = rawQuery.replace(/^ticket#/iu, "").trim(); const searchPattern = `%${rawQuery}%`; const ticketNumberPattern = `%${normalizedTicketQuery || rawQuery}%`; const result = await query( ` SELECT t.id AS ticket_id, t.ticket_number, t.title, t.organization_id, COALESCE(o.name, t.customer_name, '') AS organization_name, COALESCE(o.name, t.customer_name, '') AS customer_name, t.work_type, p.id::text AS project_id, (p.id IS NOT NULL) AS is_project, p.teamspace_project AS project_name, MAX(s.started_at) FILTER (WHERE s.user_id = $1) AS latest_started_at, COALESCE(to_char(MAX(s.started_at) FILTER (WHERE s.user_id = $1) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM'), '') AS latest_period, COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS session_count, COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.user_id = $1), 0)::int AS total_minutes FROM tickets t LEFT JOIN sessions s ON s.ticket_id = t.id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT projects.* FROM projects WHERE projects.ticket_id = t.id ORDER BY (projects.user_id = $1::bigint) DESC, projects.id ASC LIMIT 1 ) p ON true WHERE ( s.user_id = $1 OR p.user_id = $1 OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = t.id AND viewer_sessions.user_id = $1 ) ) AND ( t.ticket_number ILIKE $2 OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3 OR COALESCE(t.title, '') ILIKE $2 OR COALESCE(o.name, '') ILIKE $2 OR COALESCE(t.customer_name, '') ILIKE $2 OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2) OR (s.user_id = $1 AND s.activity ILIKE $2) OR COALESCE(p.teamspace_project, '') ILIKE $2 OR COALESCE(p.description, '') ILIKE $2 OR COALESCE(p.goals, '') ILIKE $2 ) GROUP BY t.id, t.ticket_number, t.title, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project ORDER BY CASE WHEN lower(t.ticket_number) = lower($4) THEN 0 WHEN lower(replace(t.ticket_number, 'Ticket#', '')) = lower($5) THEN 1 WHEN t.ticket_number ILIKE $3 THEN 2 ELSE 3 END, MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST, t.ticket_number ASC LIMIT 12; `, [user.id, searchPattern, ticketNumberPattern, rawQuery, normalizedTicketQuery] ); res.json({ tickets: result.rows }); }); async function runExistingZammadSync( userId: string, fields: ZammadSyncExistingFields, dryRun: boolean, selectedTicketNumbers: Set | null ) { const result = emptyZammadSyncResult(dryRun ? "preview" : "applied"); const localTickets = selectedTicketNumbers ? await query( ` SELECT t.id::text AS id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, t.customer_name, t.work_type, t.zammad_ticket_id, t.zammad_group_id, t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE t.ticket_number = ANY($1::text[]) ORDER BY t.ticket_number ASC; `, [Array.from(selectedTicketNumbers)] ) : await query( ` SELECT t.id::text AS id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, t.customer_name, t.work_type, t.zammad_ticket_id, t.zammad_group_id, t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE t.ticket_number LIKE 'Ticket#%' ORDER BY t.ticket_number ASC; ` ); for (const localTicket of localTickets.rows) { result.checked += 1; try { const zammadTicket = await fetchZammadTicketByNumber(userId, localTicket.ticket_number); if (!zammadTicket) { result.missingInZammad += 1; updateZammadSyncResultFromItem(result, { ticketNumber: localTicket.ticket_number, action: "skip", reason: "Ticket wurde in Zammad nicht gefunden.", local: localSyncSnapshot(localTicket), zammad: null, changes: [] }, localTicket.work_type); continue; } const plan = await planExistingZammadTicketSync({ query }, localTicket, zammadTicket, userId, fields); if (!plan) { continue; } if (plan.action !== "update") { updateZammadSyncResultFromItem(result, plan, localTicket.work_type); continue; } if (!dryRun) { const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); if (status === "organization-missing") { const item = { ...plan, action: "skip" as const, reason: "Zammad-Organisation ist lokal nicht synchronisiert.", changes: [] }; updateZammadSyncResultFromItem(result, item, localTicket.work_type); continue; } } updateZammadSyncResultFromItem(result, plan, localTicket.work_type); } catch (error) { updateZammadSyncResultFromItem(result, { ticketNumber: localTicket.ticket_number, action: "error", reason: error instanceof Error ? error.message : "Unbekannter Fehler", local: localSyncSnapshot(localTicket), zammad: null, changes: [] }, localTicket.work_type); } } return result; } async function runConsultingZammadImport(userId: string, dryRun: boolean, selectedTicketNumbers: Set | null) { const result = emptyZammadSyncResult(dryRun ? "preview" : "applied"); const zammadTickets = await fetchZammadConsultingTicketsForOwner(userId); for (const zammadTicket of zammadTickets) { if (selectedTicketNumbers && !selectedTicketNumbers.has(zammadTicket.ticketNumber)) { continue; } result.checked += 1; try { const plan = await planConsultingImport({ query }, zammadTicket); if (plan.action !== "create") { updateZammadSyncResultFromItem(result, plan); continue; } if (!dryRun) { await withTransaction(async (client) => { await importZammadTicketForUser(client, zammadTicket, userId); }); } updateZammadSyncResultFromItem(result, plan); if (zammadTicket.zammadStateId === 4 && !plan.changes.some((change) => change.field === "TS/TT-Prüfung")) { result.markedForCloseReview += 1; } } catch (error: any) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; if (error?.status === 400 || message === "Zammad Organisation ist lokal nicht synchronisiert") { updateZammadSyncResultFromItem(result, { ticketNumber: zammadTicket.ticketNumber, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }); } else { updateZammadSyncResultFromItem(result, { ticketNumber: zammadTicket.ticketNumber, action: "error", reason: message, local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }); } } } return result; } async function runExistingZammadSyncFromPreview( userId: string, previewResult: ZammadSyncResult, fields: ZammadSyncExistingFields, selectedTicketNumbers: Set ) { const result = emptyZammadSyncResult("applied"); const cachedTickets = new Map(); for (const item of previewResult.items) { if (!selectedTicketNumbers.has(item.ticketNumber)) { continue; } const zammadTicket = zammadTicketFromSyncSnapshot(item.zammad); if (zammadTicket) { cachedTickets.set(item.ticketNumber, zammadTicket); } } if (cachedTickets.size === 0) { return result; } const localTickets = await query( ` SELECT t.id::text AS id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, t.customer_name, t.work_type, t.zammad_ticket_id, t.zammad_group_id, t.zammad_priority_id, t.zammad_state_id, t.zammad_owner_id, t.zammad_preferences FROM tickets t LEFT JOIN organizations o ON o.zammad_id = t.organization_id WHERE t.ticket_number = ANY($1::text[]) ORDER BY t.ticket_number ASC; `, [Array.from(cachedTickets.keys())] ); for (const localTicket of localTickets.rows) { result.checked += 1; const zammadTicket = cachedTickets.get(localTicket.ticket_number); if (!zammadTicket) { continue; } try { const plan = await planExistingZammadTicketSync({ query }, localTicket, zammadTicket, userId, fields); if (!plan) { continue; } if (plan.action !== "update") { updateZammadSyncResultFromItem(result, plan, localTicket.work_type); continue; } const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); if (status === "organization-missing") { updateZammadSyncResultFromItem(result, { ...plan, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", changes: [] }, localTicket.work_type); continue; } updateZammadSyncResultFromItem(result, plan, localTicket.work_type); } catch (error) { updateZammadSyncResultFromItem(result, { ticketNumber: localTicket.ticket_number, action: "error", reason: error instanceof Error ? error.message : "Unbekannter Fehler", local: localSyncSnapshot(localTicket), zammad: zammadSyncSnapshot(zammadTicket), changes: [] }, localTicket.work_type); } } return result; } async function runConsultingZammadImportFromPreview(userId: string, previewResult: ZammadSyncResult, selectedTicketNumbers: Set) { const result = emptyZammadSyncResult("applied"); for (const item of previewResult.items) { if (!selectedTicketNumbers.has(item.ticketNumber)) { continue; } const zammadTicket = zammadTicketFromSyncSnapshot(item.zammad); if (!zammadTicket) { continue; } result.checked += 1; try { const plan = await planConsultingImport({ query }, zammadTicket); if (plan.action !== "create") { updateZammadSyncResultFromItem(result, plan); continue; } await withTransaction(async (client) => { await importZammadTicketForUser(client, zammadTicket, userId); }); updateZammadSyncResultFromItem(result, plan); } catch (error: any) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; if (error?.status === 400 || message === "Zammad Organisation ist lokal nicht synchronisiert") { updateZammadSyncResultFromItem(result, { ticketNumber: zammadTicket.ticketNumber, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }); } else { updateZammadSyncResultFromItem(result, { ticketNumber: zammadTicket.ticketNumber, action: "error", reason: message, local: null, zammad: zammadSyncSnapshot(zammadTicket), changes: [] }); } } } return result; } type ZammadSyncJob = { id: string; userId: string; status: "running" | "complete" | "error"; result: ZammadSyncResult | null; error: string | null; startedAt: string; finishedAt: string | null; }; const zammadSyncJobs = new Map(); function cleanupZammadSyncJobs() { const cutoff = Date.now() - 6 * 60 * 60 * 1000; for (const [jobId, job] of zammadSyncJobs.entries()) { const finishedAt = job.finishedAt ? Date.parse(job.finishedAt) : null; if (finishedAt && finishedAt < cutoff) { zammadSyncJobs.delete(jobId); } } } function createZammadSyncJob(userId: string, run: () => Promise) { cleanupZammadSyncJobs(); const job: ZammadSyncJob = { id: randomUUID(), userId, status: "running", result: null, error: null, startedAt: new Date().toISOString(), finishedAt: null }; zammadSyncJobs.set(job.id, job); void run() .then((result) => { job.status = "complete"; job.result = result; job.finishedAt = new Date().toISOString(); }) .catch((error) => { job.status = "error"; job.error = error instanceof Error ? error.message : "Unbekannter Fehler"; job.finishedAt = new Date().toISOString(); }); return job; } app.post("/api/zammad-sync/existing", requireUser, async (req, res) => { const userId = currentUser(req).id; const fields = zammadSyncExistingFields(req.body.fields); const dryRun = optionalBoolean(req.body.dryRun, true); const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); res.json(await runExistingZammadSync(userId, fields, dryRun, selectedTicketNumbers)); }); app.post("/api/zammad-sync/consulting-import", requireUser, async (req, res) => { const userId = currentUser(req).id; const dryRun = optionalBoolean(req.body.dryRun, true); const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); res.json(await runConsultingZammadImport(userId, dryRun, selectedTicketNumbers)); }); app.post("/api/zammad-sync/jobs/existing", requireUser, async (req, res) => { const userId = currentUser(req).id; const fields = zammadSyncExistingFields(req.body.fields); const dryRun = optionalBoolean(req.body.dryRun, true); const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); const job = createZammadSyncJob(userId, () => runExistingZammadSync(userId, fields, dryRun, selectedTicketNumbers)); res.status(202).json({ job }); }); app.post("/api/zammad-sync/jobs/consulting-import", requireUser, async (req, res) => { const userId = currentUser(req).id; const dryRun = optionalBoolean(req.body.dryRun, true); const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); const job = createZammadSyncJob(userId, () => runConsultingZammadImport(userId, dryRun, selectedTicketNumbers)); res.status(202).json({ job }); }); app.post("/api/zammad-sync/jobs/:jobId/apply-existing", requireUser, async (req, res) => { const userId = currentUser(req).id; const previewJob = zammadSyncJobs.get(String(req.params.jobId)); if (!previewJob || previewJob.userId !== userId || previewJob.status !== "complete" || !previewJob.result || previewJob.result.mode !== "preview") { res.status(404).json({ error: "Vorschau-Lauf nicht gefunden" }); return; } const fields = zammadSyncExistingFields(req.body.fields); const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); if (!selectedTicketNumbers || selectedTicketNumbers.size === 0) { throw badRequest("ticketNumbers must contain at least one ticket"); } const job = createZammadSyncJob(userId, () => runExistingZammadSyncFromPreview(userId, previewJob.result!, fields, selectedTicketNumbers)); res.status(202).json({ job }); }); app.post("/api/zammad-sync/jobs/:jobId/apply-consulting-import", requireUser, async (req, res) => { const userId = currentUser(req).id; const previewJob = zammadSyncJobs.get(String(req.params.jobId)); if (!previewJob || previewJob.userId !== userId || previewJob.status !== "complete" || !previewJob.result || previewJob.result.mode !== "preview") { res.status(404).json({ error: "Vorschau-Lauf nicht gefunden" }); return; } const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); if (!selectedTicketNumbers || selectedTicketNumbers.size === 0) { throw badRequest("ticketNumbers must contain at least one ticket"); } const job = createZammadSyncJob(userId, () => runConsultingZammadImportFromPreview(userId, previewJob.result!, selectedTicketNumbers)); res.status(202).json({ job }); }); app.get("/api/zammad-sync/jobs/:jobId", requireUser, async (req, res) => { const job = zammadSyncJobs.get(String(req.params.jobId)); if (!job || job.userId !== currentUser(req).id) { res.status(404).json({ error: "Sync-Lauf nicht gefunden" }); return; } res.json({ job }); }); app.get("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const includeClosed = req.query.includeClosed === "true"; const hiddenOnly = req.query.hidden === "true"; const hiddenProjectWhere = ` ( p.user_id = $1 OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = p.ticket_id AND viewer_sessions.user_id = $1 ) ) `; const hiddenCountResult = await query<{ count: string }>( ` SELECT COUNT(*) AS count FROM projects p WHERE ${hiddenProjectWhere} AND EXISTS ( SELECT 1 FROM hidden_user_projects hup WHERE hup.user_id = $1 AND hup.project_id = p.id ); `, [userId] ); const totalCountResult = await query<{ count: string }>( ` SELECT COUNT(*) AS count FROM projects p WHERE ${hiddenProjectWhere}; `, [userId] ); const result = await query( ` SELECT p.id::text AS id, p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, (p.user_id = $1::bigint) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, p.closed_at, p.close_review_required, p.created_at, p.updated_at, COALESCE(session_totals.session_count, 0)::int AS session_count, COALESCE(session_totals.recurring_session_count, 0)::int AS recurring_session_count, COALESCE(session_totals.manual_session_count, 0)::int AS manual_session_count, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $1 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, EXISTS ( SELECT 1 FROM hidden_user_projects hup WHERE hup.user_id = $1 AND hup.project_id = p.id ) AS hidden_for_user, COALESCE(todo_totals.open_todos, 0)::int AS open_todos, COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos FROM projects p JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT COUNT(*)::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 FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $1 ) session_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( json_agg( json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC ), '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $1 AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL ) unassigned_sessions ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos, COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos, COUNT(*) FILTER (WHERE status = 'done')::int AS done_todos FROM ticket_todos tt WHERE tt.ticket_id = p.ticket_id AND tt.user_id = $1 ) todo_totals ON true WHERE ( p.user_id = $1 OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = p.ticket_id AND viewer_sessions.user_id = $1 ) ) AND ( ( $3::boolean AND EXISTS ( SELECT 1 FROM hidden_user_projects hup WHERE hup.user_id = $1 AND hup.project_id = p.id ) ) OR ( NOT $3::boolean AND NOT EXISTS ( SELECT 1 FROM hidden_user_projects hup WHERE hup.user_id = $1 AND hup.project_id = p.id ) ) ) AND ($2::boolean OR $3::boolean OR p.closed_at IS NULL) ORDER BY CASE WHEN p.closed_at IS NULL THEN 0 ELSE 1 END, p.updated_at DESC, t.ticket_number ASC; `, [userId, includeClosed, hiddenOnly] ); res.json({ projects: result.rows, hiddenCount: Number(hiddenCountResult.rows[0]?.count ?? 0), totalCount: Number(totalCountResult.rows[0]?.count ?? 0) }); }); app.get("/api/projects/:projectId", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const result = await query( ` SELECT p.id::text AS id, p.user_id::text AS user_id, owner.username AS owner_username, owner.display_name AS owner_display_name, (p.user_id = $2::bigint) AS can_edit, p.ticket_id::text AS ticket_id, t.ticket_number, t.title, t.organization_id::text AS organization_id, o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, t.zammad_priority_id::text AS zammad_priority_id, CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id, t.zammad_preferences ->> 'pending_time' AS zammad_pending_time, p.teamspace_project, p.description, p.goals, p.starts_on::text AS starts_on, p.ends_on::text AS ends_on, entry_totals.total_hour_budget_minutes AS hour_budget_minutes, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, EXISTS ( SELECT 1 FROM recurring_billings rb WHERE rb.user_id = $2 AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number ) AS has_recurring_billing, COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes, (COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes, entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, p.close_review_required, p.created_at, p.updated_at FROM projects p JOIN users owner ON owner.id = p.user_id JOIN tickets t ON t.id = p.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes FROM ticket_day_billings tdb WHERE tdb.ticket_id = p.ticket_id ) teamspace_totals ON true LEFT JOIN LATERAL ( SELECT COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes, COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes, COUNT(*)::int AS teamspace_entry_count, COALESCE( json_agg( json_build_object( 'id', pte.id::text, 'project_id', pte.project_id::text, 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, 'display_order', pte.display_order ) ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC ), '[]'::json ) AS teamspace_entries FROM project_teamspace_entries pte WHERE pte.project_id = p.id ) entry_totals ON true LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS session_count FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $2 AND s.work_type = 'consulting' AND s.project_teamspace_entry_id IS NULL ) unassigned_sessions ON true WHERE p.id = $1 AND ( p.user_id = $2 OR EXISTS ( SELECT 1 FROM sessions viewer_sessions WHERE viewer_sessions.ticket_id = p.ticket_id AND viewer_sessions.user_id = $2 ) ); `, [projectId, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } res.json({ project: result.rows[0] }); }); app.post("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); const useZammadLookup = optionalBoolean(req.body.zammadLookup, false); const title = optionalNullableString(req.body.title); const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId); const teamspaceProject = optionalString(req.body.teamspaceProject); const description = optionalString(req.body.description) ?? ""; const goals = optionalString(req.body.goals) ?? ""; const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); } const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false; const zammadTicket = useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(userId, ticketNumber) : null; if (useZammadLookup && !existingLocalTicket && !zammadTicket) { res.status(404).json({ error: "Ticket wurde in Zammad nicht gefunden" }); return; } if (zammadTicket && zammadTicket.workType !== "consulting") { res.status(409).json({ error: "Das Zammad-Ticket ist kein Consulting-Ticket" }); return; } if (zammadTicket) { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null && budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes); teamspaceEntries = zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ id: null, teamspaceProject: teamspaceProjectName, isDefault: index === 0, hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged), manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0, displayOrder: index * 1024 })) : teamspaceEntries.map((entry) => entry.isDefault ? { ...entry, teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes, budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes)) } : entry ) .map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; } else { teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; } try { const created = await withTransaction(async (client) => { if (zammadTicket) { await importZammadTicketForUser(client, zammadTicket, userId); } const existingTicket = await client.query<{ id: string; title: string | null; organization_id: string | null; customer_name: string | null; zammad_owner_id: string | null; }>( ` SELECT id, title, organization_id::text AS organization_id, customer_name, zammad_owner_id::text AS zammad_owner_id FROM tickets WHERE ticket_number = $1 FOR UPDATE; `, [ticketNumber] ); const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId; if (!organizationId) { return "organization-required" as const; } const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { return "organization-not-found" as const; } const ticketResult = await client.query<{ id: string }>( ` INSERT INTO tickets (ticket_number, title, organization_id, customer_name, work_type) VALUES ($1, $2, $3, $4, 'consulting') ON CONFLICT (ticket_number) DO UPDATE SET title = COALESCE(EXCLUDED.title, tickets.title), organization_id = EXCLUDED.organization_id, customer_name = EXCLUDED.customer_name, work_type = 'consulting' RETURNING id; `, [ticketNumber, title, organization.id, organization.name] ); const ticketId = ticketResult.rows[0].id; const projectOwnerUserId = zammadTicket ? await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId) : userId; await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2, work_type = 'consulting' WHERE ticket_id = $3 AND user_id = $4 AND recurring_billing_id IS NULL; `, [organization.id, organization.name, ticketId, userId] ); if (zammadTicket && projectOwnerUserId !== userId) { await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4 }); return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId); } const projectResult = await client.query( ` INSERT INTO projects ( user_id, ticket_id, teamspace_project, description, goals, starts_on, ends_on, hour_budget_minutes, manual_consumption_minutes, close_review_required ) VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9, $15) ${zammadTicket ? ` ON CONFLICT (user_id, ticket_id) DO UPDATE SET teamspace_project = EXCLUDED.teamspace_project, description = EXCLUDED.description, goals = EXCLUDED.goals, starts_on = EXCLUDED.starts_on, ends_on = EXCLUDED.ends_on, hour_budget_minutes = EXCLUDED.hour_budget_minutes, manual_consumption_minutes = EXCLUDED.manual_consumption_minutes, close_review_required = projects.close_review_required OR EXCLUDED.close_review_required, updated_at = now() ` : ""} RETURNING id::text, user_id::text, NULL::text AS owner_username, NULL::text AS owner_display_name, true AS can_edit, ticket_id::text, $10::text AS ticket_number, COALESCE($13::text, $14::text) AS title, $11::text AS organization_id, $12::text AS organization_name, $12::text AS customer_name, 'consulting'::text AS work_type, teamspace_project, description, goals, starts_on::text, ends_on::text, hour_budget_minutes, manual_consumption_minutes, 0::int AS teamspace_consumption_minutes, manual_consumption_minutes AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, NULL::timestamptz AS closed_at, close_review_required, created_at, updated_at; `, [ projectOwnerUserId, ticketId, defaultTeamspaceEntry.teamspaceProject, description, goals, startsOn, endsOn, defaultTeamspaceEntry.hourBudgetMinutes, defaultTeamspaceEntry.manualConsumptionMinutes, ticketNumber, organization.id, organization.name, title, existingTicket.rows[0]?.title ?? null, Boolean(zammadTicket?.zammadStateId === 4) ] ); const project = projectResult.rows[0]; if (zammadTicket) { await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]); } for (const entry of teamspaceEntries) { await client.query( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, $3, $4, $5, $6, $7); `, [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); } await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: `new-${index}`, teamspace_project: entry.teamspaceProject, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, budget_sync_disabled: entry.budgetSyncDisabled, manual_consumption_minutes: entry.manualConsumptionMinutes, teamspace_consumption_minutes: entry.isDefault ? 0 : 0, consumption_minutes: entry.manualConsumptionMinutes, display_order: entry.displayOrder })); project.teamspace_entry_count = teamspaceEntries.length; return project; }); if (created === "organization-required") { res.status(400).json({ error: "Organisation ist erforderlich, wenn das Ticket noch keine Stammdaten hat" }); return; } if (created === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden" }); return; } res.status(201).json({ project: created }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Dieses Ticket ist bereits als Projekt angelegt" }); return; } throw error; } }); app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const ticketNumber = parseTicketNumber(req.body.ticketNumber); const title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); const teamspaceProject = optionalString(req.body.teamspaceProject); const description = optionalString(req.body.description) ?? ""; const goals = optionalString(req.body.goals) ?? ""; const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); } try { const updated = await withTransaction(async (client) => { const projectResult = await client.query<{ id: string; ticket_id: string }>( ` SELECT id, ticket_id FROM projects WHERE id = $1 AND user_id = $2 FOR UPDATE; `, [projectId, userId] ); if (projectResult.rowCount === 0) { return null; } const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>( ` SELECT id::text AS id, hour_budget_minutes FROM project_teamspace_entries WHERE project_id = $1 FOR UPDATE; `, [projectId] ); teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { return "organization-not-found" as const; } await client.query( ` UPDATE tickets SET ticket_number = $1, title = $2, organization_id = $3, customer_name = $4, work_type = 'consulting' WHERE id = $5; `, [ticketNumber, title, organization.id, organization.name, projectResult.rows[0].ticket_id] ); await client.query( ` UPDATE sessions SET organization_id = $1, customer_name = $2, work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END WHERE ticket_id = $3 AND user_id = $4; `, [organization.id, organization.name, projectResult.rows[0].ticket_id, userId] ); const result = await client.query( ` UPDATE projects SET teamspace_project = $1, description = $2, goals = $3, starts_on = $4::date, ends_on = $5::date, hour_budget_minutes = $6, manual_consumption_minutes = $7, updated_at = now() WHERE id = $8 AND user_id = $9 RETURNING id::text, ticket_id::text, $10::text AS ticket_number, $13::text AS title, $11::text AS organization_id, $12::text AS organization_name, $12::text AS customer_name, 'consulting'::text AS work_type, teamspace_project, description, goals, starts_on::text, ends_on::text, hour_budget_minutes, manual_consumption_minutes, ( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int FROM ticket_day_billings tdb WHERE tdb.ticket_id = projects.ticket_id AND tdb.user_id = projects.user_id ) AS teamspace_consumption_minutes, (( SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int FROM ticket_day_billings tdb WHERE tdb.ticket_id = projects.ticket_id AND tdb.user_id = projects.user_id ) + manual_consumption_minutes)::int AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, closed_at, close_review_required, created_at, updated_at; `, [ defaultTeamspaceEntry.teamspaceProject, description, goals, startsOn, endsOn, defaultTeamspaceEntry.hourBudgetMinutes, defaultTeamspaceEntry.manualConsumptionMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title ] ); const project = result.rows[0]; await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [project.id]); const savedEntryIds: string[] = []; for (const entry of teamspaceEntries) { if (entry.id) { const updatedEntry = await client.query<{ id: string }>( ` UPDATE project_teamspace_entries SET teamspace_project = $1, is_default = $2, hour_budget_minutes = $3, budget_sync_disabled = $4, manual_consumption_minutes = $5, display_order = $6, updated_at = now() WHERE id = $7 AND project_id = $8 RETURNING id::text AS id; `, [entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] ); if (updatedEntry.rowCount === 0) { return "teamspace-entry-not-found" as const; } savedEntryIds.push(updatedEntry.rows[0].id); } else { const insertedEntry = await client.query<{ id: string }>( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id::text AS id; `, [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); savedEntryIds.push(insertedEntry.rows[0].id); } } await client.query( ` DELETE FROM project_teamspace_entries WHERE project_id = $1 AND NOT (id = ANY($2::bigint[])); `, [project.id, savedEntryIds] ); await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); project.teamspace_entries = teamspaceEntries.map((entry, index) => ({ id: savedEntryIds[index] ?? `new-${index}`, teamspace_project: entry.teamspaceProject, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, budget_sync_disabled: entry.budgetSyncDisabled, manual_consumption_minutes: entry.manualConsumptionMinutes, teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0, consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes, display_order: entry.displayOrder })); project.teamspace_entry_count = teamspaceEntries.length; return project; }); if (updated === "organization-not-found") { res.status(404).json({ error: "Organisation nicht gefunden" }); return; } if (updated === "teamspace-entry-not-found") { res.status(404).json({ error: "Teamspace-Eintrag nicht gefunden" }); return; } if (!updated) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } res.json({ project: updated }); } catch (error: any) { if (error?.code === "23505") { res.status(409).json({ error: "Ticketnummer ist bereits vergeben" }); return; } throw error; } }); app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const closed = optionalBoolean(req.body.closed, false); const result = await query<{ id: string; ticket_id: string; closed_at: string | null }>( ` UPDATE projects SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, updated_at = now() WHERE id = $1 AND user_id = $2 RETURNING id::text, ticket_id::text, closed_at; `, [projectId, userId, closed] ); if (result.rowCount === 0) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } res.json({ project: result.rows[0] }); }); app.post("/api/projects/:projectId/hide", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const result = await query<{ id: string }>( ` SELECT p.id::text AS id FROM projects p WHERE p.id = $1 AND ( p.user_id = $2 OR EXISTS ( SELECT 1 FROM sessions s WHERE s.ticket_id = p.ticket_id AND s.user_id = $2 ) ); `, [projectId, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } await query( ` INSERT INTO hidden_user_projects (user_id, project_id) VALUES ($1, $2) ON CONFLICT (user_id, project_id) DO UPDATE SET hidden_at = now(); `, [userId, projectId] ); res.json({ hidden: result.rows[0] }); }); app.delete("/api/projects/:projectId/hide", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const result = await query<{ id: string }>( ` DELETE FROM hidden_user_projects WHERE user_id = $1 AND project_id = $2 RETURNING project_id::text AS id; `, [userId, projectId] ); if (result.rowCount === 0) { res.status(404).json({ error: "Ausblendung nicht gefunden" }); return; } res.json({ shown: result.rows[0] }); }); app.delete("/api/projects/:projectId", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; const result = await withTransaction(async (client) => { const projectResult = await client.query<{ id: string; ticket_id: string }>( ` SELECT id::text, ticket_id::text FROM projects WHERE id = $1 AND user_id = $2 FOR UPDATE; `, [projectId, userId] ); if (projectResult.rowCount === 0) { return null; } await deleteProjectDataForTicket(client, projectResult.rows[0].ticket_id, userId); await cleanupEmptyTickets(client, [projectResult.rows[0].ticket_id]); return projectResult.rows[0]; }); if (!result) { res.status(404).json({ error: "Projekt nicht gefunden" }); return; } res.json({ deleted: result }); }); app.get("/api/todos", requireUser, async (req, res) => { const userId = currentUser(req).id; const status = req.query.status === "done" || req.query.status === "open" ? req.query.status : "all"; const params: unknown[] = [userId]; const where = ["tt.user_id = $1"]; if (status !== "all") { params.push(status); where.push(`tt.status = $${params.length}`); } const result = await query( ` SELECT tt.id::text AS id, tt.ticket_id::text AS ticket_id, t.ticket_number, COALESCE(o.name, t.customer_name) AS organization_name, p.id::text AS project_id, p.teamspace_project, tt.title, tt.description, tt.priority, tt.status, tt.sort_order, tt.starts_on::text AS starts_on, tt.ends_on::text AS ends_on, tt.completed_at, tt.created_at, tt.updated_at FROM ticket_todos tt JOIN tickets t ON t.id = tt.ticket_id LEFT JOIN organizations o ON o.zammad_id = t.organization_id LEFT JOIN projects p ON p.ticket_id = tt.ticket_id AND p.user_id = tt.user_id WHERE ${where.join(" AND ")} ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, tt.sort_order ASC, tt.id ASC; `, params ); res.json({ todos: result.rows }); }); app.post("/api/tickets/:ticketId/todos", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const userId = currentUser(req).id; const title = requireString(req.body.title, "title"); const description = optionalString(req.body.description) ?? ""; const priority = parseTodoPriority(req.body.priority ?? "medium"); const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); } const ticketResult = await query<{ id: string }>("SELECT id FROM tickets WHERE id = $1;", [ticketId]); if (ticketResult.rowCount === 0) { res.status(404).json({ error: "Ticket nicht gefunden" }); return; } const result = await query( ` INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)) RETURNING id::text, ticket_id::text, title, description, priority, status, sort_order, starts_on::text AS starts_on, ends_on::text AS ends_on, completed_at, created_at, updated_at; `, [ticketId, userId, title, description, priority, startsOn, endsOn] ); res.status(201).json({ todo: result.rows[0] }); }); app.patch("/api/todos/:todoId", requireUser, async (req, res) => { const todoId = parsePositiveInteger(req.params.todoId, "todoId"); const userId = currentUser(req).id; const title = requireString(req.body.title, "title"); const description = optionalString(req.body.description) ?? ""; const priority = parseTodoPriority(req.body.priority); const status = parseTodoStatus(req.body.status); const startsOn = parseOptionalDateOnly(req.body.startsOn, "startsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); } const result = await query( ` UPDATE ticket_todos SET title = $1, description = $2, priority = $3, status = $4, starts_on = $5::date, ends_on = $6::date, completed_at = CASE WHEN $4 = 'done' THEN COALESCE(completed_at, now()) ELSE NULL END, updated_at = now() WHERE id = $7 AND user_id = $8 RETURNING id::text, ticket_id::text, title, description, priority, status, sort_order, starts_on::text AS starts_on, ends_on::text AS ends_on, completed_at, created_at, updated_at; `, [title, description, priority, status, startsOn, endsOn, todoId, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "ToDo nicht gefunden" }); return; } res.json({ todo: result.rows[0] }); }); app.put("/api/todos/order", requireUser, async (req, res) => { const userId = currentUser(req).id; const todoIds = parseTodoOrderIds(req.body.todoIds); if (todoIds.length === 0) { res.json({ ok: true }); return; } await withTransaction(async (client) => { const ownershipResult = await client.query<{ id: string }>( ` SELECT id::text FROM ticket_todos WHERE user_id = $1 AND id = ANY($2::bigint[]); `, [userId, todoIds] ); const ownedIds = new Set(ownershipResult.rows.map((row) => row.id)); const unauthorizedId = todoIds.find((todoId) => !ownedIds.has(String(todoId))); if (unauthorizedId) { throw badRequest(`todo ${unauthorizedId} does not belong to current user`); } await Promise.all( todoIds.map((todoId, index) => client.query( ` UPDATE ticket_todos SET sort_order = $1, updated_at = updated_at WHERE id = $2 AND user_id = $3; `, [(index + 1) * 1024, todoId, userId] ) ) ); }); res.json({ ok: true }); }); app.delete("/api/todos/:todoId", requireUser, async (req, res) => { const todoId = parsePositiveInteger(req.params.todoId, "todoId"); const userId = currentUser(req).id; const result = await query( ` DELETE FROM ticket_todos WHERE id = $1 AND user_id = $2 RETURNING id::text; `, [todoId, userId] ); if (result.rowCount === 0) { res.status(404).json({ error: "ToDo nicht gefunden" }); return; } res.json({ deleted: result.rows[0] }); }); app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const result = await getTicketDetails(ticketId, currentUser(req).id, { month: optionalTicketSessionMonth(req.query.month), status: optionalTicketSessionStatus(req.query.status), source: optionalTicketSessionSource(req.query.source) }); if (!result) { res.status(404).json({ error: "Ticket not found" }); return; } res.json(result); }); app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => { const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const title = optionalNullableString(req.body.title); const organizationId = parseOrganizationId(req.body.organizationId); const workType = parseWorkType(req.body.workType); const removeProjectData = optionalBoolean(req.body.removeProjectData, false); const userId = currentUser(req).id; try { const result = await withTransaction(async (client) => { const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { return "organization-not-found" as const; } const projectsResult = await client.query<{ id: string; user_id: string }>( ` SELECT id::text AS id, user_id::text AS user_id FROM projects WHERE ticket_id = $1 FOR UPDATE; `, [ticketId] ); const ownedProject = projectsResult.rows.find((project) => project.user_id === userId) ?? null; const hasOtherProject = projectsResult.rows.some((project) => project.user_id !== userId); if (hasOtherProject && !ownedProject) { return "project-not-owned" as const; } if (ownedProject && workType === "support" && !removeProjectData) { return "project-removal-required" as const; } const ticketResult = await client.query( ` UPDATE tickets SET ticket_number = $1, title = $2, organization_id = $3, customer_name = $4, work_type = $5 WHERE id = $6 RETURNING id, ticket_number, title, organization_id, customer_name, work_type; `, [ticketNumber, title, organization.id, organization.name, workType, ticketId] ); if (ticketResult.rowCount === 0) { return null; } if (workType === "support" && ownedProject) { await deleteProjectDataForTicket(client, String(ticketId), userId); } if (workType === "consulting") { await ensureProjectForTicket(client, String(ticketId), userId); } 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 === "project-removal-required") { res.status(409).json({ error: "Projektdaten müssen bestätigt entfernt werden" }); return; } if (result === "project-not-owned") { res.status(403).json({ error: "Nur der Zammad-Owner kann die Projektdaten ändern" }); 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 monthClosureResult = await query<{ closed_at: string }>( ` SELECT closed_at FROM month_closures WHERE month = date_trunc('month', $1::date)::date AND user_id = $2; `, [day.start, userId] ); if (monthClosureResult.rows.length > 0) { res.status(409).json({ error: "Monat ist abgeschlossen" }); return; } 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 >= $1::timestamptz AND s.started_at < $2::timestamptz AND s.user_id = $3 GROUP BY t.id, t.ticket_number HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 ORDER BY t.ticket_number ASC; `, [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 = 'billable')::int AS billable_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, mc.closed_at AS month_closed_at FROM sessions s LEFT JOIN month_closures mc ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date AND mc.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 (lockResult.rows[0].month_closed_at) { res.status(409).json({ error: "Monat ist abgeschlossen" }); 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 removeProjectData = optionalBoolean(req.body.removeProjectData, false); const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); 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 getOrganizationByZammadId(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; project_id: string | null; month_closed_at: string | null; }>( ` SELECT s.id, s.ticket_id, s.started_at, s.work_type, s.recurring_billing_id, rb.work_type AS recurring_work_type, p.id AS project_id, mc.closed_at AS month_closed_at FROM sessions s LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id LEFT JOIN projects p ON p.ticket_id = s.ticket_id AND p.user_id = s.user_id LEFT JOIN month_closures mc ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date AND mc.user_id = s.user_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 newMonthClosureResult = await client.query<{ closed_at: string }>( ` SELECT closed_at FROM month_closures WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date AND user_id = $2; `, [startedAt.toISOString(), userId] ); if (current.month_closed_at || newMonthClosureResult.rows.length > 0) { return "month-closed" as const; } const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType; let projectTeamspaceEntryId: number | null = null; if (current.project_id && current.work_type === "consulting" && effectiveWorkType === "support" && !removeProjectData) { return "project-removal-required" as const; } if (effectiveWorkType === "consulting") { const projectCountResult = await client.query<{ count: string }>( ` SELECT COUNT(*) AS count FROM projects WHERE ticket_id = $1; `, [current.ticket_id] ); if (Number(projectCountResult.rows[0]?.count ?? 0) === 0) { await ensureProjectForTicket(client, current.ticket_id, userId); } projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( client, current.ticket_id, userId, effectiveWorkType, requestedProjectTeamspaceEntryId ); } 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, project_teamspace_entry_id = $9 WHERE id = $10 AND user_id = $11 RETURNING *; `, [ organization.id, organization.name, activity, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, projectTeamspaceEntryId, sessionId, userId ] ); if (effectiveWorkType === "consulting") { await client.query( ` UPDATE tickets SET work_type = 'consulting', organization_id = COALESCE(organization_id, $2), customer_name = COALESCE(customer_name, $3) WHERE id = $1; `, [current.ticket_id, organization.id, organization.name] ); } if (current.project_id && effectiveWorkType === "support" && removeProjectData) { const remainingConsultingResult = await client.query<{ count: string }>( ` SELECT COUNT(*) AS count FROM sessions WHERE ticket_id = $1 AND work_type = 'consulting'; `, [current.ticket_id] ); if (Number(remainingConsultingResult.rows[0]?.count ?? 0) === 0) { await deleteProjectDataForTicket(client, current.ticket_id, userId); await client.query( ` UPDATE tickets SET work_type = 'support' WHERE id = $1; `, [current.ticket_id] ); } } 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 === "month-closed") { res.status(409).json({ error: "Monat ist abgeschlossen" }); return; } if (updated === "project-removal-required") { res.status(409).json({ error: "Projektdaten müssen bestätigt entfernt werden" }); 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 currentResult = 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; month_closed_at: string | null; }>( ` SELECT s.id, s.ticket_id, s.started_at, s.recurring_billing_id, s.recurring_billing_slot_id, s.recurring_occurrence_date, mc.closed_at AS month_closed_at FROM sessions s LEFT JOIN month_closures mc ON mc.month = date_trunc('month', s.started_at AT TIME ZONE 'Europe/Berlin')::date AND mc.user_id = s.user_id WHERE s.id = $1 AND s.user_id = $2 FOR UPDATE OF s; `, [sessionId, userId] ); if (currentResult.rows.length === 0) { return null; } const session = currentResult.rows[0]; if (session.month_closed_at) { return "month-closed" as const; } await client.query("DELETE FROM sessions WHERE id = $1 AND user_id = $2;", [sessionId, userId]); 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); const projectResult = await client.query<{ id: string; user_id: string }>( ` SELECT id::text AS id, user_id::text AS user_id FROM projects WHERE ticket_id = $1 ORDER BY (user_id = $2::bigint) DESC, id ASC; `, [session.ticket_id, userId] ); const ownedProject = projectResult.rows.find((project) => project.user_id === userId) ?? null; await cleanupEmptyTickets(client, [session.ticket_id]); const ticketStillExistsResult = await client.query<{ exists: boolean }>( ` SELECT EXISTS ( SELECT 1 FROM tickets WHERE id = $1 ) AS exists; `, [session.ticket_id] ); return { ...session, userTicketEmpty: Number(remainingUserResult.rows[0]?.count ?? 0) === 0, ticketDeleted: !ticketStillExistsResult.rows[0]?.exists, projectDeleteAvailable: remainingSessions === 0 && Boolean(ownedProject), project_id: ownedProject?.id ?? null }; }); if (!deleted) { res.status(404).json({ error: "Session not found" }); return; } if (deleted === "month-closed") { res.status(409).json({ error: "Monat ist abgeschlossen" }); 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; statusCode?: number; type?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(error); if (error.type === "entity.too.large" || error.status === 413 || error.statusCode === 413) { res.status(413).json({ error: `Upload ist zu groß. Aktuelles Limit: ${requestBodyLimit}.` }); return; } const status = error.status ?? error.statusCode ?? 500; res.status(status).json({ error: status < 500 ? error.message : "Internal server error" }); }); async function start() { logDebug("startup", { phase: "migrate-start", port, serveFrontend, requestBodyLimit }); await migrate(); const adminResult = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users WHERE role = 'admin';"); logDebug("startup", { phase: "migrate-finished", adminCount: Number(adminResult.rows[0]?.count ?? 0) }); const server = app.listen(port, () => { console.log(`TicketTracker listening on port ${port}`); logDebug("startup", { phase: "listening", 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); });