import type { AdminUser, AuthUser, BillingStatus, DashboardPreference, Organization, PeriodOverview, PeriodType, ProjectSummary, RecurringBilling, StatisticsOverview, TicketMeta, TicketPeriod, TicketTodo, TicketSearchResult, TodoPriority, TodoStatus, WorkType } from "./types"; type CreateSessionPayload = { ticketNumber: string; organizationId: string; activity: string; workType: WorkType; startedAt: string; endedAt: string; durationSeconds: number; zammadLookup?: boolean; projectTeamspaceEntryId?: string | null; }; type DeleteSessionResult = { deleted: { id: string; ticket_id: string; started_at: string; userTicketEmpty: boolean; ticketDeleted: boolean; projectDeleteAvailable: boolean; project_id: string | null; }; }; export type ZammadSyncExistingFields = { title: boolean; organization: boolean; type: boolean; projectName: boolean; budget: boolean; overrideBudget: boolean; }; export type ZammadSyncChange = { field: string; from: unknown; to: unknown; }; export type ZammadSyncItem = { ticketNumber: string; action: "update" | "create" | "skip" | "error"; reason: string | null; local: Record | null; zammad: Record | null; changes: ZammadSyncChange[]; }; export 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[]; }; export type ZammadSyncJob = { id: string; userId: string; status: "running" | "complete" | "error"; result: ZammadSyncResult | null; error: string | null; startedAt: string; finishedAt: string | null; }; export type RestoreValidationResult = { valid: boolean; checkedTables: number; issues: string[]; }; export class ApiError extends Error { constructor( message: string, public status: number ) { super(message); this.name = "ApiError"; } } type ApiRequestInit = RequestInit & { timeoutMs?: number; }; async function request(url: string, options: ApiRequestInit = {}): Promise { const { timeoutMs, signal, ...fetchOptions } = options; const controller = timeoutMs ? new AbortController() : null; const timeout = controller ? window.setTimeout(() => { controller.abort(); }, timeoutMs) : null; if (signal && controller) { signal.addEventListener("abort", () => controller.abort(), { once: true }); } let response: Response; try { response = await fetch(url, { signal: controller?.signal ?? signal, ...fetchOptions, credentials: "same-origin", headers: { "Content-Type": "application/json", ...fetchOptions.headers } }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { throw new ApiError("Anfrage hat zu lange gedauert", 408); } if (error instanceof TypeError) { throw new ApiError("Verbindung zum Server wurde unterbrochen. Bitte prüfen, ob Backend und Frontend laufen und ob das Backup kleiner als das Upload-Limit ist.", 503); } throw error; } finally { if (timeout) { window.clearTimeout(timeout); } } if (!response.ok) { const body = await response.json().catch(() => ({})); throw new ApiError(body.error ?? "Anfrage fehlgeschlagen", response.status); } return response.json() as Promise; } async function requestRestoreEndpoint(path: string, options: ApiRequestInit) { return request(path, options); } function backupValidationSql(sql: string) { const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/); const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport"; const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim())); return [marker, ...structuralLines].join("\n"); } export function login(payload: { username: string; password: string }) { return request<{ user: AuthUser }>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }); } export function logout() { return request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); } export function getCurrentUser() { return request<{ user: AuthUser }>("/api/auth/me"); } export function updateCurrentUser(payload: { username: string; displayName: string; currentPassword?: string; newPassword?: string; zammadApiToken?: string; }) { return request<{ user: AuthUser }>("/api/auth/me", { method: "PATCH", body: JSON.stringify(payload) }); } export function getDashboardPreference() { return request<{ preference: Partial }>("/api/dashboard/preferences"); } export function saveDashboardPreference(preference: DashboardPreference) { return request<{ preference: Partial }>("/api/dashboard/preferences", { method: "PUT", body: JSON.stringify({ preference }) }); } export function addFavoriteTicket(ticketNumber: string) { return request<{ preference: Partial }>("/api/dashboard/favorites", { method: "POST", body: JSON.stringify({ ticketNumber }) }); } export function removeFavoriteTicket(ticketNumber: string) { return request<{ preference: Partial }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, { method: "DELETE" }); } export function getSetupStatus() { return request<{ required: boolean }>("/api/setup/status"); } export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) { return request<{ user: AuthUser }>("/api/setup/admin", { method: "POST", body: JSON.stringify(payload) }); } export function restoreSetupBackup(sql: string) { return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", { method: "POST", timeoutMs: 10 * 60 * 1000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: sql }); } export function validateSetupBackup(sql: string) { return requestRestoreEndpoint("/api/setup/restore/validate", { method: "POST", timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: backupValidationSql(sql) }); } export function getAdminUsers() { return request<{ users: AdminUser[] }>("/api/admin/users"); } export function createAdminUser(payload: { username: string; displayName: string; password: string; active: boolean; }) { return request<{ user: AdminUser }>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }); } export function updateAdminUser( userId: string, payload: { username: string; displayName: string; password?: string; active: boolean; } ) { return request<{ user: AdminUser }>(`/api/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); } export async function downloadDatabaseExport() { const response = await fetch("/api/admin/export/database", { credentials: "same-origin" }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status); } const contentDisposition = response.headers.get("content-disposition") ?? ""; const filenameMatch = contentDisposition.match(/filename="([^"]+)"/); return { blob: await response.blob(), filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql` }; } export function restoreDatabaseBackup(sql: string) { return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", { method: "POST", timeoutMs: 10 * 60 * 1000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: sql }); } export function validateDatabaseBackup(sql: string) { return requestRestoreEndpoint("/api/admin/restore/database/validate", { method: "POST", timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: backupValidationSql(sql) }); } export async function downloadMonthExport(month: string) { const response = await fetch(`/api/export/months/${month}`, { credentials: "same-origin" }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status); } const contentDisposition = response.headers.get("content-disposition") ?? ""; const filenameMatch = contentDisposition.match(/filename="([^"]+)"/); return { blob: await response.blob(), filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv` }; } export function getOrganizations(search = "") { const params = new URLSearchParams(); if (search.trim()) { params.set("search", search.trim()); } return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`); } export function getZammadSettings() { return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings"); } export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }) { return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings", { method: "PUT", body: JSON.stringify(payload) }); } export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) { return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", { method: "POST", body: JSON.stringify(payload) }); } function wait(milliseconds: number) { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } async function waitForZammadSyncJob(jobId: string) { const startedAt = Date.now(); const timeoutMs = 10 * 60 * 1000; for (;;) { if (Date.now() - startedAt > timeoutMs) { throw new ApiError("Zammad-Sync läuft länger als erwartet. Bitte Vorschau neu starten.", 408); } const { job } = await request<{ job: ZammadSyncJob }>(`/api/zammad-sync/jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 20_000 }); if (job.status === "complete" && job.result) { return job.result; } if (job.status === "error") { throw new ApiError(job.error ?? "Zammad-Sync fehlgeschlagen", 500); } await wait(1200); } } async function runZammadSyncJob(url: string, body: Record) { const { job } = await request<{ job: ZammadSyncJob }>(url, { method: "POST", timeoutMs: 20_000, body: JSON.stringify(body) }); const result = await waitForZammadSyncJob(job.id); return { jobId: job.id, result }; } export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", { fields, dryRun: options.dryRun ?? true, ticketNumbers: options.ticketNumbers }); return result; } export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { dryRun: options.dryRun ?? true, ticketNumbers: options.ticketNumbers }); return result; } export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) { return runZammadSyncJob("/api/zammad-sync/jobs/existing", { fields, dryRun: true }); } export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, { fields, ticketNumbers }); } export function previewOwnZammadConsultingTickets() { return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { dryRun: true }); } export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, { ticketNumbers }); } export function getRecurringBillings() { return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); } export function createRecurringBilling(payload: { ticketNumber?: string | null; organizationId: string; activity: string; workType: WorkType; recurrenceType: "weekly" | "every_n_weeks"; intervalValue: number; validFrom: string; validUntil?: string | null; slots: Array<{ weekday: number | null; startTime: string; durationMinutes: number; }>; }) { return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings", { method: "POST", body: JSON.stringify(payload) }); } export function updateRecurringBilling( billingId: string, payload: | { active: boolean } | { ticketNumber?: string | null; organizationId: string; activity: string; workType: WorkType; recurrenceType: "weekly" | "every_n_weeks"; intervalValue: number; validFrom: string; validUntil?: string | null; slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number; }>; } ) { return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, { method: "PATCH", body: JSON.stringify(payload) }); } export function deleteRecurringBilling(billingId: string) { return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, { method: "DELETE" }); } export function getProjects(includeClosed = false, hidden = false) { const params = new URLSearchParams(); if (includeClosed) { params.set("includeClosed", "true"); } if (hidden) { params.set("hidden", "true"); } return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`); } export function getProject(projectId: string) { return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`); } export function createProject(payload: { ticketNumber: string; title?: string | null; organizationId?: string | null; zammadLookup?: boolean; teamspaceProject?: string | null; teamspaceEntries?: Array<{ id?: string | null; teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; budgetSyncDisabled?: boolean; manualConsumptionMinutes?: number | null; }>; description: string; goals: string; startsOn?: string | null; endsOn?: string | null; hourBudgetMinutes?: number | null; manualConsumptionMinutes?: number | null; }) { return request<{ project: ProjectSummary }>("/api/projects", { method: "POST", body: JSON.stringify(payload) }); } export function updateProject( projectId: string, payload: { ticketNumber: string; title?: string | null; organizationId: string; teamspaceProject?: string | null; teamspaceEntries?: Array<{ id?: string | null; teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; budgetSyncDisabled?: boolean; manualConsumptionMinutes?: number | null; }>; description: string; goals: string; startsOn?: string | null; endsOn?: string | null; hourBudgetMinutes?: number | null; manualConsumptionMinutes?: number | null; } ) { return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(payload) }); } export function updateProjectStatus(projectId: string, closed: boolean) { return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, { method: "PATCH", body: JSON.stringify({ closed }) }); } export function deleteProject(projectId: string) { return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, { method: "DELETE" }); } export function hideProject(projectId: string) { return request<{ hidden: { id: string } }>(`/api/projects/${projectId}/hide`, { method: "POST" }); } export function unhideProject(projectId: string) { return request<{ shown: { id: string } }>(`/api/projects/${projectId}/hide`, { method: "DELETE" }); } export function getTodos(status?: TodoStatus | "all") { const params = new URLSearchParams(); if (status && status !== "all") { params.set("status", status); } return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`); } export function createTicketTodo( ticketId: string, payload: { title: string; description: string; priority: TodoPriority; startsOn?: string | null; endsOn?: string | null; } ) { return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, { method: "POST", body: JSON.stringify(payload) }); } export function updateTodo( todoId: string, payload: { title: string; description: string; priority: TodoPriority; status: TodoStatus; startsOn?: string | null; endsOn?: string | null; } ) { return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, { method: "PATCH", body: JSON.stringify(payload) }); } export function updateTodoOrder(todoIds: string[]) { return request<{ ok: boolean }>("/api/todos/order", { method: "PUT", body: JSON.stringify({ todoIds }) }); } export function deleteTodo(todoId: string) { return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, { method: "DELETE" }); } export function createSession(payload: CreateSessionPayload) { return request("/api/sessions", { method: "POST", body: JSON.stringify(payload) }); } export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") { const params = new URLSearchParams({ ticketNumber }); if (source === "zammad") { params.set("source", source); } return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`); } export function searchTickets(query: string) { return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`); } export function updateTicket( ticketId: string, payload: { ticketNumber: string; title?: string | null; organizationId: string; workType: WorkType; removeProjectData?: boolean; } ) { return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, { method: "PATCH", body: JSON.stringify(payload) }); } export function updateSessionDetails( sessionId: string, payload: { organizationId: string; activity: string; workType: WorkType; removeProjectData?: boolean; projectTeamspaceEntryId?: string | null; startedAt: string; endedAt: string; } ) { return request(`/api/sessions/${sessionId}/details`, { method: "PATCH", body: JSON.stringify(payload) }); } export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) { return request(`/api/tickets/${ticketId}/day-billings/${day}`, { method: "PATCH", body: JSON.stringify({ billedMinutes }) }); } export function acknowledgeMissingTicketDayBilling(ticketId: string, day: string, acknowledged = true) { return request(`/api/tickets/${ticketId}/day-billings/${day}/acknowledgement`, { method: "PATCH", body: JSON.stringify({ acknowledged }) }); } export function getMonthOverview(month: string) { return getPeriodOverview("month", month); } export function getTicketMonth(month: string, ticketId: string) { return getTicketPeriod("month", month, ticketId); } function periodPath(type: PeriodType) { return type === "month" ? "months" : "days"; } export function getPeriodOverview(type: PeriodType, period: string) { return request(`/api/periods/${periodPath(type)}/${period}/overview`); } export function getStatisticsOverview(month: string) { return request(`/api/statistics/months/${month}`); } export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); } export function getTicketDetails( ticketId: string, filters: { month?: string; status?: "all" | "open" | "billed" | "non_billable"; source?: "all" | "manual" | "recurring"; } = {} ) { const params = new URLSearchParams(); if (filters.month && filters.month !== "all") { params.set("month", filters.month); } if (filters.status && filters.status !== "all") { params.set("status", filters.status); } if (filters.source && filters.source !== "all") { params.set("source", filters.source); } return request(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`); } export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { return request(`/api/sessions/${sessionId}/billing`, { method: "PATCH", body: JSON.stringify({ billingStatus }) }); } export function deleteSession(sessionId: string) { return request(`/api/sessions/${sessionId}`, { method: "DELETE" }); } export function closeTicketMonth(month: string, ticketId: string) { return closeTicketPeriod("month", month, ticketId); } export function reopenTicketMonth(month: string, ticketId: string) { return reopenTicketPeriod("month", month, ticketId); } export function closeMonth(month: string) { return closePeriod("month", month); } export function reopenMonth(month: string) { return reopenPeriod("month", month); } export function closeTicketPeriod(type: PeriodType, period: string, ticketId: string) { return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/close`, { method: "POST" }); } export function reopenTicketPeriod(type: PeriodType, period: string, ticketId: string) { return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/reopen`, { method: "POST" }); } export function closePeriod(type: PeriodType, period: string) { return request(`/api/periods/${periodPath(type)}/${period}/close`, { method: "POST" }); } export function reopenPeriod(type: PeriodType, period: string) { return request(`/api/periods/${periodPath(type)}/${period}/reopen`, { method: "POST" }); }