import { createHash } from 'node:crypto'; import { sql, SQL } from 'drizzle-orm'; import { fail, normalizeSpan, transition, range, evaluate, Span, instant, overlaps } from './domain'; export type Actor = { tenant: number; user: string; admin?: boolean; permissions?: string[]; trusted?: boolean; }; export const can = (a: Actor, permission: string) => Boolean(a.admin || a.trusted || a.permissions?.includes(permission)); export function authorize(a: Actor, user: string, operation = 'read') { if (!a.tenant || !a.user || !user) fail('Anmeldung und Mandant erforderlich.', 403); if (operation === 'approve' || operation === 'activate') { if (!can(a, `staff.time.${operation}`)) fail('Keine Berechtigung für diese Aktion.', 403); } else if (user !== a.user && !can(a, `staff.time.${operation}_all`) && !(operation === 'read' && ['write_all', 'approve', 'activate'].some(p => can(a, `staff.time.${p}`)))) fail('Keine Berechtigung für fremde Zeiten.', 403); } export async function rows(db: any, query: SQL): Promise { const result = await db.execute(query); const normalize = (value: any, key = ''): any => { if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) return value.map(v => normalize(v)); if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, normalize(v, k)])); if (typeof value === 'string' && (key.endsWith('_at') || key === 'event_time') && /^\d{4}-\d{2}-\d{2}[ T]/.test(value)) return new Date(value).toISOString(); return value; }; return normalize(result.rows); } export const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); export class TimeService { constructor(public db: any) { } async lock(tx: any, a: Actor, user: string) { await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${`staff-time:${a.tenant}:${user}`},0))`); const member = await rows(tx, sql `SELECT user_id FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`); if (!member.length) fail('Mitarbeiter im Mandanten nicht gefunden.', 404); } async active(a: Actor, user: string, db = this.db) { return (await rows(db, sql `SELECT * FROM staff_time_v2_accounts WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid`))[0] || null; } async list(a: Actor, user: string, from?: string, to?: string, db = this.db): Promise { authorize(a, user); const w = from && to ? range(from, to) : null; const filter = w ? sql `AND s.started_at < ${w.ended_at}::timestamptz AND (s.ended_at IS NULL OR s.ended_at > ${w.started_at}::timestamptz)` : sql ``; return rows(db, sql `SELECT s.*, COALESCE((SELECT jsonb_agg(b ORDER BY b.started_at) FROM staff_time_span_breaks b WHERE b.span_id=s.id),'[]'::jsonb) AS breaks, COALESCE((SELECT jsonb_agg(p ORDER BY p.started_at) FROM staff_time_span_projects p WHERE p.span_id=s.id),'[]'::jsonb) AS projects FROM staff_time_spans s WHERE s.tenant_id=${a.tenant} AND s.user_id=${user}::uuid ${filter} ORDER BY s.started_at DESC,s.id`); } async current(a: Actor, user: string, db = this.db): Promise { authorize(a, user); const [open] = await rows(db, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND ended_at IS NULL LIMIT 1`); return open ? this.get(a, open.id, db) : null; } async get(a: Actor, id: string, db = this.db): Promise { const [s] = await rows(db, sql `SELECT * FROM staff_time_spans WHERE id=${id}::uuid AND tenant_id=${a.tenant}`); if (!s) fail('Zeiteintrag nicht gefunden.', 404); authorize(a, s.user_id); s.breaks = await rows(db, sql `SELECT * FROM staff_time_span_breaks WHERE span_id=${id}::uuid ORDER BY started_at`); s.projects = await rows(db, sql `SELECT * FROM staff_time_span_projects WHERE span_id=${id}::uuid ORDER BY started_at`); return s; } async history(a: Actor, id: string) { await this.get(a, id); return rows(this.db, sql `SELECT * FROM staff_time_span_history WHERE span_id=${id}::uuid ORDER BY created_at,id`); } async save(tx: any, a: Actor, user: string, data: any, before: Span | null, action: string, reason: string, source: string): Promise { const s = normalizeSpan(data); s.status = data.status || 'factual'; const conflict = await rows(tx, sql `SELECT id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND (${before?.id || null}::uuid IS NULL OR id <> ${before?.id || null}::uuid) AND started_at < COALESCE(${s.ended_at}::timestamptz,'infinity'::timestamptz) AND COALESCE(ended_at,'infinity'::timestamptz) > ${s.started_at}::timestamptz LIMIT 1`); if (conflict.length) fail('Der Zeitraum überschneidet sich mit einem bestehenden Eintrag.', 409); for (const p of s.projects) { if (!(await rows(tx, sql `SELECT id FROM projects WHERE id=${p.project_id} AND tenant=${a.tenant}`)).length) fail('Projekt gehört nicht zum Mandanten.', 400); } let id = before?.id; if (id) { await tx.execute(sql `UPDATE staff_time_spans SET type=${s.type},started_at=${s.started_at}::timestamptz,ended_at=${s.ended_at}::timestamptz, date_from=${s.date_from}::date,date_to=${s.date_to}::date,timezone=${s.timezone},description=${s.description},status=${s.status},version=version+1,updated_at=now() WHERE id=${id}::uuid`); await tx.execute(sql `DELETE FROM staff_time_span_breaks WHERE span_id=${id}::uuid`); await tx.execute(sql `DELETE FROM staff_time_span_projects WHERE span_id=${id}::uuid`); } else { const [created] = await rows(tx, sql `INSERT INTO staff_time_spans(tenant_id,user_id,type,started_at,ended_at,date_from,date_to,timezone,description,source,status) VALUES(${a.tenant},${user}::uuid,${s.type},${s.started_at}::timestamptz,${s.ended_at}::timestamptz,${s.date_from}::date,${s.date_to}::date,${s.timezone},${s.description},${source},${s.status}) RETURNING id`); id = created.id; } for (const p of s.breaks) await tx.execute(sql `INSERT INTO staff_time_span_breaks(span_id,started_at,ended_at) VALUES(${id}::uuid,${p.started_at}::timestamptz,${p.ended_at}::timestamptz)`); for (const p of s.projects) await tx.execute(sql `INSERT INTO staff_time_span_projects(span_id,project_id,started_at,ended_at,notes) VALUES(${id}::uuid,${p.project_id},${p.started_at}::timestamptz,${p.ended_at}::timestamptz,${p.notes})`); const after = await this.get({ ...a, trusted: true }, id!, tx); await tx.execute(sql `INSERT INTO staff_time_span_history(span_id,actor_id,action,reason,before_data,after_data) VALUES(${id}::uuid,${a.user}::uuid,${action},${reason},${before ? JSON.stringify(before) : null}::jsonb,${JSON.stringify(after)}::jsonb)`); return after; } async mutate(a: Actor, user: string, requestId: string, command: any, source = 'web') { const approval = ['approve', 'reject'].includes(command.action); authorize(a, user, approval ? 'approve' : 'write'); if (typeof requestId !== 'string' || requestId.length < 8 || requestId.length > 200) fail('Eindeutige Anfrage-ID erforderlich.'); const fingerprint = digest({ command, actor: a.user, source }); return this.db.transaction(async (tx: any) => { await this.lock(tx, a, user); if (!await this.active(a, user, tx)) fail('Zeiterfassung 2.0 zuerst mit Datenvorschau aktivieren.', 409); const [previous] = await rows(tx, sql `SELECT * FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${requestId}`); if (previous) { if (previous.fingerprint !== fingerprint) fail('Anfrage-ID wurde bereits für andere Daten verwendet.', 409); return previous.response; } const action = command.action; if (['create', 'start'].includes(action) && command.id) fail('Neue Einträge dürfen keine bestehende ID ersetzen.'); let before: Span | null = null, data: any; if (command.id) { before = await this.get({ ...a, trusted: true }, command.id, tx); if (before.user_id !== user) fail('Eintrag gehört zu anderem Mitarbeiter.', 403); if (command.version !== before.version) fail('Eintrag wurde zwischenzeitlich geändert. Bitte neu laden.', 409); } if (action === 'create') data = { ...command.data, status: 'factual' }; else if (action === 'start') { data = { type: 'work', started_at: instant(command.at), ended_at: null, description: command.description || '' }; } else if (['stop', 'pause', 'resume'].includes(action)) { if (!before || before.type !== 'work' || before.ended_at) fail('Keine passende laufende Anwesenheit.', 409); data = { ...before, breaks: before.breaks.map(p => ({ ...p })) }; const at = instant(command.at), open = data.breaks.find((p: any) => !p.ended_at); if (action === 'pause') { if (open) fail('Pause läuft bereits.', 409); data.breaks.push({ started_at: at, ended_at: null }); } else { if (action === 'resume' && !open) fail('Keine laufende Pause.', 409); if (open) open.ended_at = at; if (action === 'stop') data.ended_at = at; } } else if (action === 'update') { if (!before) fail('Eintrag erforderlich.'); if (!String(command.reason || '').trim()) fail('Korrekturgrund erforderlich.'); data = { ...command.data, status: 'factual' }; } else if (['submit', 'approve', 'reject'].includes(action)) { if (!before) fail('Eintrag erforderlich.'); data = { ...before, status: transition(before, action, command.reason) }; } else fail('Unbekannte Aktion.'); const response = await this.save(tx, a, user, data, before, action, String(command.reason || ''), source); await tx.execute(sql `INSERT INTO staff_time_v2_requests(tenant_id,user_id,request_id,fingerprint,response) VALUES(${a.tenant},${user}::uuid,${requestId},${fingerprint},${JSON.stringify(response)}::jsonb)`); return response; }); } async evaluation(a: Actor, user: string, from: string, to: string) { const spans = await this.list(a, user, from, to); const [profile] = await rows(this.db, sql `SELECT * FROM auth_profiles WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid LIMIT 1`); if (!profile) fail('Mitarbeiterprofil fehlt.', 404); const holidays = await rows(this.db, sql `SELECT DISTINCT date FROM holidays WHERE state_code IN ('DE',${profile.state_code || 'DE'}) AND date BETWEEN ${from}::date AND ${to}::date`); return { userId: user, spans, summary: { ...evaluate(spans, profile, holidays.map(h => h.date), from, to), from, to, user_id: user, tenant_id: a.tenant } }; } }