3269 lines
104 KiB
TypeScript
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);
|
|
});
|