feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
189
backend/src/modules/time-v2/service.ts
Normal file
189
backend/src/modules/time-v2/service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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<any[]> {
|
||||
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<Span[]> {
|
||||
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<Span | null> {
|
||||
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<Span> {
|
||||
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<Span> {
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user