7785 lines
254 KiB
TypeScript
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);
|
|
});
|