Files
tickettracker/backend/src/index.ts
T
2026-09-14 06:42:52 +00:00

7785 lines
254 KiB
TypeScript

import "dotenv/config";
import cors from "cors";
import express from "express";
import { randomUUID } 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 {
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
} 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";
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<string, unknown> = {}) {
console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`);
}
function updateRestoreDebug(id: string, update: Partial<RestoreDebugState>) {
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<string, number> = {};
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 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 === "billed" || value === "non_billable") {
return value;
}
throw badRequest("status must be all, open, 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;
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;
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: <T extends pg.QueryResultRow = pg.QueryResultRow>(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<string, unknown>;
teamspaceBudgetMinutes: number | null;
teamspaceProject: string | null;
};
type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
type: boolean;
projectName: boolean;
budget: boolean;
};
type ZammadSyncChange = {
field: string;
from: unknown;
to: unknown;
};
type ZammadSyncItem = {
ticketNumber: string;
action: "update" | "create" | "skip" | "error";
reason: string | null;
local: Record<string, unknown> | null;
zammad: Record<string, unknown> | 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]);
return result.rows[0]?.value ?? null;
}
async function setAppSetting(key: string, value: string) {
await query(
`
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
`,
[key, value]
);
}
async function 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<string, unknown> {
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;
}
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 zammadPreferenceValue(ticket: Record<string, unknown>, 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 = userResult.rows[0]?.zammad_api_token ?? null;
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<unknown>;
}
function zammadTicketCandidates(payload: unknown): Record<string, unknown>[] {
if (Array.isArray(payload)) {
return payload.filter(isPlainObject);
}
if (isPlainObject(payload)) {
const nestedCandidates: Record<string, unknown>[] = [];
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<number>();
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<string, unknown>, 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 : {};
return {
zammadTicketId,
ticketNumber,
zammadGroupId: groupId,
zammadPriorityId: numberOrNull(rawTicket.priority_id),
zammadStateId: numberOrNull(rawTicket.state_id),
organizationId,
title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null,
zammadOwnerId: numberOrNull(rawTicket.owner_id),
workType,
preferences,
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
teamspaceProject:
typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim()
? String(zammadPreferenceValue(rawTicket, "project")).trim()
: null
};
}
async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): Promise<ZammadTicketImport | null> {
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;
}
async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<ZammadTicketImport[]> {
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<number, Record<string, unknown>>();
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,
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<string, unknown>;
}
async function getUserPreference(userId: string, key: string) {
const result = await query<{ value: Record<string, unknown> }>(
"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<string, unknown>) {
const result = await query<{ value: Record<string, unknown> }>(
`
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<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
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"),
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
displayOrder: index * 1024
};
});
if (!hasDefault) {
entries[0] = { ...entries[0], isDefault: true };
}
return entries;
}
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<string, PeriodConfig> = {
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<string, string> = {
"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<RecurringBillingRow>(
`
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<RecurringBillingSlotRow>(
`
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<string, RecurringBillingSlotRow[]>();
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<unknown> }, 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<unknown> },
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; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {}
) {
const hasTeamspaceProjectDefault = Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject");
const teamspaceProjectDefault = hasTeamspaceProjectDefault ? 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, manual_consumption_minutes, display_order)
VALUES ($1, $2, true, $3, $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
]
);
}
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<ProjectRow>(
`
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,
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,
'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<unknown> },
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)
};
}
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<string, unknown> | 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;
};
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 === "Organisation") {
return syncIdComparableValue(value);
}
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: syncComparableValue(from), to: syncComparableValue(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,
teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes
};
}
function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | 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 = numberOrNull(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
};
}
function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = 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,
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
}
: null
};
}
async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string) {
const result = await client.query<SyncProjectSnapshot>(
`
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
FROM projects p
LEFT JOIN LATERAL (
SELECT teamspace_project, hour_budget_minutes
FROM project_teamspace_entries
WHERE project_id = p.id
AND is_default
ORDER BY id ASC
LIMIT 1
) pte 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);
}
async function planExistingZammadTicketSync(
client: QueryClient,
localTicket: LocalZammadSyncTicket,
zammadTicket: ZammadTicketImport,
userId: string,
fields: ZammadSyncExistingFields
): Promise<ZammadSyncItem | null> {
if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) {
return 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),
zammad: zammadSyncSnapshot(zammadTicket),
changes: []
};
}
const changes: ZammadSyncChange[] = [];
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),
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);
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
return null;
}
if (!project) {
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
}
if (fields.projectName) {
addSyncChange(changes, "Teamspace-Projekt", project?.teamspace_project ?? null, zammadTicket.teamspaceProject);
}
if (fields.budget) {
addSyncChange(changes, "Teamspace-Budget", 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),
zammad: zammadSyncSnapshot(zammadTicket),
changes
};
}
async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTicketImport): Promise<ZammadSyncItem> {
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: zammadTicket.teamspaceProject ?? "" },
{ 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}`);
}
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 applyZammadTicketSync(
client: QueryClient,
localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null },
zammadTicket: ZammadTicketImport,
userId: string,
fields: ZammadSyncExistingFields
) {
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
if ((fields.organization || !localTicket.work_type) && !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,
fields.title,
zammadTicket.title,
fields.organization,
organization?.id ?? null,
organization?.name ?? null,
fields.type,
zammadTicket.workType,
zammadTicket.zammadTicketId,
zammadTicket.zammadGroupId,
zammadTicket.zammadPriorityId,
zammadTicket.zammadStateId,
zammadTicket.zammadOwnerId,
JSON.stringify(zammadTicket.preferences)
]
);
if (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.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 ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
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]
);
const projectUpdateParts: string[] = [];
const projectUpdateParams: unknown[] = [];
if (fields.projectName) {
projectUpdateParams.push(zammadTicket.teamspaceProject);
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
}
if (fields.budget) {
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
}
if (zammadTicket.zammadStateId === 4) {
projectUpdateParts.push("close_review_required = true");
}
if (projectUpdateParts.length > 0) {
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
await client.query(
`
UPDATE projects
SET ${projectUpdateParts.join(", ")},
updated_at = now()
WHERE ticket_id = $${projectUpdateParams.length - 1}
AND user_id = $${projectUpdateParams.length};
`,
projectUpdateParams
);
}
const projectResult = await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM projects
WHERE ticket_id = $1
AND user_id = $2
LIMIT 1;
`,
[localTicket.id, projectOwnerUserId]
);
if (projectResult.rows[0]) {
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
});
}
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 ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket.teamspaceProject,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
const projectUpdateParts: string[] = [];
const projectUpdateParams: unknown[] = [];
if (fields.projectName) {
projectUpdateParams.push(zammadTicket.teamspaceProject);
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
}
if (fields.budget) {
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
}
if (zammadTicket.zammadStateId === 4) {
projectUpdateParts.push("close_review_required = true");
}
if (projectUpdateParts.length > 0) {
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
await client.query(
`
UPDATE projects
SET ${projectUpdateParts.join(", ")},
updated_at = now()
WHERE ticket_id = $${projectUpdateParams.length - 1}
AND user_id = $${projectUpdateParams.length};
`,
projectUpdateParams
);
}
const projectResult = await client.query<{ id: string }>(
`
SELECT id::text AS id
FROM projects
WHERE ticket_id = $1
AND user_id = $2
LIMIT 1;
`,
[localTicket.id, projectOwnerUserId]
);
if (projectResult.rows[0]) {
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
});
}
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<unknown> },
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,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
false AS requires_closure,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS ticket_open_count,
(COUNT(*) FILTER (WHERE s.billing_status IS NULL) = 0) AS closed,
NULL::timestamptz AS closed_at
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $3
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
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,
nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0),
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
openSessions: openSessions.length
},
tickets,
openSessions,
activitySeries: activityResult.rows
};
}
async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
const baseCte = `
WITH session_base AS (
SELECT
s.id,
s.ticket_id,
t.ticket_number,
COALESCE(s.organization_id, t.organization_id) AS organization_id,
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.work_type,
s.started_at,
(s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day,
s.rounded_minutes,
s.billing_status,
s.recurring_billing_id
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
),
ticket_day AS (
SELECT
ticket_id,
ticket_number,
day,
SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed')::int AS tracked_minutes
FROM session_base
GROUP BY ticket_id, ticket_number, day
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
),
ticket_day_billing AS (
SELECT
td.ticket_id,
td.ticket_number,
td.day,
td.tracked_minutes,
COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes,
tdb.billed_minutes IS NOT NULL AS has_crm_value,
ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged,
ack.acknowledged_at AS missing_crm_acknowledged_at
FROM ticket_day td
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = td.ticket_id
AND tdb.user_id = $3
AND tdb.day = td.day
LEFT JOIN ticket_day_billing_acknowledgements ack
ON ack.ticket_id = td.ticket_id
AND ack.user_id = $3
AND ack.day = td.day
),
session_group AS (
SELECT
sb.organization_id,
sb.organization_name,
sb.work_type,
sb.ticket_id,
sb.ticket_number,
sb.day,
COUNT(*)::int AS session_count,
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
FROM session_base sb
GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day
),
session_group_with_crm AS (
SELECT
sg.*,
ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes
FROM session_group sg
LEFT JOIN ticket_day_billing tdb
ON tdb.ticket_id = sg.ticket_id
AND tdb.day = sg.day
)
`;
const [
totalsResult,
dailyResult,
organizationResult,
workTypeResult,
ticketResult,
openResult,
missingCrmResult,
acknowledgedMissingCrmResult,
mismatchResult,
closureResult
] = await Promise.all([
query(
`
${baseCte}
SELECT
COUNT(DISTINCT ticket_id)::int AS tickets,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 0)::int AS manual_minutes,
COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed')::int AS recurring_sessions,
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed')::int AS manual_sessions,
COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
FROM session_base;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
td.day::text AS day,
COALESCE(day_sessions.session_count, 0)::int AS sessions,
td.tracked_minutes::int AS total_minutes,
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM (
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
FROM ticket_day
GROUP BY day
) td
LEFT JOIN (
SELECT
day,
SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
SUM(billed_sessions)::int AS billed_sessions,
SUM(non_billable_sessions)::int AS non_billable_sessions,
SUM(open_count)::int AS open_sessions
FROM session_group
GROUP BY day
) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
ORDER BY td.day ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
organization_id::text AS organization_id,
organization_name,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY organization_id, organization_name
ORDER BY total_minutes DESC, organization_name ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
work_type,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY work_type
ORDER BY total_minutes DESC, work_type ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
ticket_id::text AS ticket_id,
ticket_number,
MIN(organization_id)::text AS organization_id,
MIN(organization_name) AS organization_name,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
FROM session_base
GROUP BY ticket_id, ticket_number
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
ORDER BY total_minutes DESC, ticket_number ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query(
`
SELECT
s.id::text AS id,
s.ticket_id::text AS ticket_id,
t.ticket_number,
COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.started_at,
s.rounded_minutes
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.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<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId])
]);
const totals = totalsResult.rows[0] ?? {
tickets: 0,
sessions: 0,
minutes: 0,
billed_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
billed_sessions: 0,
non_billable_sessions: 0,
open_sessions: 0,
recurring_minutes: 0,
manual_minutes: 0,
recurring_sessions: 0,
manual_sessions: 0,
average_session_minutes: 0,
active_days: 0,
crm_billed_minutes: 0
};
return {
periodType: "month",
period: period.label,
closed: closureResult.rows.length > 0,
closedAt: closureResult.rows[0]?.closed_at ?? null,
totals: {
tickets: totals.tickets,
sessions: totals.sessions,
minutes: totals.minutes,
billedMinutes: totals.billed_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
billedSessions: totals.billed_sessions,
nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
manualMinutes: totals.manual_minutes,
recurringSessions: totals.recurring_sessions,
manualSessions: totals.manual_sessions,
averageSessionMinutes: totals.average_session_minutes,
activeDays: totals.active_days,
crmBilledMinutes: totals.crm_billed_minutes,
crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
},
dailySeries: dailyResult.rows,
organizations: organizationResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
workTypes: workTypeResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
tickets: ticketResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
attention: {
openSessions: openResult.rows,
missingCrmDays: missingCrmResult.rows,
acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows,
crmMismatches: mismatchResult.rows
}
};
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
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,
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,
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" | "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,
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,
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
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,
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,
'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;
}>(
`
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;
`,
[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 username = requireString(req.body.username, "username");
const password = requireString(req.body.password, "password");
const result = await query<{
id: string;
username: string;
display_name: string;
role: "admin" | "user";
active: boolean;
password_hash: string;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}>(
`
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
FROM users
WHERE lower(username) = lower($1);
`,
[username]
);
const user = result.rows[0];
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
res.status(401).json({ error: "Benutzername oder Passwort ist falsch" });
return;
}
await createAuthSession(res, user.id);
res.json({
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active,
zammad_user_id: user.zammad_user_id,
has_zammad_api_token: user.has_zammad_api_token
}
});
});
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.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;
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,
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;
`,
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId]
);
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, 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, 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, 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<OrganizationRow>(
`
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<string, unknown>;
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<typeof organization> => 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 === "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<string, unknown>;
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<string, unknown>) {
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<string, unknown>;
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 startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
if (endedAt < startedAt) {
throw badRequest("endedAt must be after startedAt");
}
if (ticketNumber.startsWith("Fix#") || (await ticketHasRecurringBilling({ query }, ticketNumber, user.id))) {
res.status(409).json({ error: "Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten." });
return;
}
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(), user.id]
);
if (monthClosureResult.rows.length > 0) {
res.status(409).json({ error: "Monat ist abgeschlossen" });
return;
}
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const existingLocalTicket = useZammadLookup ? await ticketExists(ticketNumber) : false;
const zammadTicket = useZammadLookup && !existingLocalTicket ? await fetchZammadTicketByNumber(user.id, ticketNumber) : null;
if (useZammadLookup && !existingLocalTicket && !zammadTicket) {
res.status(404).json({ error: "Ticket wurde in Zammad nicht gefunden" });
return;
}
const created = await withTransaction(async (client) => {
if (zammadTicket) {
await importZammadTicketForUser(client, zammadTicket, user.id);
}
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, user.id]
);
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 }>(
`
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;
`,
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, user.id]
);
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,
user.id
);
await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, {
teamspaceProject: zammadTicket?.teamspaceProject ?? null,
hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null,
closeReviewRequired: zammadTicket?.zammadStateId === 4
});
projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession(
client,
ticket.id,
user.id,
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
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *;
`,
[
ticket.id,
organization.id,
organization.name,
activity,
workType,
user.id,
startedAt.toISOString(),
endedAt.toISOString(),
durationSeconds,
roundedMinutes,
projectTeamspaceEntryId
]
);
await reopenPeriodsForSession(client, ticket.id, startedAt, user.id);
if (workType === "consulting") {
await reopenProjectsForTicket(client, ticket.id);
await showProjectsForUserTicket(client, ticket.id, user.id);
}
return {
ticket,
session: sessionResult.rows[0]
};
});
res.status(201).json(created);
});
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
res.json(await getPeriodOverview(config, period, currentUser(req).id));
});
app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
res.json(await getStatisticsOverview(month, currentUser(req).id));
});
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const 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,
t.zammad_state_id::text 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,
'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 previewEntries =
zammadTicket.workType === "consulting" && (zammadTicket.teamspaceProject || zammadTicket.teamspaceBudgetMinutes !== null)
? [
{
id: `zammad-${zammadTicket.zammadTicketId}-default`,
teamspace_project: zammadTicket.teamspaceProject,
is_default: true,
hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
manual_consumption_minutes: 0,
display_order: 0
}
]
: [];
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: zammadTicket.teamspaceProject,
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<string> | null
) {
const result = emptyZammadSyncResult(dryRun ? "preview" : "applied");
const localTickets = selectedTicketNumbers
? await query<LocalZammadSyncTicket>(
`
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<LocalZammadSyncTicket>(
`
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<string> | 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<string>
) {
const result = emptyZammadSyncResult("applied");
const cachedTickets = new Map<string, ZammadTicketImport>();
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<LocalZammadSyncTicket>(
`
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<string>) {
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<string, ZammadSyncJob>();
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<ZammadSyncResult>) {
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,
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,
'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<ProjectRow>(
`
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,
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,
'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) {
teamspaceEntries = teamspaceEntries.map((entry) =>
entry.isDefault
? {
...entry,
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes
}
: entry
);
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,
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
closeReviewRequired: zammadTicket.zammadStateId === 4
});
return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId);
}
const projectResult = await client.query<ProjectRow>(
`
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, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, 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,
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;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const 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 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<ProjectRow>(
`
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,
manual_consumption_minutes = $4,
display_order = $5,
updated_at = now()
WHERE id = $6
AND project_id = $7
RETURNING id::text AS id;
`,
[entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, 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, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id::text AS id;
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, 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,
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 = '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);
});