Files
tickettracker/backend/src/index.ts
T

3269 lines
104 KiB
TypeScript

import "dotenv/config";
import cors from "cors";
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "./migrations.js";
import { pool, query, withTransaction } from "./db.js";
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
import {
badRequest,
parseDay,
parseBillingStatus,
parseIsoDate,
parseMonth,
parsePositiveInteger,
parseTicketNumber,
parseTrackableTicketNumber,
parseUsername,
parseWorkType,
requireString
} from "./validation.js";
const app = express();
const port = Number(process.env.PORT ?? 3000);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
const serveFrontend = process.env.SERVE_FRONTEND !== "false";
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.get("/api/health", async (_req, res) => {
await query("SELECT 1");
res.json({ ok: true });
});
type PeriodConfig = {
type: "month" | "day";
tablePrefix: "month" | "day";
closureTable: "month_closures" | "day_closures";
ticketClosureTable: "ticket_month_closures" | "ticket_day_closures";
column: "month" | "day";
};
type ParsedPeriod = {
label: string;
start: string;
startIso: string;
endIso: string;
};
function optionalString(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function optionalWorkType(value: unknown) {
if (value === undefined || value === null || value === "") {
return null;
}
return parseWorkType(value);
}
function parseOrganizationId(value: unknown) {
return parsePositiveInteger(value, "organizationId");
}
function parseOptionalBilledMinutes(value: unknown) {
if (value === null || value === undefined || value === "") {
return null;
}
const minutes = Number(value);
if (!Number.isInteger(minutes) || minutes < 0) {
throw badRequest("billedMinutes must be a non-negative integer or null");
}
return minutes;
}
function parseZammadBaseUrl(value: unknown) {
const raw = requireString(value, "baseUrl").replace(/\/+$/, "");
try {
const url = new URL(raw);
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw badRequest("baseUrl must use http or https");
}
return url.toString().replace(/\/+$/, "");
} catch {
throw badRequest("baseUrl must be a valid URL");
}
}
type OrganizationRow = {
id: string;
zammad_id: string;
name: string;
synced_at: string;
};
const zammadBaseUrlSettingKey = "zammad.base_url";
const zammadApiKeySettingKey = "zammad.api_key";
async function getAppSetting(key: string) {
const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]);
return result.rows[0]?.value ?? null;
}
async function setAppSetting(key: string, value: string) {
await query(
`
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
`,
[key, value]
);
}
async function getOrganizationById(
client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> },
organizationId: number
) {
const result = await client.query(
`
SELECT id, zammad_id, name, synced_at
FROM organizations
WHERE id = $1;
`,
[organizationId]
);
return result.rows[0] ?? null;
}
function optionalBoolean(value: unknown, fallback: boolean) {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value !== "boolean") {
throw badRequest("active must be boolean");
}
return value;
}
const periodConfigs: Record<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.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 = COALESCE(tickets.work_type, EXCLUDED.work_type)
RETURNING id;
`,
[rule.ticket_number, rule.organization_id, rule.organization_name, rule.work_type]
);
await client.query(
`
INSERT INTO sessions (
ticket_id,
organization_id,
customer_name,
activity,
work_type,
user_id,
started_at,
ended_at,
duration_seconds,
rounded_minutes,
recurring_billing_id,
recurring_billing_slot_id,
recurring_occurrence_date,
recurring_occurrence_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::date, $14)
ON CONFLICT (recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL DO NOTHING;
`,
[
ticketResult.rows[0].id,
rule.organization_id,
rule.organization_name,
rule.activity,
rule.work_type,
userId,
startedAt.toISOString(),
endedAt.toISOString(),
slot.duration_minutes * 60,
slot.duration_minutes,
rule.id,
slot.id,
occurrenceDate,
occurrenceKey
]
);
existingKeys.add(occurrenceKey);
}
}
}
});
}
async function cleanupEmptyTickets(client: { query: (text: string, params?: unknown[]) => Promise<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
);
`,
[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 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.organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
COUNT(s.id)::int AS session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
false AS requires_closure,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS ticket_open_count,
(COUNT(*) FILTER (WHERE s.billing_status IS NULL) = 0) AS closed,
NULL::timestamptz AS closed_at
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN organizations o ON o.id = t.organization_id
WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz
AND s.user_id = $3
GROUP BY
t.id,
t.ticket_number,
t.organization_id,
o.name,
t.customer_name,
t.work_type
ORDER BY t.ticket_number ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
SELECT
s.id,
s.ticket_id,
t.ticket_number,
s.organization_id,
so.name AS organization_name,
COALESCE(so.name, s.customer_name) AS customer_name,
s.activity,
s.work_type,
s.started_at,
s.rounded_minutes
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
AND s.billing_status IS NULL
ORDER BY s.started_at ASC;
`,
[period.startIso, period.endIso, userId]
),
config.type === "month"
? query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId])
: Promise.resolve({ rows: [] as { closed_at: string }[], rowCount: 0 }),
query(
`
SELECT
${bucketExpression} AS bucket_key,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes
FROM sessions s
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
GROUP BY bucket_key
ORDER BY bucket_key ASC;
`,
[period.startIso, period.endIso, userId]
),
query<{ crm_billed_minutes: number }>(
`
SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes
FROM ticket_day_billings tdb
WHERE tdb.user_id = $1
AND tdb.day >= $2::date
AND tdb.day < $3::timestamptz::date
AND EXISTS (
SELECT 1
FROM sessions s
WHERE s.ticket_id = tdb.ticket_id
AND s.user_id = tdb.user_id
AND (s.started_at AT TIME ZONE 'Europe/Berlin')::date = tdb.day
AND s.billing_status = 'billed'
);
`,
[userId, period.start, period.endIso]
)
]);
const tickets = ticketsResult.rows;
const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0);
const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0);
const openSessions = openResult.rows;
const periodClosed = config.type === "month" && closureResult.rows.length > 0;
return {
periodType: config.type,
period: period.label,
closed: periodClosed,
closedAt: periodClosed ? closureResult.rows[0]?.closed_at ?? null : null,
canClose: config.type === "month" && tickets.length > 0 && openSessions.length === 0,
totals: {
tickets: tickets.length,
sessions: totalSessions,
minutes: totalMinutes,
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0,
nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0),
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
openSessions: openSessions.length
},
tickets,
openSessions,
activitySeries: activityResult.rows
};
}
async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
const baseCte = `
WITH session_base AS (
SELECT
s.id,
s.ticket_id,
t.ticket_number,
COALESCE(s.organization_id, t.organization_id) AS organization_id,
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.work_type,
s.started_at,
(s.started_at AT TIME ZONE 'Europe/Berlin')::date AS day,
s.rounded_minutes,
s.billing_status,
s.recurring_billing_id
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations ot ON ot.id = t.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
),
ticket_day AS (
SELECT
ticket_id,
ticket_number,
day,
SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed')::int AS tracked_minutes
FROM session_base
GROUP BY ticket_id, ticket_number, day
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
),
ticket_day_billing AS (
SELECT
td.ticket_id,
td.ticket_number,
td.day,
td.tracked_minutes,
COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes,
tdb.billed_minutes IS NOT NULL AS has_crm_value,
ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged,
ack.acknowledged_at AS missing_crm_acknowledged_at
FROM ticket_day td
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = td.ticket_id
AND tdb.user_id = $3
AND tdb.day = td.day
LEFT JOIN ticket_day_billing_acknowledgements ack
ON ack.ticket_id = td.ticket_id
AND ack.user_id = $3
AND ack.day = td.day
),
session_group AS (
SELECT
sb.organization_id,
sb.organization_name,
sb.work_type,
sb.ticket_id,
sb.ticket_number,
sb.day,
COUNT(*)::int AS session_count,
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NULL)::int AS manual_session_count
FROM session_base sb
GROUP BY sb.organization_id, sb.organization_name, sb.work_type, sb.ticket_id, sb.ticket_number, sb.day
),
session_group_with_crm AS (
SELECT
sg.*,
ROUND(COALESCE(tdb.crm_billed_minutes, 0) * sg.total_minutes::numeric / NULLIF(tdb.tracked_minutes, 0))::int AS crm_billed_minutes
FROM session_group sg
LEFT JOIN ticket_day_billing tdb
ON tdb.ticket_id = sg.ticket_id
AND tdb.day = sg.day
)
`;
const [
totalsResult,
dailyResult,
organizationResult,
workTypeResult,
ticketResult,
openResult,
missingCrmResult,
acknowledgedMissingCrmResult,
mismatchResult,
closureResult
] = await Promise.all([
query(
`
${baseCte}
SELECT
COUNT(DISTINCT ticket_id)::int AS tickets,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed'), 0)::int AS manual_minutes,
COUNT(*) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed')::int AS recurring_sessions,
COUNT(*) FILTER (WHERE recurring_billing_id IS NULL AND billing_status = 'billed')::int AS manual_sessions,
COALESCE(ROUND(AVG(rounded_minutes) FILTER (WHERE billing_status = 'billed')), 0)::int AS average_session_minutes,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing), 0)::int AS crm_billed_minutes
FROM session_base;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
td.day::text AS day,
COALESCE(day_sessions.session_count, 0)::int AS sessions,
td.tracked_minutes::int AS total_minutes,
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM (
SELECT day, SUM(tracked_minutes)::int AS tracked_minutes
FROM ticket_day
GROUP BY day
) td
LEFT JOIN (
SELECT
day,
SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
SUM(billed_sessions)::int AS billed_sessions,
SUM(non_billable_sessions)::int AS non_billable_sessions,
SUM(open_count)::int AS open_sessions
FROM session_group
GROUP BY day
) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
ORDER BY td.day ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
organization_id::text AS organization_id,
organization_name,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY organization_id, organization_name
ORDER BY total_minutes DESC, organization_name ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
work_type,
COUNT(DISTINCT ticket_id)::int AS tickets,
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
FROM session_group_with_crm
GROUP BY work_type
ORDER BY total_minutes DESC, work_type ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
ticket_id::text AS ticket_id,
ticket_number,
MIN(organization_id)::text AS organization_id,
MIN(organization_name) AS organization_name,
COUNT(DISTINCT day) FILTER (WHERE billing_status = 'billed')::int AS active_days,
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
FROM session_base
GROUP BY ticket_id, ticket_number
HAVING SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed') > 0
ORDER BY total_minutes DESC, ticket_number ASC
LIMIT 12;
`,
[period.startIso, period.endIso, userId]
),
query(
`
SELECT
s.id::text AS id,
s.ticket_id::text AS ticket_id,
t.ticket_number,
COALESCE(so.name, o.name, s.customer_name, t.customer_name, 'Keine Organisation') AS organization_name,
s.activity,
s.started_at,
s.rounded_minutes
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations o ON o.id = t.organization_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
AND s.billing_status IS NULL
ORDER BY s.started_at ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes,
false AS acknowledged,
NULL::text AS acknowledged_at
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE NOT tdb.has_crm_value
AND NOT tdb.missing_crm_acknowledged
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY tdb.day ASC, tdb.ticket_number ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes,
true AS acknowledged,
MAX(tdb.missing_crm_acknowledged_at)::text AS acknowledged_at
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE NOT tdb.has_crm_value
AND tdb.missing_crm_acknowledged
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY tdb.day ASC, tdb.ticket_number ASC;
`,
[period.startIso, period.endIso, userId]
),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes,
(tdb.crm_billed_minutes - tdb.tracked_minutes)::int AS delta_minutes
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE tdb.has_crm_value
AND tdb.tracked_minutes <> tdb.crm_billed_minutes
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY ABS(tdb.tracked_minutes - tdb.crm_billed_minutes) DESC, tdb.day ASC;
`,
[period.startIso, period.endIso, userId]
),
query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date AND user_id = $2;", [period.start, userId])
]);
const totals = totalsResult.rows[0] ?? {
tickets: 0,
sessions: 0,
minutes: 0,
billed_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
billed_sessions: 0,
non_billable_sessions: 0,
open_sessions: 0,
recurring_minutes: 0,
manual_minutes: 0,
recurring_sessions: 0,
manual_sessions: 0,
average_session_minutes: 0,
active_days: 0,
crm_billed_minutes: 0
};
return {
periodType: "month",
period: period.label,
closed: closureResult.rows.length > 0,
closedAt: closureResult.rows[0]?.closed_at ?? null,
totals: {
tickets: totals.tickets,
sessions: totals.sessions,
minutes: totals.minutes,
billedMinutes: totals.billed_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
billedSessions: totals.billed_sessions,
nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
manualMinutes: totals.manual_minutes,
recurringSessions: totals.recurring_sessions,
manualSessions: totals.manual_sessions,
averageSessionMinutes: totals.average_session_minutes,
activeDays: totals.active_days,
crmBilledMinutes: totals.crm_billed_minutes,
crmDeltaMinutes: totals.crm_billed_minutes - totals.minutes
},
dailySeries: dailyResult.rows,
organizations: organizationResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
workTypes: workTypeResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
tickets: ticketResult.rows.map((row: any) => ({
...row,
crm_delta_minutes: row.crm_billed_minutes - row.total_minutes
})),
attention: {
openSessions: openResult.rows,
missingCrmDays: missingCrmResult.rows,
acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows,
crmMismatches: mismatchResult.rows
}
};
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
const periodClosureJoin =
config.type === "month"
? "LEFT JOIN month_closures pc ON pc.month = $3::date AND pc.user_id = $2"
: "LEFT JOIN (SELECT $3::date AS period_start, NULL::timestamptz AS closed_at) pc ON true";
const ticketResult = await query(
`
SELECT
t.id,
t.ticket_number,
t.organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
NULL::timestamptz AS closed_at,
pc.closed_at AS period_closed_at,
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count,
COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count,
COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count
FROM tickets t
LEFT JOIN organizations o ON o.id = t.organization_id
${periodClosureJoin}
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS session_count,
COUNT(*) FILTER (WHERE sm.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sm.recurring_billing_id IS NULL)::int AS manual_session_count
FROM sessions sm
WHERE sm.ticket_id = t.id
AND sm.user_id = $2
AND sm.started_at >= $4::timestamptz
AND sm.started_at < $5::timestamptz
) period_sessions ON true
WHERE t.id = $1;
`,
[ticketId, userId, period.start, period.startIso, period.endIso]
);
if (ticketResult.rowCount === 0) {
return null;
}
const sessionsResult = await query(
`
SELECT
id,
organization_id,
(
SELECT name
FROM organizations
WHERE id = sessions.organization_id
) AS organization_name,
COALESCE(
(
SELECT name
FROM organizations
WHERE id = sessions.organization_id
),
customer_name
) AS customer_name,
activity,
work_type,
started_at,
ended_at,
duration_seconds,
rounded_minutes,
billing_status,
billing_updated_at,
created_at,
recurring_billing_id,
recurring_billing_slot_id,
recurring_occurrence_date
FROM sessions
WHERE ticket_id = $1
AND started_at >= $2::timestamptz
AND started_at < $3::timestamptz
AND user_id = $4
ORDER BY started_at ASC;
`,
[ticketId, period.startIso, period.endIso, userId]
);
const dayBillingResult = await query(
`
SELECT day::text AS day, billed_minutes
FROM ticket_day_billings
WHERE ticket_id = $1
AND user_id = $2
AND day >= $3::date
AND day < $4::timestamptz::date
ORDER BY day ASC;
`,
[ticketId, userId, period.start, period.endIso]
);
const sessions = sessionsResult.rows;
const openCount = sessions.filter((session: any) => session.billing_status === null).length;
const manualSessionCount = sessions.filter((session: any) => session.recurring_billing_id === null).length;
const recurringSessionCount = sessions.length - manualSessionCount;
const ticket = ticketResult.rows[0] as any;
const ticketOpenCount = Number(ticket.ticket_open_count ?? 0);
return {
periodType: config.type,
period: period.label,
ticket: {
...ticket,
requires_closure: false,
recurring_session_count: recurringSessionCount,
manual_session_count: manualSessionCount
},
closed: config.type === "month" && Boolean(ticketResult.rows[0].period_closed_at),
closedAt: config.type === "month" ? ticketResult.rows[0].period_closed_at ?? null : null,
sessions,
dayBillings: dayBillingResult.rows,
canClose: false,
openCount,
ticketOpenCount
};
}
app.post("/api/auth/login", async (req, res) => {
const username = requireString(req.body.username, "username");
const password = requireString(req.body.password, "password");
const result = await query<{
id: string;
username: string;
display_name: string;
role: "admin" | "user";
active: boolean;
password_hash: string;
}>(
`
SELECT id, username, display_name, role, active, password_hash
FROM users
WHERE lower(username) = lower($1);
`,
[username]
);
const user = result.rows[0];
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
res.status(401).json({ error: "Benutzername oder Passwort ist falsch" });
return;
}
await createAuthSession(res, user.id);
res.json({
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
}
});
});
app.use("/api", requireAuth);
app.get("/api/auth/me", (req, res) => {
res.json({ user: currentUser(req) });
});
app.post("/api/auth/logout", async (req, res) => {
await clearAuthSession(req, res);
res.json({ ok: true });
});
app.patch("/api/auth/me", async (req, res) => {
const user = currentUser(req);
const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName");
const currentPassword = optionalString(req.body.currentPassword);
const newPassword = optionalString(req.body.newPassword);
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
}
if (newPassword) {
if (!currentPassword) {
throw badRequest("currentPassword is required");
}
const passwordResult = await query<{ password_hash: string }>("SELECT password_hash FROM users WHERE id = $1;", [user.id]);
if (passwordResult.rows.length === 0 || !(await verifyPassword(currentPassword, passwordResult.rows[0].password_hash))) {
res.status(401).json({ error: "Aktuelles Passwort ist falsch" });
return;
}
}
try {
const result = await query(
`
UPDATE users
SET username = $1,
display_name = $2,
password_hash = COALESCE($3, password_hash),
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active;
`,
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "User not found" });
return;
}
res.json({ user: result.rows[0] });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Benutzername ist bereits vergeben" });
return;
}
if (error?.code === "23514") {
res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" });
return;
}
throw error;
}
});
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query(
`
SELECT id, username, display_name, role, active, created_at, updated_at
FROM users
ORDER BY username ASC;
`
);
res.json({ users: result.rows });
});
app.post("/api/admin/users", requireAdmin, async (req, res) => {
const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName");
const password = requireString(req.body.password, "password");
const active = optionalBoolean(req.body.active, true);
if (password.length < 6) {
throw badRequest("password must be at least 6 characters");
}
try {
const result = await query(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
res.status(201).json({ user: result.rows[0] });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Benutzername ist bereits vergeben" });
return;
}
if (error?.code === "23514") {
res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" });
return;
}
throw error;
}
});
app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
const userId = parsePositiveInteger(req.params.userId, "userId");
const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName");
const active = optionalBoolean(req.body.active, true);
const password = optionalString(req.body.password);
if (password && password.length < 6) {
throw badRequest("password must be at least 6 characters");
}
const existingResult = await query<{ id: string; role: "admin" | "user" }>("SELECT id, role FROM users WHERE id = $1;", [userId]);
const existingUser = existingResult.rows[0] ?? null;
if (!existingUser) {
res.status(404).json({ error: "User not found" });
return;
}
if (existingUser.role === "admin" && currentUser(req).id !== String(userId)) {
res.status(409).json({ error: "Der feste Admin kann nicht über die Benutzerliste bearbeitet werden" });
return;
}
if (existingUser.role === "admin" && !active) {
res.status(409).json({ error: "Der feste Admin kann nicht deaktiviert werden" });
return;
}
try {
const result = await query(
`
UPDATE users
SET username = $1,
display_name = $2,
role = role,
active = $3,
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "User not found" });
return;
}
if (!active) {
await query("DELETE FROM auth_sessions WHERE user_id = $1;", [userId]);
}
res.json({ user: result.rows[0] });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Benutzername ist bereits vergeben" });
return;
}
if (error?.code === "23514") {
res.status(400).json({ error: "Benutzername muss 3-40 Zeichen lang sein und darf keine Leerzeichen enthalten" });
return;
}
throw error;
}
});
app.get("/api/organizations", async (req, res) => {
const search = optionalString(req.query.search);
const params: unknown[] = [];
const where: string[] = [];
if (search) {
params.push(`%${search}%`);
where.push(`name ILIKE $${params.length}`);
}
const result = await query<OrganizationRow>(
`
SELECT id, zammad_id, name, synced_at
FROM organizations
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY lower(name) ASC
LIMIT 500;
`,
params
);
res.json({ organizations: result.rows });
});
app.get("/api/admin/zammad/settings", requireAdmin, async (_req, res) => {
const [baseUrl, apiKey] = await Promise.all([getAppSetting(zammadBaseUrlSettingKey), getAppSetting(zammadApiKeySettingKey)]);
res.json({
settings: {
baseUrl: baseUrl ?? "",
hasApiKey: Boolean(apiKey)
}
});
});
app.put("/api/admin/zammad/settings", requireAdmin, async (req, res) => {
const baseUrl = parseZammadBaseUrl(req.body.baseUrl);
const apiKey = optionalString(req.body.apiKey);
await setAppSetting(zammadBaseUrlSettingKey, baseUrl);
if (apiKey) {
await setAppSetting(zammadApiKeySettingKey, apiKey);
}
res.json({
settings: {
baseUrl,
hasApiKey: Boolean(apiKey) || Boolean(await getAppSetting(zammadApiKeySettingKey))
}
});
});
app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res) => {
const requestBaseUrl = optionalString(req.body.baseUrl);
const requestApiKey = optionalString(req.body.apiKey);
const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey);
const savedApiKey = await getAppSetting(zammadApiKeySettingKey);
const baseUrl = parseZammadBaseUrl(requestBaseUrl ?? savedBaseUrl);
const apiKey = requestApiKey ?? savedApiKey;
if (!apiKey) {
throw badRequest("apiKey is required");
}
const response = await fetch(`${baseUrl}/api/v1/organizations`, {
headers: {
Authorization: `Token token=${apiKey}`,
Accept: "application/json"
}
});
if (!response.ok) {
const body = await response.text().catch(() => "");
res.status(502).json({
error: `Zammad Organisationen konnten nicht geladen werden (${response.status})`,
detail: body.slice(0, 500)
});
return;
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
throw badRequest("Zammad response must be an organization array");
}
const organizations = payload
.map((item) => {
if (!item || typeof item !== "object") {
return null;
}
const raw = item as Record<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<{ id: string }>(
`
SELECT id
FROM organizations
WHERE NOT (zammad_id = ANY($1::bigint[]));
`,
[zammadIds]
)
: await client.query<{ id: string }>(
`
SELECT id
FROM organizations;
`
);
const staleIds = staleResult.rows.map((row) => row.id);
if (staleIds.length > 0) {
const ticketResult = await client.query(
`
UPDATE tickets
SET organization_id = NULL
WHERE organization_id = ANY($1::bigint[]);
`,
[staleIds]
);
const sessionResult = await client.query(
`
UPDATE sessions
SET organization_id = NULL
WHERE organization_id = ANY($1::bigint[]);
`,
[staleIds]
);
const deleteResult = await client.query(
`
DELETE FROM organizations
WHERE id = ANY($1::bigint[]);
`,
[staleIds]
);
unlinkedTickets = ticketResult.rowCount ?? 0;
unlinkedSessions = sessionResult.rowCount ?? 0;
removed = deleteResult.rowCount ?? 0;
}
}
return {
synced,
removed,
unlinkedTickets,
unlinkedSessions
};
});
await setAppSetting(zammadBaseUrlSettingKey, baseUrl);
if (requestApiKey) {
await setAppSetting(zammadApiKeySettingKey, requestApiKey);
}
res.json({
...syncResult,
skipped: payload.length - syncResult.synced
});
});
const databaseExportTables = [
"users",
"auth_sessions",
"app_settings",
"organizations",
"tickets",
"recurring_billings",
"recurring_billing_slots",
"recurring_billing_exceptions",
"sessions",
"ticket_month_closures",
"month_closures",
"ticket_day_closures",
"day_closures",
"ticket_day_billings",
"ticket_day_billing_acknowledgements"
] as const;
function quoteIdentifier(value: string) {
return `"${value.replace(/"/g, '""')}"`;
}
function quoteLiteral(value: unknown) {
if (value === null || value === undefined) {
return "NULL";
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
}
if (Buffer.isBuffer(value)) {
return `decode('${value.toString("hex")}', 'hex')`;
}
return `'${String(value).replace(/'/g, "''")}'`;
}
async function exportTableSql(table: string) {
const columnsResult = await query<{ column_name: string }>(
`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position ASC;
`,
[table]
);
const columns = columnsResult.rows.map((row) => row.column_name);
if (columns.length === 0) {
return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`];
}
const rowsResult = await query<Record<string, unknown>>(
`
SELECT ${columns.map(quoteIdentifier).join(", ")}
FROM ${quoteIdentifier(table)}
ORDER BY ${columns.map(quoteIdentifier).join(", ")};
`
);
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`];
for (const row of rowsResult.rows) {
lines.push(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${columns.map((column) => quoteLiteral(row[column])).join(", ")});`
);
}
if (columns.includes("id")) {
lines.push(
`SELECT setval(pg_get_serial_sequence(${quoteLiteral(table)}, 'id'), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 1), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 0) > 0);`
);
}
return lines;
}
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
const lines = [
"-- TicketTracker Datenbankexport",
`-- Erstellt: ${new Date().toISOString()}`,
"-- Restore: frische TicketTracker-Datenbank migrieren lassen, danach diese Datei mit psql einspielen.",
"BEGIN;",
`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`
];
for (const table of databaseExportTables) {
lines.push("", ...(await exportTableSql(table)));
}
lines.push("", "COMMIT;", "");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
res.setHeader("Content-Type", "application/sql; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`);
res.send(lines.join("\n"));
});
function csvCell(value: unknown) {
if (value === null || value === undefined) {
return "";
}
const text = String(value);
if (/[;"\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
app.get("/api/export/months/:month", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const user = currentUser(req);
await ensureRecurringSessionsForUserPeriod(user.id, month.startIso, month.endIso);
const result = await query<{
ticket_number: string;
organization_name: string;
work_type: string;
day: string;
start_time: string;
end_time: string;
rounded_minutes: number;
billing_status: string | null;
teamspace_minutes: number | null;
source: string;
activity: string;
}>(
`
SELECT
t.ticket_number,
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, '') AS organization_name,
s.work_type,
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS day,
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS start_time,
to_char(s.ended_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS end_time,
s.rounded_minutes,
s.billing_status,
tdb.billed_minutes AS teamspace_minutes,
CASE WHEN s.recurring_billing_id IS NULL THEN 'manuell' ELSE 'fix' END AS source,
s.activity
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations ot ON ot.id = t.organization_id
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = s.ticket_id
AND tdb.user_id = s.user_id
AND tdb.day = (s.started_at AT TIME ZONE 'Europe/Berlin')::date
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
ORDER BY s.started_at ASC, t.ticket_number ASC, s.id ASC;
`,
[month.startIso, month.endIso, user.id]
);
const header = [
"Ticket",
"Organisation",
"Art",
"Datum",
"Von",
"Bis",
"Session-Minuten",
"Bewertung",
"Teamspace-Minuten",
"Quelle",
"Taetigkeit"
];
const rows = result.rows.map((row) => [
row.ticket_number,
row.organization_name,
row.work_type === "support" ? "Support" : "Consulting",
row.day,
row.start_time,
row.end_time,
row.rounded_minutes,
row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen",
row.teamspace_minutes ?? "",
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
row.activity
]);
const csv = [`\uFEFF${header.map(csvCell).join(";")}`, ...rows.map((row) => row.map(csvCell).join(";"))].join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-${user.username}-${month.label}.csv"`);
res.send(csv);
});
function parseRecurrenceType(value: unknown) {
if (value !== "weekly" && value !== "every_n_weeks") {
throw badRequest("recurrenceType must be weekly or every_n_weeks");
}
return value;
}
function parseNullableDay(value: unknown, field: string) {
if (value === null || value === undefined || value === "") {
return null;
}
return parseDay(requireString(value, field)).start;
}
function parseOptionalTicketNumber(value: unknown) {
const ticketNumber = optionalString(value);
return ticketNumber ? parseTicketNumber(ticketNumber) : null;
}
function parseWeekday(value: unknown) {
const weekday = parsePositiveInteger(value, "weekday");
if (weekday > 6) {
throw badRequest("weekday must be between 0 and 6");
}
return weekday;
}
function parseRecurringSlots(value: unknown, recurrenceType: "weekly" | "every_n_weeks") {
if (!Array.isArray(value) || value.length === 0) {
throw badRequest("slots are required");
}
return value.map((raw) => {
if (!raw || typeof raw !== "object") {
throw badRequest("slot must be an object");
}
const slot = raw as Record<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.id = rb.organization_id
LEFT JOIN recurring_billing_slots rbs ON rbs.recurring_billing_id = rb.id
WHERE rb.user_id = $1
GROUP BY rb.id, owner.username, owner.display_name, o.name
ORDER BY rb.id DESC;
`,
[userId]
);
return result.rows;
}
app.get("/api/recurring-billings", requireUser, async (req, res) => {
res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) });
});
app.post("/api/recurring-billings", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const payload = parseRecurringBillingPayload(req.body);
const created = await withTransaction(async (client) => {
const organization = await getOrganizationById(client, payload.organizationId);
if (!organization) {
return "organization-not-found" as const;
}
const billingResult = await client.query<{ id: string }>(
`
INSERT INTO recurring_billings (
user_id,
ticket_number,
organization_id,
activity,
work_type,
recurrence_type,
interval_value,
valid_from,
valid_until,
start_time
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::date, $9::date, $10::time)
RETURNING id;
`,
[
userId,
payload.ticketNumber,
organization.id,
payload.activity,
payload.workType,
payload.recurrenceType,
payload.intervalValue,
payload.validFrom,
payload.validUntil,
payload.startTime
]
);
for (const slot of payload.slots) {
await client.query(
`
INSERT INTO recurring_billing_slots (recurring_billing_id, weekday, start_time, duration_minutes)
VALUES ($1, $2, $3::time, $4);
`,
[billingResult.rows[0].id, slot.weekday, slot.startTime, slot.durationMinutes]
);
}
return billingResult.rows[0].id;
});
if (created === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden" });
return;
}
res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) });
});
app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
const billingId = parsePositiveInteger(req.params.billingId, "billingId");
const userId = currentUser(req).id;
const active = optionalBoolean(req.body.active, true);
const body = req.body as Record<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 s.id = $1
AND s.user_id = $2
FOR UPDATE OF s;
`,
[billingId, userId]
);
if (billingResult.rowCount === 0) {
return { status: "not-found" as const };
}
const organization = await getOrganizationById(client, payload.organizationId);
if (!organization) {
return { status: "organization-not-found" as const };
}
const previousSessionResult = await client.query<{ ticket_id: string; started_at: string }>(
`
SELECT ticket_id, started_at
FROM sessions
WHERE recurring_billing_id = $1
AND user_id = $2;
`,
[billingId, userId]
);
const previousTicketIds = previousSessionResult.rows.map((session) => session.ticket_id);
await client.query(
`
UPDATE recurring_billings
SET ticket_number = $1,
organization_id = $2,
activity = $3,
work_type = $4,
recurrence_type = $5,
interval_value = $6,
valid_from = $7::date,
valid_until = $8::date,
start_time = $9::time,
active = $10,
updated_at = now()
WHERE id = $11
AND user_id = $12;
`,
[
payload.ticketNumber,
organization.id,
payload.activity,
payload.workType,
payload.recurrenceType,
payload.intervalValue,
payload.validFrom,
payload.validUntil,
payload.startTime,
active,
billingId,
userId
]
);
const existingSlotsResult = await client.query<{ id: string }>(
`
SELECT id
FROM recurring_billing_slots
WHERE recurring_billing_id = $1;
`,
[billingId]
);
const existingSlotIds = new Set(existingSlotsResult.rows.map((slot) => slot.id));
const keptSlotIds: string[] = [];
for (const slot of payload.slots) {
if (slot.id !== null && existingSlotIds.has(String(slot.id))) {
const updatedSlot = await client.query<{ id: string }>(
`
UPDATE recurring_billing_slots
SET weekday = $1,
start_time = $2::time,
duration_minutes = $3
WHERE id = $4
AND recurring_billing_id = $5
RETURNING id;
`,
[slot.weekday, slot.startTime, slot.durationMinutes, slot.id, billingId]
);
keptSlotIds.push(updatedSlot.rows[0].id);
} else {
const insertedSlot = await client.query<{ id: string }>(
`
INSERT INTO recurring_billing_slots (recurring_billing_id, weekday, start_time, duration_minutes)
VALUES ($1, $2, $3::time, $4)
RETURNING id;
`,
[billingId, slot.weekday, slot.startTime, slot.durationMinutes]
);
keptSlotIds.push(insertedSlot.rows[0].id);
}
}
const removedSlotIds = [...existingSlotIds].filter((slotId) => !keptSlotIds.includes(slotId));
const deletedRows: Array<{ ticket_id: string; started_at: string }> = [];
if (removedSlotIds.length > 0) {
const deletedRemovedSlots = await client.query<{ ticket_id: string; started_at: string }>(
`
DELETE FROM sessions
WHERE recurring_billing_id = $1
AND user_id = $2
AND recurring_billing_slot_id = ANY($3::bigint[])
RETURNING ticket_id, started_at;
`,
[billingId, userId, removedSlotIds]
);
deletedRows.push(...deletedRemovedSlots.rows);
await client.query(
`
DELETE FROM recurring_billing_slots
WHERE recurring_billing_id = $1
AND id = ANY($2::bigint[]);
`,
[billingId, removedSlotIds]
);
}
const deletedOutOfRange = await client.query<{ ticket_id: string; started_at: string }>(
`
DELETE FROM sessions
WHERE recurring_billing_id = $1
AND user_id = $2
AND (
recurring_occurrence_date < $3::date
OR ($4::date IS NOT NULL AND recurring_occurrence_date > $4::date)
)
RETURNING ticket_id, started_at;
`,
[billingId, userId, payload.validFrom, payload.validUntil]
);
deletedRows.push(...deletedOutOfRange.rows);
const targetTicketNumber = payload.ticketNumber ?? `Fix#${billingId}`;
const targetTicketResult = await client.query<{ id: string }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (ticket_number)
DO UPDATE SET
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
RETURNING id;
`,
[targetTicketNumber, organization.id, organization.name, payload.workType]
);
const updatedSessions = await client.query<{ ticket_id: string; started_at: string }>(
`
UPDATE sessions s
SET ticket_id = $3,
organization_id = $4,
customer_name = $5,
activity = $6,
work_type = $7,
started_at = (s.recurring_occurrence_date::timestamp + slot.start_time)::timestamptz,
ended_at = ((s.recurring_occurrence_date::timestamp + slot.start_time) + (slot.duration_minutes || ' minutes')::interval)::timestamptz,
duration_seconds = slot.duration_minutes * 60,
rounded_minutes = slot.duration_minutes
FROM recurring_billing_slots slot
WHERE s.recurring_billing_id = $1
AND s.user_id = $2
AND s.recurring_billing_slot_id = slot.id
AND slot.recurring_billing_id = $1
AND s.recurring_occurrence_date >= $8::date
AND ($9::date IS NULL OR s.recurring_occurrence_date <= $9::date)
RETURNING s.ticket_id, s.started_at;
`,
[
billingId,
userId,
targetTicketResult.rows[0].id,
organization.id,
organization.name,
payload.activity,
payload.workType,
payload.validFrom,
payload.validUntil
]
);
for (const session of [...previousSessionResult.rows, ...deletedRows, ...updatedSessions.rows]) {
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId);
}
await cleanupEmptyTickets(client, [...previousTicketIds, ...deletedRows.map((session) => session.ticket_id)]);
return { status: "ok" as const };
});
if (result.status === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden" });
return;
}
if (result.status === "not-found") {
res.status(404).json({ error: "Wiederkehrende Abrechnung nicht gefunden" });
return;
}
res.json({ recurringBillings: await recurringBillingResponse(userId) });
});
app.delete("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
const billingId = parsePositiveInteger(req.params.billingId, "billingId");
const userId = currentUser(req).id;
const result = await withTransaction(async (client) => {
const billingResult = await client.query<{ id: string }>(
`
SELECT id
FROM recurring_billings
WHERE id = $1
AND user_id = $2
FOR UPDATE;
`,
[billingId, userId]
);
if (billingResult.rowCount === 0) {
return { status: "not-found" as const };
}
const deletedSessions = await client.query<{ ticket_id: string; started_at: string }>(
`
DELETE FROM sessions
WHERE recurring_billing_id = $1
AND user_id = $2
RETURNING ticket_id, started_at;
`,
[billingId, userId]
);
await client.query("DELETE FROM recurring_billings WHERE id = $1 AND user_id = $2;", [billingId, userId]);
for (const session of deletedSessions.rows) {
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId);
}
await cleanupEmptyTickets(client, deletedSessions.rows.map((session) => session.ticket_id));
return {
status: "ok" as const,
deletedSessions: deletedSessions.rowCount ?? 0
};
});
if (result.status === "not-found") {
res.status(404).json({ error: "Wiederkehrende Abrechnung nicht gefunden" });
return;
}
res.json({ recurringBillings: await recurringBillingResponse(userId), deletedSessions: result.deletedSessions });
});
app.get("/api/admin/sessions", requireAdmin, async (_req, res) => {
res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
});
app.post("/api/admin/sessions", requireAdmin, async (_req, res) => {
res.status(410).json({ error: "Der feste Admin erfasst keine Sessions" });
});
app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res) => {
res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
});
app.post("/api/sessions", requireUser, async (req, res) => {
const user = currentUser(req);
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
const activity = requireString(req.body.activity, "activity");
const requestedOrganizationId = req.body.organizationId === undefined || req.body.organizationId === null || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId);
const requestedWorkType = optionalWorkType(req.body.workType);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
if (endedAt < startedAt) {
throw badRequest("endedAt must be after startedAt");
}
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const created = await withTransaction(async (client) => {
const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
`
SELECT id, organization_id, customer_name, work_type
FROM tickets
WHERE ticket_number = $1
FOR UPDATE;
`,
[ticketNumber]
);
const existingTicket = existingTicketResult.rows[0] ?? null;
const effectiveOrganizationId = requestedOrganizationId ?? (existingTicket?.organization_id ? Number(existingTicket.organization_id) : null);
if (!effectiveOrganizationId) {
throw badRequest("organizationId is required");
}
const organization = await getOrganizationById(client, effectiveOrganizationId);
if (!organization) {
throw badRequest("organizationId must reference an existing organization");
}
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (ticket_number)
DO UPDATE SET
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
RETURNING id, ticket_number, organization_id, customer_name, work_type;
`,
[ticketNumber, organization.id, organization.name, requestedWorkType]
);
const ticket = ticketResult.rows[0];
const workType = requestedWorkType ?? ticket.work_type;
if (!workType) {
throw badRequest("workType must be support or consulting");
}
const sessionResult = await client.query(
`
INSERT INTO sessions (
ticket_id,
organization_id,
customer_name,
activity,
work_type,
user_id,
started_at,
ended_at,
duration_seconds,
rounded_minutes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
`,
[
ticket.id,
organization.id,
organization.name,
activity,
workType,
user.id,
startedAt.toISOString(),
endedAt.toISOString(),
durationSeconds,
roundedMinutes
]
);
await reopenPeriodsForSession(client, ticket.id, startedAt, user.id);
return {
ticket,
session: sessionResult.rows[0]
};
});
res.status(201).json(created);
});
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
res.json(await getPeriodOverview(config, period, currentUser(req).id));
});
app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
res.json(await getStatisticsOverview(month, currentUser(req).id));
});
app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const result = await query(
`
SELECT
t.id,
t.ticket_number,
t.organization_id,
o.name AS organization_name,
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type
FROM tickets t
LEFT JOIN organizations o ON o.id = t.organization_id
WHERE t.ticket_number = $1;
`,
[ticketNumber]
);
res.json({
ticket: result.rows[0] ?? null
});
});
app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
const organizationId = parseOrganizationId(req.body.organizationId);
const workType = parseWorkType(req.body.workType);
try {
const result = await withTransaction(async (client) => {
const organization = await getOrganizationById(client, organizationId);
if (!organization) {
return "organization-not-found" as const;
}
const ticketResult = await client.query(
`
UPDATE tickets
SET ticket_number = $1,
organization_id = $2,
customer_name = $3,
work_type = $4
WHERE id = $5
RETURNING id, ticket_number, organization_id, customer_name, work_type;
`,
[ticketNumber, organization.id, organization.name, workType, ticketId]
);
if (ticketResult.rowCount === 0) {
return null;
}
await client.query(
`
UPDATE sessions
SET organization_id = $1,
customer_name = $2,
work_type = CASE WHEN recurring_billing_id IS NULL THEN $3 ELSE work_type END
WHERE ticket_id = $4;
`,
[organization.id, organization.name, workType, ticketId]
);
return ticketResult.rows[0];
});
if (result === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" });
return;
}
if (!result) {
res.status(404).json({ error: "Ticket not found" });
return;
}
res.json({ ticket: result });
} catch (error: any) {
if (error?.code === "23505") {
res.status(409).json({ error: "Ticketnummer ist bereits vergeben" });
return;
}
throw error;
}
});
app.get("/api/periods/:periodType/:period/tickets/:ticketId", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await getPeriodTicket(config, period, ticketId, currentUser(req).id);
if (!result) {
res.status(404).json({ error: "Ticket not found" });
return;
}
res.json(result);
});
app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const day = parseDay(String(req.params.day));
const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes);
const userId = currentUser(req).id;
const sessionResult = await query<{ id: string }>(
`
SELECT id
FROM sessions
WHERE ticket_id = $1
AND user_id = $2
AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date
LIMIT 1;
`,
[ticketId, userId, day.start]
);
if (sessionResult.rowCount === 0) {
res.status(404).json({ error: "No sessions found for this ticket and day" });
return;
}
if (billedMinutes === null) {
await query(
`
DELETE FROM ticket_day_billings
WHERE ticket_id = $1
AND user_id = $2
AND day = $3::date;
`,
[ticketId, userId, day.start]
);
res.json({ dayBilling: null });
return;
}
const result = await query(
`
INSERT INTO ticket_day_billings (ticket_id, user_id, day, billed_minutes)
VALUES ($1, $2, $3::date, $4)
ON CONFLICT (ticket_id, user_id, day)
DO UPDATE SET billed_minutes = EXCLUDED.billed_minutes, updated_at = now()
RETURNING day::text AS day, billed_minutes;
`,
[ticketId, userId, day.start, billedMinutes]
);
await query(
`
DELETE FROM ticket_day_billing_acknowledgements
WHERE ticket_id = $1
AND user_id = $2
AND day = $3::date;
`,
[ticketId, userId, day.start]
);
res.json({ dayBilling: result.rows[0] });
});
app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const day = parseDay(String(req.params.day));
const acknowledged = req.body.acknowledged !== false;
const userId = currentUser(req).id;
const sessionResult = await query<{ id: string }>(
`
SELECT id
FROM sessions
WHERE ticket_id = $1
AND user_id = $2
AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date
LIMIT 1;
`,
[ticketId, userId, day.start]
);
if (sessionResult.rowCount === 0) {
res.status(404).json({ error: "No sessions found for this ticket and day" });
return;
}
if (!acknowledged) {
await query(
`
DELETE FROM ticket_day_billing_acknowledgements
WHERE ticket_id = $1
AND user_id = $2
AND day = $3::date;
`,
[ticketId, userId, day.start]
);
res.json({ acknowledged: false });
return;
}
const result = await query(
`
INSERT INTO ticket_day_billing_acknowledgements (ticket_id, user_id, day)
VALUES ($1, $2, $3::date)
ON CONFLICT (ticket_id, user_id, day)
DO UPDATE SET acknowledged_at = now()
RETURNING day::text AS day, acknowledged_at;
`,
[ticketId, userId, day.start]
);
res.json({ acknowledged: true, acknowledgement: result.rows[0] });
});
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", requireUser, async (req, res) => {
parsePeriod(String(req.params.periodType), String(req.params.period));
parsePositiveInteger(req.params.ticketId, "ticketId");
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte Sessions bewerten und den Monat abschließen." });
});
app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", requireUser, async (req, res) => {
parsePeriod(String(req.params.periodType), String(req.params.period));
parsePositiveInteger(req.params.ticketId, "ticketId");
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte den Monat öffnen." });
});
app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const userId = currentUser(req).id;
if (config.type !== "month") {
res.status(400).json({ error: "Tagesabschlüsse werden nicht verwendet. Bitte den Monat abschließen." });
return;
}
const blockersResult = await query(
`
SELECT
t.id AS ticket_id,
t.ticket_number,
COUNT(s.id)::int AS session_count,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
false AS ticket_closed
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz
AND s.user_id = $4
GROUP BY t.id, t.ticket_number
HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0
ORDER BY t.ticket_number ASC;
`,
[period.start, period.startIso, period.endIso, userId]
);
if (blockersResult.rows.length > 0) {
res.status(409).json({
error: "Period has open sessions",
blockers: blockersResult.rows
});
return;
}
const result = await query(
`
INSERT INTO month_closures (month, user_id)
VALUES ($1::date, $2)
ON CONFLICT (month, user_id)
DO UPDATE SET closed_at = now()
RETURNING *;
`,
[period.start, userId]
);
res.json({ closure: result.rows[0] });
});
app.post("/api/periods/:periodType/:period/reopen", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const userId = currentUser(req).id;
if (config.type !== "month") {
res.status(400).json({ error: "Tagesabschlüsse werden nicht verwendet. Bitte den Monat öffnen." });
return;
}
const result = await query(
`
DELETE FROM month_closures
WHERE month = $1::date AND user_id = $2
RETURNING *;
`,
[period.start, userId]
);
res.json({ reopened: result.rows.length > 0 });
});
app.use("/api/months", (_req, res) => {
res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." });
});
app.get("/api/months/:month/overview", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const [ticketsResult, openResult, monthClosureResult] = await Promise.all([
query(
`
SELECT
t.id,
t.ticket_number,
t.customer_name,
t.work_type,
COUNT(s.id)::int AS session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
tm.closed_at IS NOT NULL AS closed,
tm.closed_at
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date
WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz
GROUP BY t.id, t.ticket_number, t.customer_name, t.work_type, tm.closed_at
ORDER BY t.ticket_number ASC;
`,
[month.start, month.startIso, month.endIso]
),
query(
`
SELECT
s.id,
s.ticket_id,
t.ticket_number,
s.customer_name,
s.activity,
s.work_type,
s.started_at,
s.rounded_minutes
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.billing_status IS NULL
ORDER BY s.started_at ASC;
`,
[month.startIso, month.endIso]
),
query<{ closed_at: string }>("SELECT closed_at FROM month_closures WHERE month = $1::date;", [month.start])
]);
const tickets = ticketsResult.rows;
const totalSessions = tickets.reduce((sum: number, ticket: any) => sum + ticket.session_count, 0);
const totalMinutes = tickets.reduce((sum: number, ticket: any) => sum + ticket.total_minutes, 0);
const openSessions = openResult.rows;
const allTicketsClosed = tickets.length > 0 && tickets.every((ticket: any) => ticket.closed);
res.json({
month: month.label,
monthClosed: monthClosureResult.rows.length > 0,
monthClosedAt: monthClosureResult.rows[0]?.closed_at ?? null,
canCloseMonth: tickets.length > 0 && allTicketsClosed && openSessions.length === 0,
totals: {
tickets: tickets.length,
sessions: totalSessions,
minutes: totalMinutes,
openSessions: openSessions.length
},
tickets,
openSessions
});
});
app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const ticketResult = await query(
`
SELECT
t.id,
t.ticket_number,
t.customer_name,
t.work_type,
tm.closed_at,
mc.closed_at AS month_closed_at
FROM tickets t
LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date
LEFT JOIN month_closures mc ON mc.month = $1::date
WHERE t.id = $2;
`,
[month.start, ticketId]
);
if (ticketResult.rowCount === 0) {
res.status(404).json({ error: "Ticket not found" });
return;
}
const sessionsResult = await query(
`
SELECT
id,
customer_name,
activity,
work_type,
started_at,
ended_at,
duration_seconds,
rounded_minutes,
billing_status,
billing_updated_at,
created_at
FROM sessions
WHERE ticket_id = $1
AND started_at >= $2::timestamptz
AND started_at < $3::timestamptz
ORDER BY started_at ASC;
`,
[ticketId, month.startIso, month.endIso]
);
const sessions = sessionsResult.rows;
const openCount = sessions.filter((session: any) => session.billing_status === null).length;
res.json({
month: month.label,
ticket: ticketResult.rows[0],
monthClosed: Boolean(ticketResult.rows[0].month_closed_at),
monthClosedAt: ticketResult.rows[0].month_closed_at ?? null,
sessions,
canClose: sessions.length > 0 && openCount === 0,
openCount
});
});
app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const billingStatus = parseBillingStatus(req.body.billingStatus);
const userId = currentUser(req).id;
const lockResult = await query(
`
SELECT
s.ticket_id,
date_trunc('month', s.started_at)::date AS month,
date_trunc('day', s.started_at)::date AS day,
tm.closed_at AS ticket_month_closed_at
FROM sessions s
LEFT JOIN ticket_month_closures tm
ON tm.ticket_id = s.ticket_id
AND tm.month = date_trunc('month', s.started_at)::date
AND tm.user_id = s.user_id
WHERE s.id = $1
AND s.user_id = $2;
`,
[sessionId, userId]
);
if (lockResult.rowCount === 0) {
res.status(404).json({ error: "Session not found" });
return;
}
if (billingStatus !== null && lockResult.rows[0].ticket_month_closed_at) {
res.status(409).json({ error: "Ticket is closed for this period" });
return;
}
const result = await withTransaction(async (client) => {
const updated = await client.query(
`
UPDATE sessions
SET
billing_status = $1,
billing_updated_at = CASE WHEN $1::text IS NULL THEN NULL ELSE now() END
WHERE id = $2
AND user_id = $3
RETURNING *;
`,
[billingStatus, sessionId, userId]
);
if (billingStatus === null) {
await reopenPeriodsForSession(client, lockResult.rows[0].ticket_id, new Date(lockResult.rows[0].day), userId);
}
return updated;
});
if (result.rowCount === 0) {
res.status(404).json({ error: "Session not found" });
return;
}
res.json({ session: result.rows[0] });
});
app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const userId = currentUser(req).id;
const organizationId = parseOrganizationId(req.body.organizationId);
const activity = requireString(req.body.activity, "activity");
const workType = parseWorkType(req.body.workType);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
if (endedAt <= startedAt) {
throw badRequest("endedAt must be after startedAt");
}
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const updated = await withTransaction(async (client) => {
const organization = await getOrganizationById(client, organizationId);
if (!organization) {
return "organization-not-found" as const;
}
const currentResult = await client.query<{
id: string;
ticket_id: string;
started_at: string;
work_type: "support" | "consulting";
recurring_billing_id: string | null;
recurring_work_type: "support" | "consulting" | null;
}>(
`
SELECT
s.id,
s.ticket_id,
s.started_at,
s.work_type,
s.recurring_billing_id,
rb.work_type AS recurring_work_type
FROM sessions s
LEFT JOIN recurring_billings rb ON rb.id = s.recurring_billing_id
WHERE s.id = $1
AND s.user_id = $2
FOR UPDATE OF s;
`,
[sessionId, userId]
);
if (currentResult.rows.length === 0) {
return null;
}
const current = currentResult.rows[0];
const effectiveWorkType = current.recurring_billing_id ? current.recurring_work_type ?? current.work_type : workType;
await reopenPeriodsForSession(client, current.ticket_id, new Date(current.started_at), userId);
await reopenPeriodsForSession(client, current.ticket_id, startedAt, userId);
const result = await client.query(
`
UPDATE sessions
SET organization_id = $1,
customer_name = $2,
activity = $3,
work_type = $4,
started_at = $5,
ended_at = $6,
duration_seconds = $7,
rounded_minutes = $8
WHERE id = $9
AND user_id = $10
RETURNING *;
`,
[organization.id, organization.name, activity, effectiveWorkType, startedAt.toISOString(), endedAt.toISOString(), durationSeconds, roundedMinutes, sessionId, userId]
);
await cleanupTicketDayBillingIfEmpty(client, current.ticket_id, userId, new Date(current.started_at));
return result.rows[0];
});
if (updated === "organization-not-found") {
res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" });
return;
}
if (!updated) {
res.status(404).json({ error: "Session not found" });
return;
}
res.json({ session: updated });
});
app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const userId = currentUser(req).id;
const deleted = await withTransaction(async (client) => {
const sessionResult = await client.query<{
id: string;
ticket_id: string;
started_at: string;
recurring_billing_id: string | null;
recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null;
}>(
`
DELETE FROM sessions
WHERE id = $1
AND user_id = $2
RETURNING id, ticket_id, started_at, recurring_billing_id, recurring_billing_slot_id, recurring_occurrence_date;
`,
[sessionId, userId]
);
if (sessionResult.rows.length === 0) {
return null;
}
const session = sessionResult.rows[0];
if (session.recurring_billing_id && session.recurring_billing_slot_id && session.recurring_occurrence_date) {
await client.query(
`
INSERT INTO recurring_billing_exceptions (recurring_billing_id, recurring_billing_slot_id, occurrence_date, action)
VALUES ($1, $2, $3::date, 'cancelled')
ON CONFLICT (recurring_billing_id, recurring_billing_slot_id, occurrence_date)
DO UPDATE SET action = 'cancelled';
`,
[session.recurring_billing_id, session.recurring_billing_slot_id, session.recurring_occurrence_date]
);
}
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), userId);
await cleanupTicketDayBillingIfEmpty(client, session.ticket_id, userId, new Date(session.started_at));
const remainingUserResult = await client.query<{ count: string }>(
`
SELECT COUNT(*) AS count
FROM sessions
WHERE ticket_id = $1
AND user_id = $2;
`,
[session.ticket_id, userId]
);
const remainingResult = await client.query<{ count: string }>(
`
SELECT COUNT(*) AS count
FROM sessions
WHERE ticket_id = $1;
`,
[session.ticket_id]
);
const remainingSessions = Number(remainingResult.rows[0]?.count ?? 0);
if (remainingSessions === 0) {
await client.query("DELETE FROM tickets WHERE id = $1;", [session.ticket_id]);
}
return {
...session,
userTicketEmpty: Number(remainingUserResult.rows[0]?.count ?? 0) === 0,
ticketDeleted: remainingSessions === 0
};
});
if (!deleted) {
res.status(404).json({ error: "Session not found" });
return;
}
res.json({ deleted });
});
app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const statusResult = await query(
`
SELECT
COUNT(*)::int AS session_count,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_count
FROM sessions
WHERE ticket_id = $1
AND started_at >= $2::timestamptz
AND started_at < $3::timestamptz;
`,
[ticketId, month.startIso, month.endIso]
);
const status = statusResult.rows[0] as { session_count: number; open_count: number };
if (status.session_count === 0) {
res.status(404).json({ error: "No sessions found for this ticket and month" });
return;
}
if (status.open_count > 0) {
res.status(409).json({ error: "Ticket has open sessions", openCount: status.open_count });
return;
}
const closureResult = await query(
`
INSERT INTO ticket_month_closures (ticket_id, month)
VALUES ($1, $2::date)
ON CONFLICT (ticket_id, month)
DO UPDATE SET closed_at = now()
RETURNING *;
`,
[ticketId, month.start]
);
res.json({ closure: closureResult.rows[0] });
});
app.post("/api/months/:month/tickets/:ticketId/reopen", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await withTransaction(async (client) => {
const ticketClosureResult = await client.query(
`
DELETE FROM ticket_month_closures
WHERE ticket_id = $1 AND month = $2::date
RETURNING *;
`,
[ticketId, month.start]
);
const monthClosureResult = await client.query(
`
DELETE FROM month_closures
WHERE month = $1::date
RETURNING *;
`,
[month.start]
);
return {
ticketReopened: ticketClosureResult.rows.length > 0,
monthReopened: monthClosureResult.rows.length > 0
};
});
res.json(result);
});
app.post("/api/months/:month/close", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const blockersResult = await query(
`
SELECT
t.id AS ticket_id,
t.ticket_number,
COUNT(s.id)::int AS session_count,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
tm.closed_at IS NOT NULL AS ticket_closed
FROM tickets t
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN ticket_month_closures tm ON tm.ticket_id = t.id AND tm.month = $1::date
WHERE s.started_at >= $2::timestamptz AND s.started_at < $3::timestamptz
GROUP BY t.id, t.ticket_number, tm.closed_at
HAVING COUNT(*) FILTER (WHERE s.billing_status IS NULL) > 0 OR tm.closed_at IS NULL
ORDER BY t.ticket_number ASC;
`,
[month.start, month.startIso, month.endIso]
);
if (blockersResult.rows.length > 0) {
res.status(409).json({
error: "Month has unfinished tickets",
blockers: blockersResult.rows
});
return;
}
const result = await query(
`
INSERT INTO month_closures (month)
VALUES ($1::date)
ON CONFLICT (month)
DO UPDATE SET closed_at = now()
RETURNING *;
`,
[month.start]
);
res.json({ closure: result.rows[0] });
});
app.post("/api/months/:month/reopen", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const result = await query(
`
DELETE FROM month_closures
WHERE month = $1::date
RETURNING *;
`,
[month.start]
);
res.json({
reopened: result.rows.length > 0
});
});
if (serveFrontend) {
app.use(express.static(frontendDist));
app.get(/.*/, (_req, res) => {
res.sendFile(path.join(frontendDist, "index.html"));
});
}
app.use((error: Error & { status?: number }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(error);
res.status(error.status ?? 500).json({
error: error.status ? error.message : "Internal server error"
});
});
async function start() {
await migrate();
const server = app.listen(port, () => {
console.log(`TicketTracker listening on port ${port}`);
});
const shutdown = async () => {
server.close();
await pool.end();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
start().catch((error) => {
console.error(error);
process.exit(1);
});