commit 125b1d22ff1da88083497fe7356f82f86959b1dc Author: mboehmlaender Date: Wed Aug 5 10:55:14 2026 +0200 Initial TicketTracker release diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c842d10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +backend/dist +frontend/dist +.git +.env +*.log +npm-debug.log* +coverage +*.tsbuildinfo diff --git a/.env.docker-external.example b/.env.docker-external.example new file mode 100644 index 0000000..155c33b --- /dev/null +++ b/.env.docker-external.example @@ -0,0 +1,12 @@ +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +APP_PORT=3910 + +DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker +DB_POOL_SIZE=10 + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-now +ADMIN_DISPLAY_NAME=Admin + +COOKIE_SECURE=false +SESSION_MAX_AGE_MS=1209600000 diff --git a/.env.docker-postgres.example b/.env.docker-postgres.example new file mode 100644 index 0000000..4a70fdf --- /dev/null +++ b/.env.docker-postgres.example @@ -0,0 +1,14 @@ +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +APP_PORT=3910 + +POSTGRES_DB=tickettracker +POSTGRES_USER=tickettracker +POSTGRES_PASSWORD=change-me-db-password +DB_POOL_SIZE=10 + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-now +ADMIN_DISPLAY_NAME=Admin + +COOKIE_SECURE=false +SESSION_MAX_AGE_MS=1209600000 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e84357 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-now +ADMIN_DISPLAY_NAME=Admin +APP_PORT=3910 +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +COOKIE_SECURE=false +DB_POOL_SIZE=10 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c68236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +.env.* +!.env.example +!.env.docker-external.example +!.env.docker-postgres.example +node_modules/ +backend/dist/ +frontend/.next/ +frontend/dist/ +frontend/out/ +coverage/ +*.log +npm-debug.log* +*.tsbuildinfo +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e4adab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-bookworm-slim AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY package.json package-lock.json ./ +COPY backend/package.json ./backend/package.json +COPY frontend/package.json ./frontend/package.json +RUN npm ci + +COPY . . +RUN npm run build && npm prune --omit=dev + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV BACKEND_PORT=3001 +ENV SERVE_FRONTEND=false +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/backend/package.json ./backend/package.json +COPY --from=builder /app/backend/dist ./backend/dist +COPY --from=builder /app/frontend/package.json ./frontend/package.json +COPY --from=builder /app/frontend/.next ./frontend/.next +COPY --from=builder /app/frontend/public ./frontend/public +COPY scripts ./scripts +RUN chmod +x ./scripts/start-container.sh + +EXPOSE 3000 +CMD ["./scripts/start-container.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e30183 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# TicketTracker + +WebApp zum Erfassen von Ticket-Sessions mit Pausen, Minutenrundung und monatlichem Abschlussprozess. +Sessions können in Monats- und Tagesansichten ausgewertet, bewertet, geschlossen, wieder geöffnet und gelöscht werden. + +## Dokumentation + +- [Benutzeranleitung](docs/user-guide.md) +- [Docker Release / Gitea Deployment](docs/docker-release.md) + +## Stack + +- Frontend: Next.js + React + shadcn/ui + Tailwind CSS +- Backend: Node.js + Express + TypeScript +- Datenbank: PostgreSQL 17, wahlweise extern via `DATABASE_URL` oder als Container im Docker-Stack +- Deployment: ein Docker-Container, Backend serviert das gebaute Frontend + +shadcn/ui liefert kopierbare, lokal versionierte React-Komponenten auf Basis von Tailwind CSS. Die App nutzt ein eigenes Dark-Mode-Theme mit responsiven Layouts für Desktop und Mobile. + +## Lokal starten + +Als einzelner lokaler Server auf Port `3910`: + +```bash +npm install +export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker' +export ADMIN_USERNAME='admin' +export ADMIN_PASSWORD='ein-sicheres-passwort' +npm run serve:local +``` + +Danach ist die App unter `http://localhost:3910` erreichbar. + +Beim ersten Start wird automatisch ein Admin angelegt, wenn noch keine Benutzer existieren. Ohne `ADMIN_PASSWORD` wird `admin/admin` verwendet; das ist nur als Notfall-Fallback gedacht. + +Für die getrennte Entwicklung: + +```bash +npm install +export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker' +PORT=3001 npm run dev:backend +API_PROXY_TARGET=http://127.0.0.1:3001 PORT=3910 npm run dev:frontend +``` + +Frontend: `http://localhost:3910` +Backend/API: `http://localhost:3001` + +## Docker + +Externe PostgreSQL-Datenbank: + +```bash +docker build -t tickettracker:local . +docker run --rm -p 3910:3000 \ + -e DATABASE_URL='postgresql://user:pass@host:5432/tickettracker' \ + tickettracker:local +``` + +Compose mit externer Datenbank: + +```bash +cp .env.docker-external.example .env.docker-external +docker compose --env-file .env.docker-external -f compose.yml up -d +``` + +Compose mit PostgreSQL 17 im Stack: + +```bash +cp .env.docker-postgres.example .env.docker-postgres +docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d +``` + +## Gitea Container Registry + +Vor dem ersten Push einmal anmelden: + +```bash +docker login git.d-razz.de +``` + +Dann Image bauen und pushen: + +```bash +chmod +x scripts/build-and-push-gitea.sh +./scripts/build-and-push-gitea.sh +``` + +Auf dem Docker-Server: + +```bash +docker compose --env-file .env.docker-external -f compose.yml up -d +``` + +Wenn noch kein Gitea-Repository existiert, erst in Gitea `michael/tickettracker` leer anlegen und danach: + +```bash +chmod +x scripts/init-gitea-repo.sh +./scripts/init-gitea-repo.sh +git branch -M main +git push -u origin main +``` diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..9fd0ab7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tickettracker/backend", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^5.1.0", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.10.2", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..76e2b66 --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,164 @@ +import crypto from "node:crypto"; +import { promisify } from "node:util"; +import type { NextFunction, Request, Response } from "express"; +import { query } from "./db.js"; + +const scrypt = promisify(crypto.scrypt); +const sessionCookieName = "tickettracker_session"; +const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS ?? 1000 * 60 * 60 * 24 * 14); + +export type UserRole = "admin" | "user"; + +export type AuthUser = { + id: string; + username: string; + display_name: string; + role: UserRole; + active: boolean; +}; + +declare global { + namespace Express { + interface Request { + user?: AuthUser; + authSessionHash?: string; + } + } +} + +export async function hashPassword(password: string) { + const salt = crypto.randomBytes(16).toString("base64url"); + const key = (await scrypt(password, salt, 64)) as Buffer; + return `scrypt$${salt}$${key.toString("base64url")}`; +} + +export async function verifyPassword(password: string, storedHash: string) { + const [algorithm, salt, expected] = storedHash.split("$"); + + if (algorithm !== "scrypt" || !salt || !expected) { + return false; + } + + const expectedBuffer = Buffer.from(expected, "base64url"); + const actualBuffer = (await scrypt(password, salt, expectedBuffer.length)) as Buffer; + + return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer); +} + +export function hashToken(token: string) { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +function readCookie(req: Request, name: string) { + const rawCookie = req.headers.cookie; + + if (!rawCookie) { + return null; + } + + const cookies = rawCookie.split(";").map((part) => part.trim()); + const prefix = `${name}=`; + const match = cookies.find((cookie) => cookie.startsWith(prefix)); + + if (!match) { + return null; + } + + return decodeURIComponent(match.slice(prefix.length)); +} + +function publicUser(user: AuthUser) { + return { + id: user.id, + username: user.username, + display_name: user.display_name, + role: user.role, + active: user.active + }; +} + +export async function createAuthSession(res: Response, userId: string) { + const token = crypto.randomBytes(32).toString("base64url"); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + sessionMaxAgeMs); + + await query( + ` + INSERT INTO auth_sessions (token_hash, user_id, expires_at) + VALUES ($1, $2, $3); + `, + [tokenHash, userId, expiresAt.toISOString()] + ); + + res.cookie(sessionCookieName, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.COOKIE_SECURE === "true", + maxAge: sessionMaxAgeMs, + path: "/" + }); +} + +export async function clearAuthSession(req: Request, res: Response) { + if (req.authSessionHash) { + await query("DELETE FROM auth_sessions WHERE token_hash = $1;", [req.authSessionHash]); + } + + res.clearCookie(sessionCookieName, { + sameSite: "lax", + secure: process.env.COOKIE_SECURE === "true", + path: "/" + }); +} + +export async function requireAuth(req: Request, res: Response, next: NextFunction) { + try { + const token = readCookie(req, sessionCookieName); + + if (!token) { + res.status(401).json({ error: "Nicht angemeldet" }); + return; + } + + const tokenHash = hashToken(token); + const result = await query( + ` + SELECT u.id, u.username, u.display_name, u.role, u.active + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = $1 + AND s.expires_at > now() + AND u.active = true; + `, + [tokenHash] + ); + + if (result.rows.length === 0) { + res.status(401).json({ error: "Sitzung ist abgelaufen" }); + return; + } + + req.user = publicUser(result.rows[0]); + req.authSessionHash = tokenHash; + next(); + } catch (error) { + next(error); + } +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction) { + if (req.user?.role !== "admin") { + res.status(403).json({ error: "Adminrechte erforderlich" }); + return; + } + + next(); +} + +export function currentUser(req: Request) { + if (!req.user) { + throw Object.assign(new Error("Nicht angemeldet"), { status: 401 }); + } + + return req.user; +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..0680b86 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,35 @@ +import pg from "pg"; + +const { Pool } = pg; + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error("DATABASE_URL is required"); +} + +export const pool = new Pool({ + connectionString, + max: Number(process.env.DB_POOL_SIZE ?? 10), + idleTimeoutMillis: 30_000 +}); + +export async function query(text: string, params: unknown[] = []) { + return pool.query(text, params); +} + +export async function withTransaction(callback: (client: pg.PoolClient) => Promise) { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const result = await callback(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b7b9e19 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,2843 @@ +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, verifyPassword, type UserRole } 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 parseUserRole(value: unknown) { + if (value !== "admin" && value !== "user") { + throw badRequest("role must be admin or user"); + } + + return value; +} + +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 = { + 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 = { + "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( + ` + 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( + ` + 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(); + + 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 }, 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 }, + 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 }, + 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), 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, + 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(*)::int AS session_count, + COALESCE(SUM(s.rounded_minutes), 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 + ); + `, + [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, + openSessions: openSessions.length + }, + tickets, + openSessions, + activitySeries: activityResult.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 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: UserRole; + 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 role = parseUserRole(req.body.role ?? "user"); + 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), role, 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 role = parseUserRole(req.body.role); + 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"); + } + + if (currentUser(req).id === String(userId) && (!active || role !== "admin")) { + res.status(409).json({ error: "Du kannst deinen eigenen Admin-Zugang nicht deaktivieren oder herabstufen" }); + return; + } + + try { + const result = await query( + ` + UPDATE users + SET username = $1, + display_name = $2, + role = $3, + active = $4, + password_hash = COALESCE($5, password_hash), + updated_at = now() + WHERE id = $6 + RETURNING id, username, display_name, role, active, created_at, updated_at; + `, + [username, displayName, role, 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( + ` + 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; + 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 => 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 + }); +}); + +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; + 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) { + 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", async (req, res) => { + res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) }); +}); + +app.post("/api/recurring-billings", 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", 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; + 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", 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) => { + const result = await query( + ` + SELECT + s.id, + s.ticket_id, + s.user_id, + owner.username AS owner_username, + owner.display_name AS owner_display_name, + 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.ended_at, + s.duration_seconds, + s.rounded_minutes, + s.billing_status, + s.created_at, + s.recurring_billing_id, + s.recurring_billing_slot_id, + s.recurring_occurrence_date + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + JOIN users owner ON owner.id = s.user_id + LEFT JOIN organizations so ON so.id = s.organization_id + ORDER BY s.started_at DESC + LIMIT 500; + ` + ); + + res.json({ sessions: result.rows }); +}); + +app.post("/api/admin/sessions", requireAdmin, async (req, res) => { + const userId = parsePositiveInteger(req.body.userId, "userId"); + const ticketNumber = parseTicketNumber(req.body.ticketNumber); + 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 created = await withTransaction(async (client) => { + const userResult = await client.query<{ id: string }>( + ` + SELECT id + FROM users + WHERE id = $1 + AND active = true; + `, + [userId] + ); + + if (userResult.rows.length === 0) { + return null; + } + + const organization = await getOrganizationById(client, organizationId); + + if (!organization) { + return "organization-not-found" as const; + } + + 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, workType] + ); + + const sessionResult = await client.query<{ id: string }>( + ` + 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 id; + `, + [ + ticketResult.rows[0].id, + organization.id, + organization.name, + activity, + workType, + userId, + startedAt.toISOString(), + endedAt.toISOString(), + durationSeconds, + roundedMinutes + ] + ); + + await reopenPeriodsForSession(client, ticketResult.rows[0].id, startedAt, String(userId)); + + return sessionResult.rows[0].id; + }); + + if (created === "organization-not-found") { + res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" }); + return; + } + + if (!created) { + res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" }); + return; + } + + const result = await query( + ` + SELECT + s.id, + s.ticket_id, + s.user_id, + owner.username AS owner_username, + owner.display_name AS owner_display_name, + 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.ended_at, + s.duration_seconds, + s.rounded_minutes, + s.billing_status, + s.created_at, + s.recurring_billing_id, + s.recurring_billing_slot_id, + s.recurring_occurrence_date + FROM sessions s + JOIN tickets t ON t.id = s.ticket_id + JOIN users owner ON owner.id = s.user_id + LEFT JOIN organizations so ON so.id = s.organization_id + WHERE s.id = $1; + `, + [created] + ); + + res.status(201).json({ session: result.rows[0] }); +}); + +app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (req, res) => { + const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); + const nextUserId = parsePositiveInteger(req.body.userId, "userId"); + + const reassigned = await withTransaction(async (client) => { + const targetResult = await client.query<{ id: string }>( + ` + SELECT id + FROM users + WHERE id = $1 + AND active = true; + `, + [nextUserId] + ); + + if (targetResult.rows.length === 0) { + return { status: "target-not-found" as const }; + } + + const currentResult = await client.query<{ + id: string; + ticket_id: string; + user_id: string; + started_at: string; + }>( + ` + SELECT id, ticket_id, user_id, started_at + FROM sessions + WHERE id = $1 + FOR UPDATE; + `, + [sessionId] + ); + + if (currentResult.rows.length === 0) { + return { status: "session-not-found" as const }; + } + + const session = currentResult.rows[0]; + + if (session.user_id === String(nextUserId)) { + return { status: "ok" as const, session }; + } + + await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), session.user_id); + await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), String(nextUserId)); + + const updatedResult = await client.query( + ` + UPDATE sessions + SET user_id = $1 + WHERE id = $2 + RETURNING *; + `, + [nextUserId, sessionId] + ); + + return { status: "ok" as const, session: updatedResult.rows[0] }; + }); + + if (reassigned.status === "target-not-found") { + res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" }); + return; + } + + if (reassigned.status === "session-not-found") { + res.status(404).json({ error: "Session not found" }); + return; + } + + res.json({ session: reassigned.session }); +}); + +app.post("/api/sessions", 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", async (req, res) => { + const { config, period } = parsePeriod(req.params.periodType, req.params.period); + res.json(await getPeriodOverview(config, period, currentUser(req).id)); +}); + +app.get("/api/tickets/lookup", 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", 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", async (req, res) => { + const { config, period } = parsePeriod(req.params.periodType, 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", async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const day = parseDay(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] + ); + + res.json({ dayBilling: result.rows[0] }); +}); + +app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => { + parsePeriod(req.params.periodType, 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", async (req, res) => { + parsePeriod(req.params.periodType, 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", async (req, res) => { + const { config, period } = parsePeriod(req.params.periodType, 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", async (req, res) => { + const { config, period } = parsePeriod(req.params.periodType, 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", async (req, res) => { + const month = parseMonth(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", async (req, res) => { + const month = parseMonth(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", 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", 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", 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", async (req, res) => { + const month = parseMonth(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", async (req, res) => { + const month = parseMonth(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", async (req, res) => { + const month = parseMonth(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", async (req, res) => { + const month = parseMonth(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); +}); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts new file mode 100644 index 0000000..2cba532 --- /dev/null +++ b/backend/src/migrations.ts @@ -0,0 +1,398 @@ +import { query } from "./db.js"; +import { hashPassword } from "./auth.js"; + +async function ensureMigrationAdmin() { + const username = process.env.ADMIN_USERNAME?.trim() || "admin"; + const password = process.env.ADMIN_PASSWORD ?? "admin"; + const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin"; + + const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;"); + + if (Number(existingUsers.rows[0]?.count ?? 0) === 0) { + await query( + ` + INSERT INTO users (username, display_name, password_hash, role) + VALUES ($1, $2, $3, 'admin'); + `, + [username, displayName, await hashPassword(password)] + ); + + if (!process.env.ADMIN_PASSWORD) { + console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern."); + } + } + + const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;"); + + if (adminResult.rows.length === 0) { + throw new Error("At least one admin user is required"); + } + + return adminResult.rows[0].id; +} + +async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) { + await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); + await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); + await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`); + await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`); + await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`); + await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`); +} + +export async function migrate() { + await query(` + CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT users_role_valid CHECK (role IN ('admin', 'user')), + CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s') + ); + `); + + await query(` + ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_username_format, + ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s'); + `); + + await query(` + CREATE TABLE IF NOT EXISTS auth_sessions ( + token_hash TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);"); + await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);"); + + const adminId = await ensureMigrationAdmin(); + + await query(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS organizations ( + id BIGSERIAL PRIMARY KEY, + zammad_id BIGINT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS active;"); + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS shared;"); + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS vip;"); + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;"); + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;"); + await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;"); + await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));"); + await query("DROP INDEX IF EXISTS idx_organizations_active_name;"); + + await query(` + CREATE TABLE IF NOT EXISTS tickets ( + id BIGSERIAL PRIMARY KEY, + ticket_number TEXT NOT NULL UNIQUE, + organization_id BIGINT REFERENCES organizations(id), + customer_name TEXT, + work_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'), + CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')) + ); + `); + + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);"); + await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;"); + await query(` + ALTER TABLE tickets + DROP CONSTRAINT IF EXISTS ticket_number_format, + ADD CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^(Ticket#[0-9]{6}|Fix#[0-9]+)$'); + `); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ticket_work_type_valid' + ) THEN + ALTER TABLE tickets + ADD CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')); + END IF; + END $$; + `); + + await query(` + CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + organization_id BIGINT REFERENCES organizations(id), + customer_name TEXT NOT NULL, + activity TEXT NOT NULL, + work_type TEXT NOT NULL, + user_id BIGINT REFERENCES users(id), + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ NOT NULL, + duration_seconds INTEGER NOT NULL, + rounded_minutes INTEGER NOT NULL, + billing_status TEXT, + billing_updated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')), + CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billed', 'non_billable')), + CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0), + CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1), + CONSTRAINT ended_after_started CHECK (ended_at >= started_at) + ); + `); + + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); + await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); + await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); + await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;"); + + await query(` + CREATE TABLE IF NOT EXISTS recurring_billings ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ticket_number TEXT, + organization_id BIGINT NOT NULL REFERENCES organizations(id), + activity TEXT NOT NULL, + work_type TEXT NOT NULL, + recurrence_type TEXT NOT NULL, + interval_value INTEGER NOT NULL DEFAULT 1, + valid_from DATE NOT NULL, + valid_until DATE, + start_time TIME NOT NULL DEFAULT '09:00', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT recurring_billings_work_type_valid CHECK (work_type IN ('support', 'consulting')), + CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')), + CONSTRAINT recurring_billings_interval_positive CHECK (interval_value >= 1), + CONSTRAINT recurring_billings_valid_range CHECK (valid_until IS NULL OR valid_until >= valid_from) + ); + `); + + await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;"); + await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;"); + await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';"); + await query(` + ALTER TABLE recurring_billings + DROP CONSTRAINT IF EXISTS recurring_billings_type_valid, + ADD CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')); + `); + + await query(` + CREATE TABLE IF NOT EXISTS recurring_billing_slots ( + id BIGSERIAL PRIMARY KEY, + recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE, + weekday INTEGER, + start_time TIME, + duration_minutes INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT recurring_billing_slots_weekday_valid CHECK (weekday IS NULL OR weekday BETWEEN 0 AND 6), + CONSTRAINT recurring_billing_slots_duration_positive CHECK (duration_minutes >= 1) + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS recurring_billing_exceptions ( + id BIGSERIAL PRIMARY KEY, + recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE, + recurring_billing_slot_id BIGINT REFERENCES recurring_billing_slots(id) ON DELETE CASCADE, + occurrence_date DATE NOT NULL, + action TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT recurring_billing_exceptions_action_valid CHECK (action IN ('cancelled')), + UNIQUE (recurring_billing_id, recurring_billing_slot_id, occurrence_date) + ); + `); + + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_fkey' + ) THEN + ALTER TABLE sessions + ADD CONSTRAINT sessions_recurring_billing_fkey + FOREIGN KEY (recurring_billing_id) REFERENCES recurring_billings(id) ON DELETE SET NULL; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_slot_fkey' + ) THEN + ALTER TABLE sessions + ADD CONSTRAINT sessions_recurring_billing_slot_fkey + FOREIGN KEY (recurring_billing_slot_id) REFERENCES recurring_billing_slots(id) ON DELETE SET NULL; + END IF; + END $$; + `); + + await query(` + CREATE TABLE IF NOT EXISTS ticket_month_closures ( + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id), + month DATE NOT NULL, + closed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS month_closures ( + user_id BIGINT REFERENCES users(id), + month DATE NOT NULL, + closed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS ticket_day_closures ( + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id), + day DATE NOT NULL, + closed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS day_closures ( + user_id BIGINT REFERENCES users(id), + day DATE NOT NULL, + closed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); + + await query(` + CREATE TABLE IF NOT EXISTS ticket_day_billings ( + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day DATE NOT NULL, + billed_minutes INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (ticket_id, user_id, day), + CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) + ); + `); + + await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId); + await ensureClosureOwnership("month_closures", "month", ["month"], adminId); + await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId); + await ensureClosureOwnership("day_closures", "day", ["day"], adminId); + + await query("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_ticket_started ON sessions(ticket_id, started_at);"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);"); + await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;"); + await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); + await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); + await query("UPDATE recurring_billings SET active = true WHERE active = false;"); + await query(` + WITH deleted_orphan_sessions AS ( + DELETE FROM sessions s + WHERE s.recurring_occurrence_key IS NOT NULL + AND split_part(s.recurring_occurrence_key, ':', 1) ~ '^[0-9]+$' + AND NOT EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.id = split_part(s.recurring_occurrence_key, ':', 1)::bigint + ) + RETURNING + ticket_id, + user_id, + (started_at AT TIME ZONE 'UTC')::date AS day, + date_trunc('month', started_at AT TIME ZONE 'UTC')::date AS month + ), + deleted_ticket_month_closures AS ( + DELETE FROM ticket_month_closures tmc + USING deleted_orphan_sessions dos + WHERE tmc.ticket_id = dos.ticket_id + AND tmc.user_id = dos.user_id + AND tmc.month = dos.month + RETURNING tmc.ticket_id + ), + deleted_month_closures AS ( + DELETE FROM month_closures mc + USING deleted_orphan_sessions dos + WHERE mc.user_id = dos.user_id + AND mc.month = dos.month + RETURNING mc.user_id + ), + deleted_ticket_day_closures AS ( + DELETE FROM ticket_day_closures tdc + USING deleted_orphan_sessions dos + WHERE tdc.ticket_id = dos.ticket_id + AND tdc.user_id = dos.user_id + AND tdc.day = dos.day + RETURNING tdc.ticket_id + ), + deleted_day_closures AS ( + DELETE FROM day_closures dc + USING deleted_orphan_sessions dos + WHERE dc.user_id = dos.user_id + AND dc.day = dos.day + RETURNING dc.user_id + ) + DELETE FROM tickets t + WHERE t.id IN (SELECT ticket_id FROM deleted_orphan_sessions) + AND NOT EXISTS ( + SELECT 1 + FROM sessions s + WHERE s.ticket_id = t.id + ); + `); + await query(` + DELETE FROM tickets t + WHERE t.ticket_number ~ '^Fix#[0-9]+$' + AND NOT EXISTS ( + SELECT 1 + FROM sessions s + WHERE s.ticket_id = t.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM recurring_billings rb + WHERE rb.id = substring(t.ticket_number FROM 5)::bigint + ); + `); + + await query(` + UPDATE tickets t + SET customer_name = latest.customer_name, + work_type = latest.work_type + FROM ( + SELECT DISTINCT ON (ticket_id) + ticket_id, + customer_name, + work_type + FROM sessions + ORDER BY ticket_id, started_at DESC + ) latest + WHERE t.id = latest.ticket_id + AND (t.customer_name IS NULL OR t.work_type IS NULL); + `); +} diff --git a/backend/src/validation.ts b/backend/src/validation.ts new file mode 100644 index 0000000..8684fc2 --- /dev/null +++ b/backend/src/validation.ts @@ -0,0 +1,135 @@ +export const ticketPattern = /^Ticket#\d{6}$/; +export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/; +export const monthPattern = /^\d{4}-\d{2}$/; +export const dayPattern = /^\d{4}-\d{2}-\d{2}$/; +export const usernamePattern = /^\S{3,40}$/u; + +export function requireString(value: unknown, field: string) { + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${field} is required`); + } + + return value.trim(); +} + +export function parseTicketNumber(value: unknown) { + const ticketNumber = requireString(value, "ticketNumber"); + + if (!ticketPattern.test(ticketNumber)) { + throw badRequest("ticketNumber must match Ticket#XXXXXX"); + } + + return ticketNumber; +} + +export function parseTrackableTicketNumber(value: unknown) { + const ticketNumber = requireString(value, "ticketNumber"); + + if (!trackableTicketPattern.test(ticketNumber)) { + throw badRequest("ticketNumber must match Ticket#XXXXXX or Fix#ID"); + } + + return ticketNumber; +} + +export function parseUsername(value: unknown) { + const username = requireString(value, "username"); + + if (!usernamePattern.test(username)) { + throw badRequest("username must be 3-40 characters without spaces"); + } + + return username; +} + +export function parseMonth(value: string) { + if (!monthPattern.test(value)) { + throw badRequest("month must use YYYY-MM"); + } + + const monthStart = `${value}-01`; + const date = new Date(`${monthStart}T00:00:00.000Z`); + + if (Number.isNaN(date.getTime()) || date.getUTCMonth() + 1 !== Number(value.slice(5, 7))) { + throw badRequest("month is invalid"); + } + + const next = new Date(date); + next.setUTCMonth(next.getUTCMonth() + 1); + + return { + label: value, + start: monthStart, + startIso: date.toISOString(), + endIso: next.toISOString() + }; +} + +export function parseDay(value: string) { + if (!dayPattern.test(value)) { + throw badRequest("day must use YYYY-MM-DD"); + } + + const date = new Date(`${value}T00:00:00.000Z`); + + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { + throw badRequest("day is invalid"); + } + + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + 1); + + return { + label: value, + start: value, + startIso: date.toISOString(), + endIso: next.toISOString() + }; +} + +export function parsePositiveInteger(value: unknown, field: string) { + const number = Number(value); + + if (!Number.isInteger(number) || number < 0) { + throw badRequest(`${field} must be a positive integer`); + } + + return number; +} + +export function parseWorkType(value: unknown) { + if (value !== "support" && value !== "consulting") { + throw badRequest("workType must be support or consulting"); + } + + return value; +} + +export function parseBillingStatus(value: unknown) { + if (value === null) { + return null; + } + + if (value !== "billed" && value !== "non_billable") { + throw badRequest("billingStatus must be billed, non_billable or null"); + } + + return value; +} + +export function parseIsoDate(value: unknown, field: string) { + const raw = requireString(value, field); + const date = new Date(raw); + + if (Number.isNaN(date.getTime())) { + throw badRequest(`${field} must be an ISO date`); + } + + return date; +} + +export function badRequest(message: string) { + const error = new Error(message) as Error & { status?: number }; + error.status = 400; + return error; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..d6deda8 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/compose.postgres.yml b/compose.postgres.yml new file mode 100644 index 0000000..55ba6a0 --- /dev/null +++ b/compose.postgres.yml @@ -0,0 +1,44 @@ +services: + postgres: + image: postgres:17-alpine + container_name: tickettracker-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-tickettracker} + POSTGRES_USER: ${POSTGRES_USER:-tickettracker} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein} + volumes: + - tickettracker_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 10 + + tickettracker: + image: ${TICKETTRACKER_IMAGE:-git.d-razz.de/michael/tickettracker:latest} + build: + context: . + container_name: tickettracker + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker} + PORT: 3000 + BACKEND_PORT: 3001 + API_PROXY_TARGET: http://127.0.0.1:3001 + SERVE_FRONTEND: "false" + NEXT_TELEMETRY_DISABLED: "1" + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin} + SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + DB_POOL_SIZE: ${DB_POOL_SIZE:-10} + ports: + - "${APP_PORT:-3910}:3000" + +volumes: + tickettracker_postgres_data: diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a4ab895 --- /dev/null +++ b/compose.yml @@ -0,0 +1,22 @@ +services: + tickettracker: + image: ${TICKETTRACKER_IMAGE:-git.d-razz.de/michael/tickettracker:latest} + build: + context: . + container_name: tickettracker + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + PORT: 3000 + BACKEND_PORT: 3001 + API_PROXY_TARGET: http://127.0.0.1:3001 + SERVE_FRONTEND: "false" + NEXT_TELEMETRY_DISABLED: "1" + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin} + SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + DB_POOL_SIZE: ${DB_POOL_SIZE:-10} + ports: + - "${APP_PORT:-3910}:3000" diff --git a/docs/docker-release.md b/docs/docker-release.md new file mode 100644 index 0000000..496fe0c --- /dev/null +++ b/docs/docker-release.md @@ -0,0 +1,132 @@ +# Docker Release + +TicketTracker kann mit einer externen PostgreSQL-17-Datenbank oder mit PostgreSQL 17 im gleichen Docker-Stack laufen. + +## Variante A: Externe PostgreSQL-Datenbank + +1. Beispiel-Env kopieren: + +```bash +cp .env.docker-external.example .env.docker-external +``` + +2. `.env.docker-external` anpassen: + +```bash +DATABASE_URL=postgresql://tickettracker:PASSWORT@10.10.10.23:5432/tickettracker +ADMIN_PASSWORD=ein-sicheres-passwort +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +APP_PORT=3910 +``` + +3. Stack starten: + +```bash +docker compose --env-file .env.docker-external -f compose.yml up -d +``` + +## Variante B: PostgreSQL 17 im Stack + +1. Beispiel-Env kopieren: + +```bash +cp .env.docker-postgres.example .env.docker-postgres +``` + +2. `.env.docker-postgres` anpassen: + +```bash +POSTGRES_PASSWORD=ein-sicheres-db-passwort +ADMIN_PASSWORD=ein-sicheres-admin-passwort +TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest +APP_PORT=3910 +``` + +3. Stack starten: + +```bash +docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d +``` + +Die Datenbankdaten liegen im Docker-Volume `tickettracker_postgres_data`. + +## Image bauen und in Gitea pushen + +Einmal am Docker/Gitea-Registry anmelden: + +```bash +docker login git.d-razz.de +``` + +Dann Image bauen und pushen: + +```bash +chmod +x scripts/build-and-push-gitea.sh +./scripts/build-and-push-gitea.sh +``` + +Standard-Image: + +```text +git.d-razz.de/michael/tickettracker:latest +``` + +## Gitea Repository erstellen + +Da aktuell noch kein Repository auf `git.d-razz.de` existiert: + +1. In Gitea als `michael` anmelden. +2. Oben rechts `+` -> `Neues Repository`. +3. Repository-Name: `tickettracker`. +4. Repository leer erstellen, also ohne README, `.gitignore` oder Lizenz. +5. SSH-Key in Gitea hinterlegen, falls noch nicht vorhanden. + +Danach lokal: + +```bash +chmod +x scripts/init-gitea-repo.sh +./scripts/init-gitea-repo.sh +git branch -M main +git push -u origin main +``` + +Falls du HTTPS statt SSH verwenden willst: + +```bash +REMOTE_URL=https://git.d-razz.de/michael/tickettracker.git ./scripts/init-gitea-repo.sh +git branch -M main +git push -u origin main +``` + +## Docker-Server / Portainer Stack + +Wenn der Docker-Server direkt aus Git deployen soll: + +1. In Portainer oder Docker-Management `Stack aus Git` wählen. +2. Repository URL eintragen: + +```text +git@git.d-razz.de:michael/tickettracker.git +``` + +oder per HTTPS: + +```text +https://git.d-razz.de/michael/tickettracker.git +``` + +3. Compose-Datei wählen: + +```text +compose.yml +``` + +für externe DB, oder: + +```text +compose.postgres.yml +``` + +für PostgreSQL 17 im Stack. + +4. Environment-Variablen aus `.env.docker-external.example` oder `.env.docker-postgres.example` im Stack hinterlegen. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..7e48b1a --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,271 @@ +# TicketTracker Benutzeranleitung + +Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, CRM-Zeiten eintragen, fixe Abrechnungen verwalten und Monatsauswertungen abschliessen. + +## Grundprinzip + +TicketTracker ersetzt nicht das Ticketsystem oder CRM. Die App dient dazu, Arbeitszeiten sauber zu messen, auszuwerten und fuer die spaetere Abrechnung vorzubereiten. + +Es gibt drei Zeitebenen: + +- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit. +- Tages-CRM-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM eingetragen wurde. +- Monatsabschluss: Ein Ticket wird fuer einen Monat geschlossen, wenn alle Sessions dieses Tickets im Monat bewertet sind. + +## Anmeldung + +1. App im Browser oeffnen. +2. Mit Benutzername und Passwort anmelden. +3. Nach der Anmeldung ist die Seitenleiste sichtbar. + +Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Administratoren sehen zusaetzlich den Adminbereich. + +## Timer + +Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht. + +1. Ticketnummer im Format `Ticket#XXXXXX` eingeben. +2. `Session beginnen` klicken. +3. Der Timer startet. +4. Bei Bedarf kann der Timer pausiert, fortgesetzt, beendet oder zurueckgesetzt werden. + +Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer gestartet oder fortgesetzt, werden alle anderen laufenden Timer automatisch pausiert. + +Beim Klick auf `Beenden` stoppt der Timer sofort. Danach wird die Session fertig gespeichert: + +- Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen. +- Dann muss nur noch die Taetigkeit eingetragen werden. +- Existiert das Ticket noch nicht, muessen Organisation, Taetigkeit und Art ausgefuellt werden. + +Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen. + +## Session Nachtragen + +Sessions koennen auch manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde. + +Normale Benutzer koennen eigene Sessions nachtragen. Administratoren koennen Sessions fuer andere Benutzer nachtragen. + +Beim Nachtragen werden angegeben: + +- Ticketnummer +- Organisation +- Art: Support oder Consulting +- Datum +- Von- und Bis-Uhrzeit +- Taetigkeit + +Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten. + +## Ticketdaten Bearbeiten + +In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden: + +- Ticketnummer +- Organisation +- Art + +Aenderungen an Organisation und Art werden auf vorhandene manuelle Sessions des Tickets uebernommen. Bei wiederkehrend erzeugten Sessions bleibt die Art aus der Regel erhalten und kann nur ueber die fixe Abrechnung geaendert werden. + +## Organisationen + +Organisationen werden aus Zammad synchronisiert. Danach koennen Organisationen in TicketTracker nur noch aus der Liste ausgewaehlt werden, nicht mehr frei als Text eingetragen werden. + +Bereits vorhandene alte Freitexte muessen bei Bedarf manuell korrigiert werden, indem das Ticket oder die Session bearbeitet und eine Organisation aus der Liste ausgewaehlt wird. + +## Auswertung + +Die Seite `Auswertung` hat zwei Ansichten: + +- Monat +- Tag + +Mit den Pfeilen neben dem Datumsfeld kann ein Monat oder Tag vor- und zurueckgeschaltet werden. Die Daten aktualisieren sich automatisch. + +Die oberen Karten zeigen: + +- Tickets: Anzahl der Tickets im Zeitraum +- Sessions: Anzahl der Sessions im Zeitraum +- Zeit: Summe der erfassten Session-Zeit, plus CRM-Zeit +- Offen: Anzahl unbewerteter Sessions + +Der Graph `Gesamtaufwand` zeigt den Aufwand im Zeitraum: + +- In der Monatsansicht pro Tag +- In der Tagesansicht pro Stunde + +## Tickets Im Zeitraum + +Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben. + +Pro Ticket sind sichtbar: + +- Ticketnummer +- Organisation +- Art +- Anzahl Sessions +- Zeit +- Anzahl abgerechneter Sessions +- Anzahl nicht abrechenbarer Sessions +- Status + +Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage. + +Der Status bedeutet: + +- `offen`: Es gibt noch unbewertete Sessions. +- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet. + +## Ticketdetailansicht + +Ein Ticket wird aus der Auswertung heraus geoeffnet. + +In der Ticketdetailansicht sieht man: + +- Ticketnummer mit Copy-Icon +- Abschlussstatus +- Ticketdaten +- Session-Eintraege nach Tagen gruppiert +- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen + +Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen: + +- Tagesdatum +- `Tag gesamt`: Summe der erfassten Session-Zeit dieses Tages +- `CRM`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden + +Das CRM-Feld wird in Stunden eingetragen, zum Beispiel: + +- `1` +- `1,5` +- `2.25` + +Ein leeres CRM-Feld entfernt den gespeicherten CRM-Wert fuer diesen Ticket-Tag. Der Wert ist derselbe in Monats- und Tagesansicht. + +## Sessions Bewerten + +Jede Session kann bewertet werden als: + +- Abgerechnet +- Nicht abrechenbar + +Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen. + +Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. + +Wenn der Monat bereits abgeschlossen ist, muss er zuerst wieder geoeffnet werden, bevor Bewertungen geaendert werden. + +## Sessions Bearbeiten + +Eine Session kann ueber das Stift-Icon bearbeitet werden. + +Bearbeitbar sind: + +- Datum +- Von-Uhrzeit +- Bis-Uhrzeit +- Organisation +- Taetigkeit +- Art, sofern es keine wiederkehrende Session ist + +Bei wiederkehrenden Sessions ist die Art gesperrt. Sie kommt aus der Regel der fixen Abrechnung. + +## Sessions Loeschen + +Eine Session kann ueber das Papierkorb-Icon geloescht werden. + +Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Auswertung. + +Bei wiederkehrenden Sessions wird das Loeschen als Ausnahme gespeichert. Dadurch wird die Session fuer diesen Tag nicht automatisch wieder neu erzeugt. + +## Zeitraum Abschliessen + +Ein Monat kann abgeschlossen werden, wenn: + +- Alle Sessions im Monat bewertet sind. + +Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und CRM-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat. + +## Fixe Abrechnungen + +Fixe Abrechnungen werden von jedem Benutzer selbst verwaltet. + +Sie dienen fuer wiederkehrende Leistungen, zum Beispiel: + +- Jeden Donnerstag 4 Stunden +- Jeden Dienstag, Mittwoch und Freitag unterschiedliche Zeiten +- Alle 2 Wochen an einem bestimmten Wochentag + +Eine fixe Abrechnung enthaelt: + +- Optional eine Ticketnummer +- Organisation +- Art +- Taetigkeit +- Gueltig von +- Optional gueltig bis +- Muster +- Slots mit Wochentag, Startzeit und Dauer + +Wenn keine Ticketnummer hinterlegt ist, wird automatisch `Fix#ID` verwendet. + +Bayerische Feiertage werden bei der automatischen Erzeugung beruecksichtigt. Fuer Feiertage werden keine Sessions erzeugt. + +Fixe Abrechnungen koennen bearbeitet werden. Wenn Gueltigkeiten oder Slots geaendert werden, werden nicht mehr passende erzeugte Sessions entfernt oder angepasst. + +Fix erzeugte einzelne Sessions koennen geloescht werden, zum Beispiel wegen Urlaub oder Krankheit. Diese Loeschung wird als Ausnahme gespeichert, damit die Session nicht beim naechsten Aufruf wieder automatisch erscheint. + +## Profil + +Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen: + +- Benutzername +- Voller Name +- Passwort + +Passwortaenderungen erfordern das aktuelle Passwort. + +## Adminbereich + +Administratoren koennen: + +- Benutzer anlegen +- Benutzer bearbeiten +- Passwoerter fuer Benutzer setzen +- Sessions aller Benutzer sehen +- Sessions anderen Benutzern zuweisen +- Sessions fuer Benutzer nachtragen +- Zammad-Organisationen synchronisieren + +Bei der Auswertung normaler Benutzer werden nur die Sessions des angemeldeten Benutzers beruecksichtigt. Ticketdaten koennen trotzdem von Sessions anderer Benutzer uebernommen werden, wenn dasselbe Ticket schon existiert. + +## Zammad Sync + +Der Zammad-Sync ist nur fuer Administratoren sichtbar. + +Beim Sync werden Organisationen aus Zammad in die lokale TicketTracker-Datenbank geschrieben. + +Gespeichert werden: + +- interne ID +- Zammad-ID +- Name +- Zeitstempel + +Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht mehr vorhandene Organisationen entfernt beziehungsweise von bestehenden Tickets geloest. + +## Typischer Monatsablauf + +1. Im Alltag Sessions per Timer oder Nachtragen erfassen. +2. Am Monatsende `Auswertung` oeffnen. +3. Monat auswaehlen. +4. Offene Sessions pruefen. +5. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren. +6. Pro Tag und Ticket die wirklich ins CRM eingetragene Zeit im Feld `CRM` pflegen. +7. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen. + +## Hinweise + +- Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden. +- Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag. +- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann Abschluesse wieder oeffnen. +- CRM-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..2a42785 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..14bf402 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,25 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactCompiler: true, + turbopack: { + root: workspaceRoot, + }, + compiler: { + removeConsole: process.env.NODE_ENV === "production", + }, + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.API_PROXY_TARGET ?? "http://127.0.0.1:3001"}/api/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e7b3c04 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "@tickettracker/frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "NEXT_TELEMETRY_DISABLED=1 next dev -H 0.0.0.0", + "build": "NEXT_TELEMETRY_DISABLED=1 next build", + "start": "NEXT_TELEMETRY_DISABLED=1 next start -H 0.0.0.0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@shadcn/react": "^0.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "embla-carousel-react": "^8.6.0", + "geist": "^1.7.2", + "input-otp": "^1.4.2", + "lucide-react": "^1.28.0", + "next": "^16.2.12", + "next-themes": "^0.4.6", + "postcss": "^8.5.25", + "radix-ui": "^1.6.7", + "react": "^19.2.8", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.8", + "react-resizable-panels": "^4.12.2", + "recharts": "^3.8.0", + "shadcn": "^4.16.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.1.5", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^22.20.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "^5.9.3" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..79bcf13 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7eab626 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,708 @@ +import { + BarChart3, + CheckCircle2, + Command, + LayoutGrid, + LogOut, + Moon, + Pause, + Play, + Plus, + Repeat, + Search, + Shield, + Sun, + Timer, + UserCog, + UserCircle, +} from "lucide-react"; +import { FormEvent, MouseEvent, useEffect, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; + +import { cn } from "./lib/utils"; +import { ApiError, getCurrentUser, logout, lookupTicket } from "./api"; +import { formatTimer } from "./format"; +import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; +import { AdminUsersPage } from "./views/AdminUsersPage"; +import { AnalysisPage } from "./views/AnalysisPage"; +import { LoginPage } from "./views/LoginPage"; +import { ProfilePage } from "./views/ProfilePage"; +import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; +import { TicketDetailPage } from "./views/TicketDetailPage"; +import { TimerPage } from "./views/TimerPage"; +import type { AuthUser, PeriodType } from "./types"; +import type { Dispatch, SetStateAction } from "react"; + +function routeFromPath(pathname: string) { + const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); + + if (periodTicketMatch) { + return { + page: "ticket" as const, + periodType: periodTicketMatch[1] as PeriodType, + period: periodTicketMatch[2], + ticketId: periodTicketMatch[3], + }; + } + + const legacyTicketMatch = pathname.match(/^\/analysis\/(\d{4}-\d{2})\/tickets\/(\d+)$/); + + if (legacyTicketMatch) { + return { + page: "ticket" as const, + periodType: "month" as const, + period: legacyTicketMatch[1], + ticketId: legacyTicketMatch[2], + }; + } + + if (pathname.startsWith("/analysis")) { + return { page: "analysis" as const }; + } + + if (pathname.startsWith("/recurring")) { + return { page: "recurring" as const }; + } + + if (pathname.startsWith("/admin/users")) { + return { page: "admin-users" as const }; + } + + if (pathname.startsWith("/profile")) { + return { page: "profile" as const }; + } + + return { page: "timer" as const }; +} + +function useAppRouter() { + const [path, setPath] = useState("/timer"); + + useEffect(() => { + const update = () => setPath(window.location.pathname); + update(); + window.addEventListener("popstate", update); + return () => window.removeEventListener("popstate", update); + }, []); + + function navigate(to: string) { + window.history.pushState({}, "", to); + setPath(window.location.pathname); + } + + function navHandler(to: string) { + return (event: MouseEvent) => { + event.preventDefault(); + navigate(to); + }; + } + + return { + path, + route: useMemo(() => routeFromPath(path), [path]), + navigate, + navHandler, + }; +} + +function ThemeSwitcher() { + const { themeMode, setPreference } = usePreferencesStore( + useShallow((state) => ({ + themeMode: state.values.theme_mode, + setPreference: state.setPreference, + })), + ); + + function cycleTheme() { + setPreference("theme_mode", themeMode === "dark" ? "light" : "dark"); + } + + return ( + + ); +} + +type QuickTimerStarterProps = { + className?: string; + inputId: string; + onStartTimer: (ticketNumber: string) => Promise; +}; + +function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStarterProps) { + const [ticketNumber, setTicketNumber] = useState(""); + const trimmedTicketNumber = ticketNumber.trim(); + const hasInvalidTicket = trimmedTicketNumber.length > 0 && !ticketPattern.test(trimmedTicketNumber); + + async function submit(event: FormEvent) { + event.preventDefault(); + + if (await onStartTimer(trimmedTicketNumber)) { + setTicketNumber(""); + } + } + + return ( +
+ + setTicketNumber(event.currentTarget.value)} + /> + +
+ ); +} + +type SidebarTimersProps = { + timers: TimerEntry[]; + selectedTimerId: string | null; + setSelectedTimerId: Dispatch>; + setTimers: Dispatch>; + tick: number; + onOpenTimer: () => void; +}; + +function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) { + const sortedTimers = useMemo( + () => + [...timers].sort((first, second) => { + if (first.phase === second.phase) { + return first.ticketNumber.localeCompare(second.ticketNumber); + } + + return first.phase === "running" ? -1 : 1; + }), + [timers], + ); + const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; + + function toggleTimer(timerId: string) { + const timer = timers.find((entry) => entry.id === timerId); + + if (!timer) { + return; + } + + const now = Date.now(); + setSelectedTimerId(timerId); + setTimers((current) => + current.map((entry) => { + if (entry.id !== timerId) { + return pauseEntry(entry, now); + } + + return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now); + }), + ); + } + + return ( + + Timer + +
+
+ + {runningTimer ? "Aktuell aktiv" : timers.length > 0 ? "Bereit" : "Kein Timer"} + + {runningTimer ?
+ + {sortedTimers.length === 0 ? ( +

Noch keine Timer gestartet.

+ ) : ( +
+ {sortedTimers.map((timer) => { + const elapsed = Math.floor(activeElapsedMs(timer, tick) / 1000); + const isSelected = selectedTimerId === timer.id; + const isRunning = timer.phase === "running"; + + return ( +
+ + +
+ ); + })} +
+ )} +
+
+
+ ); +} + +export function App() { + const { path, route, navHandler, navigate } = useAppRouter(); + const [currentUser, setCurrentUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [timers, setTimers] = useState([]); + const [timersLoaded, setTimersLoaded] = useState(false); + const [timerOwnerId, setTimerOwnerId] = useState(null); + const [selectedTimerId, setSelectedTimerId] = useState(null); + const [tick, setTick] = useState(Date.now()); + + useEffect(() => { + async function loadUser() { + try { + const result = await getCurrentUser(); + setCurrentUser(result.user); + } catch (error) { + if (!(error instanceof ApiError && error.status === 401)) { + console.error(error); + } + setCurrentUser(null); + } finally { + setAuthLoading(false); + } + } + + void loadUser(); + }, []); + + useEffect(() => { + if (!currentUser) { + setTimers([]); + setSelectedTimerId(null); + setTimerOwnerId(null); + setTimersLoaded(false); + return; + } + + setTimers(readStoredTimers(currentUser.id)); + setTimerOwnerId(currentUser.id); + setTimersLoaded(true); + }, [currentUser]); + + useEffect(() => { + if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { + return; + } + + localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers)); + + if (timers.length === 0) { + setSelectedTimerId(null); + return; + } + + if (!selectedTimerId || !timers.some((timer) => timer.id === selectedTimerId)) { + setSelectedTimerId(timers[0].id); + } + }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); + + useEffect(() => { + if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { + return; + } + + const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone); + + if (timersToHydrate.length === 0) { + return; + } + + let cancelled = false; + + async function hydrateTimers() { + const results = await Promise.all( + timersToHydrate.map(async (timer) => { + try { + const result = await lookupTicket(timer.ticketNumber); + return { + id: timer.id, + organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, + workType: result.ticket?.work_type ?? null + }; + } catch { + return { + id: timer.id, + organizationName: null, + workType: null + }; + } + }) + ); + + if (cancelled) { + return; + } + + setTimers((current) => + current.map((timer) => { + const result = results.find((entry) => entry.id === timer.id); + + if (!result) { + return timer; + } + + return { + ...timer, + organizationName: result.organizationName, + workType: result.workType, + ticketLookupDone: true + }; + }) + ); + } + + void hydrateTimers(); + + return () => { + cancelled = true; + }; + }, [currentUser, timers, timersLoaded, timerOwnerId]); + + useEffect(() => { + const interval = window.setInterval(() => setTick(Date.now()), 500); + return () => window.clearInterval(interval); + }, []); + + async function startQuickTimer(rawTicketNumber: string) { + const ticketNumber = rawTicketNumber.trim(); + + if (!ticketPattern.test(ticketNumber)) { + toast.error("Ticketnummer prüfen", { + description: "Das Format muss Ticket#XXXXXX sein." + }); + return false; + } + + const existingTimer = timers.find((timer) => timer.ticketNumber === ticketNumber); + const now = Date.now(); + + if (existingTimer) { + setTimers((current) => + current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now))) + ); + setSelectedTimerId(existingTimer.id); + toast.info("Bestehender Timer aktiviert", { + description: ticketNumber + }); + return true; + } + + const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null })); + + const newTimer: TimerEntry = { + id: crypto.randomUUID(), + ticketNumber, + organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, + workType: ticketResult.ticket?.work_type ?? null, + ticketLookupDone: true, + startedAt: now, + pausedTotalMs: 0, + pausedAt: null, + phase: "running" + }; + + setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); + setSelectedTimerId(newTimer.id); + toast.success("Timer gestartet", { + description: ticketNumber + }); + return true; + } + + async function handleLogout() { + await logout().catch(() => undefined); + setCurrentUser(null); + navigate("/timer"); + } + + if (authLoading) { + return ( +
+ TicketTracker wird geladen... +
+ ); + } + + if (!currentUser) { + return ; + } + + const navItems = [ + { href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" }, + { href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, + { href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, + { href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, + ...(currentUser.role === "admin" + ? [{ href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }] + : []), + ]; + const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; + const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0; + + return ( + + + + + + + + + TicketTracker + + + + + + + + + + + + + + + Session starten + + + + + + + + navigate("/timer")} + /> + + + Workflows + + + {navItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + + + + +
+
+ + Abschluss +
+

Offene Sessions findest du in Tages- und Monatsansicht.

+
+ + + + + Worklog lokal + + + +
+
+ + *]:mx-auto", + "[html[data-content-layout=centered]_&>*]:w-full", + "[html[data-content-layout=centered]_&>*]:max-w-screen-2xl", + "[--dashboard-header-height:--spacing(12)]", + "min-w-0 overflow-x-clip", + )} + > +
+
+
+ + +
+ + {currentUser.display_name} +
+ {runningTimer ? ( + + ) : null} +
+ + TicketTracker +
+
+ +
+ + + + + + + + {currentUser.display_name} + {currentUser.username} + + + navigate("/profile")}> + + Profil + + void handleLogout()}> + + Abmelden + + + +
+
+
+
+ +
+
+
+ {route.page === "timer" ? ( + + ) : null} + {route.page === "analysis" ? : null} + {route.page === "recurring" ? : null} + {route.page === "profile" ? : null} + {route.page === "admin-users" && currentUser.role === "admin" ? : null} + {route.page === "admin-users" && currentUser.role !== "admin" ? ( + + ) : null} + {route.page === "ticket" ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..666cd38 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,357 @@ +import type { + AdminSession, + AdminUser, + AuthUser, + BillingStatus, + Organization, + PeriodOverview, + PeriodType, + RecurringBilling, + TicketMeta, + TicketPeriod, + UserRole, + WorkType +} from "./types"; + +type CreateSessionPayload = { + ticketNumber: string; + organizationId: string; + activity: string; + workType: WorkType; + startedAt: string; + endedAt: string; + durationSeconds: number; +}; + +type DeleteSessionResult = { + deleted: { + id: string; + ticket_id: string; + started_at: string; + userTicketEmpty: boolean; + ticketDeleted: boolean; + }; +}; + +export class ApiError extends Error { + constructor( + message: string, + public status: number + ) { + super(message); + this.name = "ApiError"; + } +} + +async function request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + ...options.headers + }, + ...options + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(body.error ?? "Anfrage fehlgeschlagen", response.status); + } + + return response.json() as Promise; +} + +export function login(payload: { username: string; password: string }) { + return request<{ user: AuthUser }>("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function logout() { + return request<{ ok: boolean }>("/api/auth/logout", { + method: "POST" + }); +} + +export function getCurrentUser() { + return request<{ user: AuthUser }>("/api/auth/me"); +} + +export function updateCurrentUser(payload: { + username: string; + displayName: string; + currentPassword?: string; + newPassword?: string; +}) { + return request<{ user: AuthUser }>("/api/auth/me", { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function getAdminUsers() { + return request<{ users: AdminUser[] }>("/api/admin/users"); +} + +export function createAdminUser(payload: { + username: string; + displayName: string; + password: string; + role: UserRole; + active: boolean; +}) { + return request<{ user: AdminUser }>("/api/admin/users", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateAdminUser( + userId: string, + payload: { + username: string; + displayName: string; + password?: string; + role: UserRole; + active: boolean; + } +) { + return request<{ user: AdminUser }>(`/api/admin/users/${userId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function getAdminSessions() { + return request<{ sessions: AdminSession[] }>("/api/admin/sessions"); +} + +export function getOrganizations(search = "") { + const params = new URLSearchParams(); + + if (search.trim()) { + params.set("search", search.trim()); + } + + return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`); +} + +export function getZammadSettings() { + return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings"); +} + +export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }) { + return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings", { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) { + return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function getRecurringBillings() { + return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); +} + +export function createRecurringBilling(payload: { + ticketNumber?: string | null; + organizationId: string; + activity: string; + workType: WorkType; + recurrenceType: "weekly" | "every_n_weeks"; + intervalValue: number; + validFrom: string; + validUntil?: string | null; + slots: Array<{ + weekday: number | null; + startTime: string; + durationMinutes: number; + }>; +}) { + return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateRecurringBilling( + billingId: string, + payload: + | { active: boolean } + | { + ticketNumber?: string | null; + organizationId: string; + activity: string; + workType: WorkType; + recurrenceType: "weekly" | "every_n_weeks"; + intervalValue: number; + validFrom: string; + validUntil?: string | null; + slots: Array<{ + id?: string; + weekday: number | null; + startTime: string; + durationMinutes: number; + }>; + } +) { + return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function deleteRecurringBilling(billingId: string) { + return request<{ recurringBillings: RecurringBilling[] }>(`/api/recurring-billings/${billingId}`, { + method: "DELETE" + }); +} + +export function createAdminSession(payload: { + userId: string; + ticketNumber: string; + organizationId: string; + activity: string; + workType: WorkType; + startedAt: string; + endedAt: string; +}) { + return request<{ session: AdminSession }>("/api/admin/sessions", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function reassignAdminSession(sessionId: string, userId: string) { + return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, { + method: "PATCH", + body: JSON.stringify({ userId }) + }); +} + +export function createSession(payload: CreateSessionPayload) { + return request("/api/sessions", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function lookupTicket(ticketNumber: string) { + return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`); +} + +export function updateTicket( + ticketId: string, + payload: { + ticketNumber: string; + organizationId: string; + workType: WorkType; + } +) { + return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function updateSessionDetails( + sessionId: string, + payload: { + organizationId: string; + activity: string; + workType: WorkType; + startedAt: string; + endedAt: string; + } +) { + return request(`/api/sessions/${sessionId}/details`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) { + return request(`/api/tickets/${ticketId}/day-billings/${day}`, { + method: "PATCH", + body: JSON.stringify({ billedMinutes }) + }); +} + +export function getMonthOverview(month: string) { + return getPeriodOverview("month", month); +} + +export function getTicketMonth(month: string, ticketId: string) { + return getTicketPeriod("month", month, ticketId); +} + +function periodPath(type: PeriodType) { + return type === "month" ? "months" : "days"; +} + +export function getPeriodOverview(type: PeriodType, period: string) { + return request(`/api/periods/${periodPath(type)}/${period}/overview`); +} + +export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { + return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); +} + +export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { + return request(`/api/sessions/${sessionId}/billing`, { + method: "PATCH", + body: JSON.stringify({ billingStatus }) + }); +} + +export function deleteSession(sessionId: string) { + return request(`/api/sessions/${sessionId}`, { + method: "DELETE" + }); +} + +export function closeTicketMonth(month: string, ticketId: string) { + return closeTicketPeriod("month", month, ticketId); +} + +export function reopenTicketMonth(month: string, ticketId: string) { + return reopenTicketPeriod("month", month, ticketId); +} + +export function closeMonth(month: string) { + return closePeriod("month", month); +} + +export function reopenMonth(month: string) { + return reopenPeriod("month", month); +} + +export function closeTicketPeriod(type: PeriodType, period: string, ticketId: string) { + return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/close`, { + method: "POST" + }); +} + +export function reopenTicketPeriod(type: PeriodType, period: string, ticketId: string) { + return request(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}/reopen`, { + method: "POST" + }); +} + +export function closePeriod(type: PeriodType, period: string) { + return request(`/api/periods/${periodPath(type)}/${period}/close`, { + method: "POST" + }); +} + +export function reopenPeriod(type: PeriodType, period: string) { + return request(`/api/periods/${periodPath(type)}/${period}/reopen`, { + method: "POST" + }); +} diff --git a/frontend/src/app/[[...path]]/page.tsx b/frontend/src/app/[[...path]]/page.tsx new file mode 100644 index 0000000..6c2ad0d --- /dev/null +++ b/frontend/src/app/[[...path]]/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { App } from "@/App"; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..299a3d9 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,303 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +/* Theme preset styles: these override CSS variables based on the selected data-theme-preset */ +@import "../styles/presets/brutalist.css"; +@import "../styles/presets/soft-pop.css"; +@import "../styles/presets/tangerine.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); +} + +/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected). +These serve as the fallback; there is no separate default.css file. */ +:root { + --radius: 0.625rem; + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* fonts */ + --font-sans: var(--font-geist); + --font-mono: var(--font-geist-mono); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + font-family: var(--font-sans), system-ui, sans-serif; + } + + a[href], + button:not(:disabled), + [role="button"]:not([aria-disabled="true"]), + [role="menuitem"]:not([aria-disabled="true"]):not([data-disabled]), + [role="option"]:not([aria-disabled="true"]):not([data-disabled]), + [data-slot="button"]:not(:disabled), + [data-slot="command-item"]:not([data-disabled="true"]), + [data-slot="dropdown-menu-item"]:not([data-disabled]), + [data-slot="select-item"]:not([data-disabled]) { + cursor: pointer; + } + + button:disabled, + [aria-disabled="true"], + [data-disabled] { + cursor: not-allowed; + } + + html { + @apply font-sans; + } +} + +@layer utilities { + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs { + box-shadow: var(--shadow-2xs); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-xs { + box-shadow: var(--shadow-xs); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-sm { + box-shadow: var(--shadow-sm); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow { + box-shadow: var(--shadow); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-md { + box-shadow: var(--shadow-md); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-lg { + box-shadow: var(--shadow-lg); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-xl { + box-shadow: var(--shadow-xl); + } + + [data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xl { + box-shadow: var(--shadow-2xl); + } + + html[data-font="inter"] body { + --font-sans: var(--font-inter); + } + html[data-font="notoSans"] body { + --font-sans: var(--font-noto-sans); + } + html[data-font="nunitoSans"] body { + --font-sans: var(--font-nunito-sans); + } + html[data-font="figtree"] body { + --font-sans: var(--font-figtree); + } + html[data-font="roboto"] body { + --font-sans: var(--font-roboto); + } + html[data-font="geist"] body { + --font-sans: var(--font-geist); + } + html[data-font="raleway"] body { + --font-sans: var(--font-raleway); + } + html[data-font="dmSans"] body { + --font-sans: var(--font-dm-sans); + } + html[data-font="publicSans"] body { + --font-sans: var(--font-public-sans); + } + html[data-font="outfit"] body { + --font-sans: var(--font-outfit); + } + html[data-font="geistMono"] body { + --font-sans: var(--font-geist-mono); + } + html[data-font="geistPixelSquare"] body { + --font-sans: var(--font-geist-pixel-square); + } + html[data-font="jetBrainsMono"] body { + --font-sans: var(--font-jetbrains-mono); + } + html[data-font="notoSerif"] body { + --font-sans: var(--font-noto-serif); + } + html[data-font="robotoSlab"] body { + --font-sans: var(--font-roboto-slab); + } + html[data-font="merriweather"] body { + --font-sans: var(--font-merriweather); + } + html[data-font="lora"] body { + --font-sans: var(--font-lora); + } + html[data-font="playfairDisplay"] body { + --font-sans: var(--font-playfair-display); + } +} + +html { + overscroll-behavior: none; +} + +.disable-transitions * { + transition: none !important; +} + +[data-print-root] { + display: none; +} + +@media print { + @page { + size: Letter; + margin: 0; + } + + html, + body { + width: 8.5in !important; + height: 11in !important; + margin: 0 !important; + overflow: hidden !important; + } + + body > *:not([data-print-root]) { + display: none !important; + } + + [data-print-root] { + display: block !important; + width: 8.5in !important; + height: 11in !important; + margin: 0 !important; + overflow: hidden !important; + background: white !important; + } + + [data-print-root] [data-print-paper] { + width: 8.5in !important; + height: 11in !important; + margin: 0 !important; + box-shadow: none !important; + } + + [data-print-root] [data-print-paper], + [data-print-root] [data-print-paper] * { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..24be77f --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { APP_CONFIG } from "@/config/app-config"; +import { fontVars } from "@/lib/fonts/registry"; +import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config"; +import { ThemeBootScript } from "@/scripts/theme-boot"; +import { PreferencesStoreProvider } from "@/stores/preferences/preferences-provider"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: APP_CONFIG.meta.title, + description: APP_CONFIG.meta.description, +}; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + const { theme_mode, theme_preset, content_layout, navbar_style, sidebar_variant, sidebar_collapsible, font } = + PREFERENCE_DEFAULTS; + + return ( + + + + + + + + {children} + + + + + + ); +} diff --git a/frontend/src/components/CopyTicketButton.tsx b/frontend/src/components/CopyTicketButton.tsx new file mode 100644 index 0000000..a11bd9c --- /dev/null +++ b/frontend/src/components/CopyTicketButton.tsx @@ -0,0 +1,40 @@ +import { Copy } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; + +type CopyTicketButtonProps = { + ticketNumber: string; +}; + +export function CopyTicketButton({ ticketNumber }: CopyTicketButtonProps) { + async function copyTicketNumber() { + try { + await navigator.clipboard.writeText(ticketNumber); + toast.success("Ticketnummer kopiert", { + description: ticketNumber + }); + } catch (error) { + toast.error("Ticketnummer konnte nicht kopiert werden", { + description: error instanceof Error ? error.message : "Zwischenablage nicht verfügbar" + }); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/OrganizationSelect.tsx b/frontend/src/components/OrganizationSelect.tsx new file mode 100644 index 0000000..0f486df --- /dev/null +++ b/frontend/src/components/OrganizationSelect.tsx @@ -0,0 +1,112 @@ +import { Check, ChevronsUpDown } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { getOrganizations } from "../api"; +import type { Organization } from "../types"; + +type OrganizationSelectProps = { + value: string; + selectedName?: string | null; + onChange: (organization: Organization) => void; + disabled?: boolean; + required?: boolean; + placeholder?: string; +}; + +export function OrganizationSelect({ + value, + selectedName, + onChange, + disabled = false, + required = false, + placeholder = "Organisation wählen" +}: OrganizationSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + try { + const result = await getOrganizations(search); + + if (!cancelled) { + setOrganizations(result.organizations); + } + } catch (error) { + if (!cancelled) { + toast.error("Organisationen konnten nicht geladen werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + if (open || value) { + void load(); + } + + return () => { + cancelled = true; + }; + }, [open, search, value]); + + const selectedOrganization = useMemo(() => organizations.find((organization) => organization.id === value) ?? null, [organizations, value]); + const label = selectedOrganization?.name ?? selectedName ?? ""; + + return ( + + + + + + + + + {loading ? "Lädt..." : "Keine Organisation gefunden."} + + {organizations.map((organization) => ( + { + onChange(organization); + setOpen(false); + setSearch(""); + }} + > + {organization.name} + + + ))} + + + + {required && !value ? undefined} /> : null} + + + ); +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..f5c5984 --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,81 @@ +"use client" + +import * as React from "react" +import { Accordion as AccordionPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..c206c86 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,199 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..88ef188 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,80 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + success: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200", + warning: + "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..57e38fa --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import { AspectRatio as AspectRatioPrimitive } from "radix-ui" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/frontend/src/components/ui/attachment.tsx b/frontend/src/components/ui/attachment.tsx new file mode 100644 index 0000000..5bdd1ce --- /dev/null +++ b/frontend/src/components/ui/attachment.tsx @@ -0,0 +1,204 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +const attachmentVariants = cva( + "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + { + variants: { + size: { + default: + "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", + sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", + xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", + }, + orientation: { + horizontal: "min-w-40 items-center", + vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", + }, + }, + } +) + +function Attachment({ + className, + state = "done", + size = "default", + orientation = "horizontal", + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + return ( +
+ ) +} + +const attachmentMediaVariants = cva( + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + { + variants: { + variant: { + icon: "", + image: + "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", + }, + }, + defaultVariants: { + variant: "icon", + }, + } +) + +function AttachmentMedia({ + className, + variant = "icon", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx new file mode 100644 index 0000000..7c2dc84 --- /dev/null +++ b/frontend/src/components/ui/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +