feat(time): add span-based attendance tracking under 2.0

This commit is contained in:
2026-10-03 15:55:45 +02:00
parent 4c67ddd908
commit 677ae0f4fb
32 changed files with 1681 additions and 47 deletions

View File

@@ -0,0 +1,93 @@
CREATE TABLE staff_time_v2_accounts (
tenant_id bigint NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES auth_users(id),
activated_at timestamptz NOT NULL DEFAULT now(), activated_by uuid REFERENCES auth_users(id),
PRIMARY KEY(tenant_id,user_id)
);
--> statement-breakpoint
CREATE TABLE staff_time_spans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id bigint NOT NULL REFERENCES tenants(id),
user_id uuid NOT NULL REFERENCES auth_users(id),
type text NOT NULL CHECK(type IN ('work','vacation','sick','overtime_compensation','holiday','other')),
started_at timestamptz NOT NULL, ended_at timestamptz,
date_from date, date_to date, timezone text NOT NULL DEFAULT 'Europe/Berlin',
description text NOT NULL DEFAULT '', source text NOT NULL,
status text NOT NULL DEFAULT 'factual' CHECK(status IN ('factual','submitted','approved','rejected')),
version integer NOT NULL DEFAULT 1 CHECK(version > 0),
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
CHECK(ended_at IS NULL OR ended_at > started_at),
CHECK((date_from IS NULL AND date_to IS NULL) OR (type <> 'work' AND date_from IS NOT NULL AND date_to IS NOT NULL AND date_to > date_from AND ended_at IS NOT NULL)),
CHECK(status = 'factual' OR ended_at IS NOT NULL)
);
CREATE INDEX staff_time_spans_user_time ON staff_time_spans(tenant_id,user_id,started_at);
CREATE UNIQUE INDEX staff_time_spans_one_open ON staff_time_spans(tenant_id,user_id) WHERE ended_at IS NULL;
--> statement-breakpoint
CREATE TABLE staff_time_span_breaks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
started_at timestamptz NOT NULL, ended_at timestamptz,
CHECK(ended_at IS NULL OR ended_at > started_at)
);
CREATE INDEX staff_time_span_breaks_parent ON staff_time_span_breaks(span_id);
CREATE UNIQUE INDEX staff_time_span_breaks_one_open ON staff_time_span_breaks(span_id) WHERE ended_at IS NULL;
CREATE TABLE staff_time_span_projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
project_id bigint NOT NULL REFERENCES projects(id), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL,
notes text NOT NULL DEFAULT '', CHECK(ended_at > started_at)
);
CREATE INDEX staff_time_span_projects_parent ON staff_time_span_projects(span_id);
--> statement-breakpoint
CREATE TABLE staff_time_span_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), span_id uuid NOT NULL REFERENCES staff_time_spans(id),
actor_id uuid REFERENCES auth_users(id), action text NOT NULL, reason text NOT NULL DEFAULT '',
before_data jsonb, after_data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX staff_time_span_history_parent ON staff_time_span_history(span_id,created_at);
CREATE TABLE staff_time_v2_requests (
tenant_id bigint NOT NULL, user_id uuid NOT NULL, request_id text NOT NULL,
fingerprint text NOT NULL, response jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(tenant_id,user_id,request_id)
);
CREATE TABLE staff_time_span_origins (
tenant_id bigint NOT NULL, source_kind text NOT NULL, source_id uuid NOT NULL,
span_id uuid NOT NULL REFERENCES staff_time_spans(id), PRIMARY KEY(tenant_id,source_kind,source_id)
);
--> statement-breakpoint
-- The same lock as the service makes cutover atomic with concurrent legacy writers.
CREATE FUNCTION staff_time_legacy_write_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE t bigint; u uuid;
BEGIN
IF TG_OP = 'DELETE' THEN t := OLD.tenant_id; u := OLD.user_id;
ELSE t := NEW.tenant_id; u := NEW.user_id; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || t || ':' || u,0));
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=t AND user_id=u) THEN
RAISE EXCEPTION 'Zeiterfassung 2.0 ist aktiv. Bitte die neue Erfassung verwenden.' USING ERRCODE='23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_events_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_events FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
CREATE TRIGGER staff_time_entries_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entries FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_write_guard();
--> statement-breakpoint
CREATE FUNCTION staff_time_span_overlap_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || NEW.tenant_id || ':' || NEW.user_id,0));
IF EXISTS(SELECT 1 FROM staff_time_spans s WHERE s.tenant_id=NEW.tenant_id AND s.user_id=NEW.user_id AND s.id<>NEW.id
AND s.started_at<COALESCE(NEW.ended_at,'infinity'::timestamptz) AND COALESCE(s.ended_at,'infinity'::timestamptz)>NEW.started_at) THEN
RAISE EXCEPTION 'Überlappende Zeitspannen' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_span_no_overlap BEFORE INSERT OR UPDATE ON staff_time_spans FOR EACH ROW EXECUTE FUNCTION staff_time_span_overlap_guard();
--> statement-breakpoint
CREATE FUNCTION staff_time_legacy_project_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE parent staff_time_entries;
BEGIN
IF TG_OP = 'DELETE' THEN SELECT * INTO parent FROM staff_time_entries WHERE id=OLD.time_entry_id;
ELSE SELECT * INTO parent FROM staff_time_entries WHERE id=NEW.time_entry_id; END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('staff-time:' || parent.tenant_id || ':' || parent.user_id,0));
IF EXISTS(SELECT 1 FROM staff_time_v2_accounts WHERE tenant_id=parent.tenant_id AND user_id=parent.user_id) THEN
RAISE EXCEPTION 'Projektzeiten unter 2.0 bearbeiten.' USING ERRCODE='23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER staff_time_project_cutover BEFORE INSERT OR UPDATE OR DELETE ON staff_time_entry_connects FOR EACH ROW EXECUTE FUNCTION staff_time_legacy_project_guard();

View File

@@ -540,6 +540,13 @@
"when": 1791072000000,
"tag": "0079_notification_matrix",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1791158400000,
"tag": "0080_staff_time_spans",
"breakpoints": true
}
]
}

View File

@@ -105,3 +105,4 @@ export * from "./scanner_inbox"
export * from "./collaboration";
export * from "./collaboration_federation"
export * from './staff_time_spans'

View File

@@ -0,0 +1,48 @@
import { pgTable, uuid, bigint, text, timestamp, date, integer, jsonb, primaryKey, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { tenants } from './tenants';
import { authUsers } from './auth_users';
import { projects } from './projects';
const stamp = (name: string) => timestamp(name, { withTimezone: true });
export const staffTimeV2Accounts = pgTable('staff_time_v2_accounts', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull().references(() => tenants.id),
user_id: uuid('user_id').notNull().references(() => authUsers.id),
activated_at: stamp('activated_at').notNull().defaultNow(), activated_by: uuid('activated_by').references(() => authUsers.id),
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id] })]);
export const staffTimeSpans = pgTable('staff_time_spans', {
id: uuid('id').primaryKey().defaultRandom(), tenant_id: bigint('tenant_id', { mode: 'number' }).notNull().references(() => tenants.id),
user_id: uuid('user_id').notNull().references(() => authUsers.id), type: text('type').notNull(),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'), date_from: date('date_from'), date_to: date('date_to'),
timezone: text('timezone').notNull().default('Europe/Berlin'), description: text('description').notNull().default(''), source: text('source').notNull(),
status: text('status').notNull().default('factual'), version: integer('version').notNull().default(1),
created_at: stamp('created_at').notNull().defaultNow(), updated_at: stamp('updated_at').notNull().defaultNow(),
}, t => [index('staff_time_spans_user_time').on(t.tenant_id, t.user_id, t.started_at),
uniqueIndex('staff_time_spans_one_open').on(t.tenant_id, t.user_id).where(sql `${t.ended_at} IS NULL`),
check('staff_time_spans_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`),
check('staff_time_spans_type_check', sql `${t.type} IN ('work','vacation','sick','overtime_compensation','holiday','other')`),
check('staff_time_spans_status_check', sql `${t.status} IN ('factual','submitted','approved','rejected')`),
check('staff_time_spans_version_check', sql `${t.version} > 0`),
check('staff_time_spans_check1', sql `(${t.date_from} IS NULL AND ${t.date_to} IS NULL) OR (${t.type} <> 'work' AND ${t.date_from} IS NOT NULL AND ${t.date_to} IS NOT NULL AND ${t.date_to} > ${t.date_from} AND ${t.ended_at} IS NOT NULL)`),
check('staff_time_spans_check2', sql `${t.status} = 'factual' OR ${t.ended_at} IS NOT NULL`)]);
export const staffTimeSpanBreaks = pgTable('staff_time_span_breaks', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at'),
}, t => [index('staff_time_span_breaks_parent').on(t.span_id), uniqueIndex('staff_time_span_breaks_one_open').on(t.span_id).where(sql `${t.ended_at} IS NULL`), check('staff_time_span_breaks_check', sql `${t.ended_at} IS NULL OR ${t.ended_at} > ${t.started_at}`)]);
export const staffTimeSpanProjects = pgTable('staff_time_span_projects', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
project_id: bigint('project_id', { mode: 'number' }).notNull().references(() => projects.id),
started_at: stamp('started_at').notNull(), ended_at: stamp('ended_at').notNull(), notes: text('notes').notNull().default(''),
}, t => [index('staff_time_span_projects_parent').on(t.span_id), check('staff_time_span_projects_check', sql `${t.ended_at} > ${t.started_at}`)]);
export const staffTimeSpanHistory = pgTable('staff_time_span_history', {
id: uuid('id').primaryKey().defaultRandom(), span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
actor_id: uuid('actor_id').references(() => authUsers.id), action: text('action').notNull(), reason: text('reason').notNull().default(''),
before_data: jsonb('before_data'), after_data: jsonb('after_data').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
}, t => [index('staff_time_span_history_parent').on(t.span_id, t.created_at)]);
export const staffTimeV2Requests = pgTable('staff_time_v2_requests', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), user_id: uuid('user_id').notNull(), request_id: text('request_id').notNull(),
fingerprint: text('fingerprint').notNull(), response: jsonb('response').notNull(), created_at: stamp('created_at').notNull().defaultNow(),
}, t => [primaryKey({ columns: [t.tenant_id, t.user_id, t.request_id] })]);
export const staffTimeSpanOrigins = pgTable('staff_time_span_origins', {
tenant_id: bigint('tenant_id', { mode: 'number' }).notNull(), source_kind: text('source_kind').notNull(), source_id: uuid('source_id').notNull(),
span_id: uuid('span_id').notNull().references(() => staffTimeSpans.id),
}, t => [primaryKey({ columns: [t.tenant_id, t.source_kind, t.source_id] })]);

View File

@@ -26,6 +26,7 @@ import helpdeskRoutes from "./routes/helpdesk";
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
import notificationsRoutes from "./routes/notifications";
import staffTimeRoutes from "./routes/staff/time";
import timeV2Routes from "./routes/staff/time-v2";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
@@ -168,6 +169,7 @@ async function main() {
await subApp.register(helpdeskRoutes);
await subApp.register(notificationsRoutes);
await subApp.register(staffTimeRoutes);
await subApp.register(timeV2Routes);
await subApp.register(staffTimeConnectRoutes);
await subApp.register(userRoutes);
await subApp.register(publiclinksAuthenticatedRoutes);

View File

@@ -1,3 +1,4 @@
import { TimeService } from "./time-v2/service";
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
@@ -200,6 +201,14 @@ export const publicLinkService = {
},
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
return server.db.transaction(async tx => {
const scoped = Object.create(server);
scoped.db = tx;
return publicLinkService.submitFormDataTransaction(scoped, token, payload, providedPin);
});
},
async submitFormDataTransaction(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
// 1. Validierung (Token & PIN)
const linkConfig = await server.db.query.publicLinks.findFirst({
where: eq(schema.publicLinks.token, token)
@@ -223,7 +232,7 @@ export const publicLinkService = {
// Profil laden, um die user_id zu bekommen
const authProfile = await server.db.query.authProfiles.findFirst({
where: eq(schema.authProfiles.id, rawProfileId)
where: and(eq(schema.authProfiles.id, rawProfileId), eq(schema.authProfiles.tenant_id, tenantId))
});
if (!authProfile) throw new Error("Profile_Not_Found");
@@ -245,7 +254,7 @@ export const publicLinkService = {
// SCHRITT A: Stammdaten laden
// =================================================================
const project = await server.db.query.projects.findFirst({
where: eq(schema.projects.id, payload.project)
where: and(eq(schema.projects.id, payload.project), eq(schema.projects.tenant, tenantId))
});
if (!project) throw new Error("Project not found");
@@ -351,29 +360,42 @@ export const publicLinkService = {
generatedDocumentId: createdDoc.id
};
// 1. START EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId, // WICHTIG: User ID
actortype: "user",
actoruser_id: userId, // WICHTIG: User ID
eventtime: startDate,
eventtype: "START",
source: "PUBLIC_LINK",
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
});
const timeService = new TimeService(server.db);
const actor = { tenant: tenantId, user: userId, trusted: true };
await timeService.lock(server.db, actor, userId);
if (await timeService.active(actor, userId)) {
await timeService.mutate(actor, userId, `public-link:${createdDoc.id}`, {
action: 'create', data: {
type: 'work', started_at: startDate.toISOString(), ended_at: endDate.toISOString(),
description: eventMetadata.description, breaks: [],
projects: [{ project_id: project.id, started_at: startDate.toISOString(), ended_at: endDate.toISOString(), notes: eventMetadata.description }],
},
}, 'public_link');
} else {
// 1. START EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId, // WICHTIG: User ID
actortype: "user",
actoruser_id: userId, // WICHTIG: User ID
eventtime: startDate,
eventtype: "START",
source: "PUBLIC_LINK",
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
});
// 2. STOP EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: endDate,
eventtype: "STOP",
source: "PUBLIC_LINK",
metadata: eventMetadata
});
// 2. STOP EVENT
await server.db.insert(schema.stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: endDate,
eventtype: "STOP",
source: "PUBLIC_LINK",
metadata: eventMetadata
});
}
}
// =================================================================

View File

@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { FastifyInstance } from 'fastify';
import { Actor, authorize, digest, rows, TimeService } from './service';
import { fail, Span } from './domain';
import { timeActor, uuid } from '../../routes/staff/time-v2';
/** Existing consumers still receive work segments with pauses excluded, backed by a stable span ID. */
export function legacySpans(spans: Span[]) {
return spans.flatMap(s => {
const base = { id: s.id, version: s.version, sourceEventIds: [s.id], status: s.status, user_id: s.user_id, description: s.description, payload: { description: s.description }, model: 'spans-v2', attendance: s };
if (s.type !== 'work')
return [{ ...base, type: s.type, startedAt: s.started_at, endedAt: s.ended_at }];
const result: any[] = [];
let start = s.started_at;
for (const p of s.breaks) {
if (Date.parse(start) < Date.parse(p.started_at))
result.push({ ...base, type: 'work', startedAt: start, endedAt: p.started_at });
result.push({ ...base, type: 'pause', startedAt: p.started_at, endedAt: p.ended_at });
if (!p.ended_at)
return result;
start = p.ended_at;
}
if (!s.ended_at || Date.parse(start) < Date.parse(s.ended_at))
result.push({ ...base, type: 'work', startedAt: start, endedAt: s.ended_at });
return result;
});
}
export async function legacyEvent(service: TimeService, a: Actor, user: string, body: any, requestId?: string, source = 'legacy') {
authorize(a, user, 'write');
const key = `legacy:${digest({ body, requestId, actor: a.user, source })}`;
const [cached] = await rows(service.db, sql `SELECT response FROM staff_time_v2_requests WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid AND request_id=${key}`);
if (cached)
return cached.response;
const at = body.eventtime, type = body.eventtype;
const command: any = { at };
const active = await service.current({ ...a, trusted: true }, user);
if (type === 'work_start')
Object.assign(command, { action: 'start', description: body.payload?.description });
else if (['work_end', 'auto_stop', 'pause_start', 'pause_end'].includes(type)) {
if (!active)
fail('Keine laufende Anwesenheit.', 409);
Object.assign(command, { action: type === 'pause_start' ? 'pause' : type === 'pause_end' ? 'resume' : 'stop', id: active.id, version: active.version });
}
else if (/^(vacation|sick|overtime_compensation|holiday|other)_start$/.test(type)) {
Object.assign(command, { action: 'create', data: { type: type.slice(0, -6), started_at: at, ended_at: null, description: body.payload?.description || '' } });
}
else if (/^(vacation|sick|overtime_compensation|holiday|other)_end$/.test(type)) {
if (!active || active.type !== type.slice(0, -4))
fail('Kein passender laufender Eintrag.', 409);
Object.assign(command, { action: 'update', id: active.id, version: active.version, reason: 'Abschluss über bestehende Erfassung', data: { ...active, ended_at: at } });
}
else
fail('Unbekannter Ereignistyp.');
// For clients without IDs, the exact event payload is the stable key. Resolve retries before constructing a changed version.
return service.mutate(a, user, key, command, source);
}
export function installTimeCompatibility(server: FastifyInstance, internal = false) {
const service = new TimeService(server.db);
server.addHook('preHandler', async (req, reply) => {
try {
const path = req.url.split('?')[0], b = (req.body || {}) as any, q = req.query as any;
const operation = path.split('/').pop()!;
const a: Actor = internal ? { ...timeActor(req), trusted: true } : timeActor(req);
// Internal keys remain bound to their authenticated tenant and user context.
if (internal && ((b.tenant_id && Number(b.tenant_id) !== a.tenant) || (q.tenantId && Number(q.tenantId) !== a.tenant)))
fail('Falscher Mandant.', 403);
let user = uuid(b.user_id || b.employeeUserId || q.targetUserId || a.user);
const ids = b.eventIds || b.originalEventIds;
if (Array.isArray(ids) && ids.length) {
const [owner] = await rows(server.db, sql `SELECT user_id FROM staff_time_spans WHERE tenant_id=${a.tenant} AND id=${uuid(ids[0])}::uuid`);
if (owner)
user = owner.user_id;
}
authorize(a, user, ['approve', 'reject'].includes(operation) ? 'approve' : req.method === 'GET' ? 'read' : 'write');
if (!await service.active(a, user))
return;
if (operation === 'spans') {
const spans = await service.list(a, user, q.from, q.to);
const current = await service.current(a, user);
if (current && !spans.some(s => s.id === current.id)) spans.push(current);
return reply.send(legacySpans(spans));
}
if (operation === 'evaluation') {
const result = await service.evaluation(a, user, q.from, q.to);
return reply.send({ ...result, spans: legacySpans(result.spans) });
}
if (operation === 'event')
return reply.send(await legacyEvent(service, a, user, b, req.headers['idempotency-key'] as string, internal ? 'terminal' : 'legacy'));
if (operation === 'edit')
fail('Dieser Eintrag wird unter 2.0 → Zeiterfassung einschließlich seiner Pausen bearbeitet.', 409);
if (['submit', 'approve', 'reject'].includes(operation)) {
if (!Array.isArray(ids) || ids.length !== 1)
fail('Bitte vollständige Anwesenheiten einzeln unter 2.0 bearbeiten.', 409);
const before = await service.get(a, uuid(ids[0]));
if (before.status === ({ submit: 'submitted', approve: 'approved', reject: 'rejected' } as any)[operation])
return reply.send(before);
const result = await service.mutate(a, user, req.headers['idempotency-key'] as string || randomUUID(), {
action: operation, id: before.id, version: before.version, reason: b.reason,
}, 'legacy');
return reply.send(result);
}
}
catch (error: any) {
req.log.error(error);
return reply.code(error.statusCode || 400).send({ error: error.statusCode ? error.message : 'Zeitaktion konnte nicht verarbeitet werden.' });
}
});
}

View File

@@ -0,0 +1,167 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
export const TYPES = ['work', 'vacation', 'sick', 'overtime_compensation', 'holiday', 'other'] as const;
export type TimeType = typeof TYPES[number];
export type Period = {
started_at: string;
ended_at: string | null;
};
export type Span = Period & {
id?: string;
user_id?: string;
type: TimeType;
description: string;
status: string;
version?: number;
date_from?: string | null;
date_to?: string | null;
timezone: string;
breaks: Period[];
projects: (Period & {
project_id: number;
notes: string;
})[];
};
export class TimeError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
export const fail = (message: string, status = 400): never => { throw new TimeError(status, message); };
export function instant(value: unknown): string {
if (typeof value !== 'string' || !/T.*(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(Date.parse(value)))
fail('Zeitpunkt mit Zeitzone erforderlich.');
dateOnly((value as string).slice(0, 10));
return new Date(value as string).toISOString();
}
export function dateOnly(value: unknown): string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value)
fail('Ungültiges Datum.');
return value as string;
}
export function midnight(date: string, zone = 'Europe/Berlin') { return dayjs.tz(dateOnly(date), zone).toISOString(); }
export function nextDay(date: string) { return dayjs.utc(dateOnly(date)).add(1, 'day').format('YYYY-MM-DD'); }
export function range(from: string, to: string, zone = 'Europe/Berlin') {
dateOnly(from);
dateOnly(to);
if (from > to || dayjs.utc(to).diff(dayjs.utc(from), 'day') > 3660)
fail('Zeitraum muss zwischen 1 und 3661 Tagen liegen.');
return { started_at: midnight(from, zone), ended_at: midnight(nextDay(to), zone) };
}
const end = (p: Period) => p.ended_at ? Date.parse(p.ended_at) : Infinity;
export const overlaps = (a: Period, b: Period) => Date.parse(a.started_at) < end(b) && Date.parse(b.started_at) < end(a);
export const minutes = (p: Period, window: Period) => Math.max(0, Math.min(end(p), end(window)) - Math.max(Date.parse(p.started_at), Date.parse(window.started_at))) / 60000;
export function normalizeSpan(input: any): Span {
if (!input || !TYPES.includes(input.type))
fail('Ungültige Zeitart.');
const zone = input.timezone || 'Europe/Berlin';
try {
new Intl.DateTimeFormat('de', { timeZone: zone });
}
catch {
fail('Ungültige Zeitzone.');
}
const allDay = input.date_from != null || input.date_to != null;
const span: Span = {
type: input.type, description: String(input.description || '').slice(0, 10000), timezone: zone,
started_at: allDay ? midnight(input.date_from, zone) : instant(input.started_at),
ended_at: allDay ? midnight(input.date_to, zone) : input.ended_at == null ? null : instant(input.ended_at),
date_from: allDay ? dateOnly(input.date_from) : null, date_to: allDay ? dateOnly(input.date_to) : null,
status: 'factual', breaks: [], projects: [],
};
if (allDay && (span.type === 'work' || span.date_to! <= span.date_from!))
fail('Ungültiger ganztägiger Zeitraum; Ende ist exklusiv.');
if (span.ended_at && end(span) <= Date.parse(span.started_at))
fail('Ende muss nach Beginn liegen.');
if (!Array.isArray(input.breaks || []) || !Array.isArray(input.projects || []))
fail('Ungültige Teilzeiträume.');
span.breaks = (input.breaks || []).map((p: any) => ({ started_at: instant(p.started_at), ended_at: p.ended_at == null ? null : instant(p.ended_at) }));
span.projects = (input.projects || []).map((p: any) => {
if (!Number.isSafeInteger(Number(p.project_id)) || Number(p.project_id) <= 0)
fail('Projekt erforderlich.');
return { started_at: instant(p.started_at), ended_at: instant(p.ended_at), project_id: Number(p.project_id), notes: String(p.notes || '').slice(0, 10000) };
});
if (span.type !== 'work' && (span.breaks.length || span.projects.length))
fail('Pausen und Projekte benötigen eine Anwesenheit.');
for (const parts of [span.breaks, span.projects]) {
if (parts.length > 200)
fail('Zu viele Teilzeiträume.');
parts.sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at));
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (end(p) <= Date.parse(p.started_at) || Date.parse(p.started_at) < Date.parse(span.started_at) || end(p) > end(span))
fail('Teilzeitraum muss innerhalb der Anwesenheit liegen.');
if (i && overlaps(parts[i - 1], p))
fail('Teilzeiträume dürfen sich nicht überschneiden.');
}
}
for (const project of span.projects)
if (span.breaks.some(p => overlaps(p, project)))
fail('Projektzeit darf keine Pause enthalten.');
return span;
}
export function transition(span: Span, action: string, reason = '') {
if (!span.ended_at)
fail('Laufende Einträge zuerst beenden.', 409);
if (action === 'submit' && ['factual', 'rejected'].includes(span.status))
return 'submitted';
if (action === 'approve' && span.status === 'submitted')
return 'approved';
if (action === 'reject' && span.status === 'submitted' && reason.trim())
return 'rejected';
fail('Ungültiger Statuswechsel oder fehlender Ablehnungsgrund.', 409);
}
export function evaluate(spans: Span[], profile: any, holidayDates: string[], from: string, to: string, zone = 'Europe/Berlin') {
const window = range(from, to, zone);
const sum = { timeSpanWorkingMinutes: 0, sumWorkingMinutesSubmitted: 0, sumWorkingMinutesApproved: 0,
sumWorkingMinutesRecreationDays: 0, sumRecreationDays: 0, sumWorkingMinutesVacationDays: 0, sumVacationDays: 0,
sumWorkingMinutesSickDays: 0, sumSickDays: 0, sumOvertimeCompensationMinutes: 0, sumOtherMinutes: 0,
presenceMinutes: 0, breakMinutes: 0, saldoApproved: 0, saldoSubmitted: 0 };
for (const s of spans.filter(s => s.type === 'work' && s.ended_at)) {
const gross = minutes(s, window), breaks = s.breaks.reduce((n, p) => n + minutes(p, window), 0), net = gross - breaks;
sum.presenceMinutes += gross;
sum.breakMinutes += breaks;
if (['submitted', 'approved'].includes(s.status))
sum.sumWorkingMinutesSubmitted += net;
if (s.status === 'approved')
sum.sumWorkingMinutesApproved += net;
}
const holidays = new Set(holidayDates);
for (let date = from; date <= to; date = nextDay(date)) {
const day = range(date, date, zone), weekday = dayjs.utc(date).day();
const target = Math.max(0, Number(profile.weekly_regular_working_hours?.[weekday] || 0)) * 60;
sum.timeSpanWorkingMinutes += target;
// Credits are capped per day; holidays take precedence over overlapping absence credits.
let remaining = target;
const approved = spans.filter(s => s.type !== 'work' && s.status === 'approved' && s.ended_at && overlaps(s, day));
if ((profile.recreation_days_compensation && holidays.has(date)) || approved.some(s => s.type === 'holiday')) {
sum.sumWorkingMinutesRecreationDays += target;
sum.sumRecreationDays += target > 0 ? 1 : 0;
remaining = 0;
}
for (const type of ['vacation', 'sick', 'overtime_compensation', 'other']) {
for (const s of approved.filter(s => s.type === type)) {
const credit = Math.min(remaining, s.date_from ? target : minutes(s, day));
if (type === 'vacation') {
sum.sumWorkingMinutesVacationDays += credit;
sum.sumVacationDays += target ? credit / target : 0;
}
if (type === 'sick') {
sum.sumWorkingMinutesSickDays += credit;
sum.sumSickDays += target ? credit / target : 0;
}
// Freizeitausgleich consumes the balance through the regular daily target, without a credit.
if (type === 'overtime_compensation')
sum.sumOvertimeCompensationMinutes += credit;
if (type === 'other')
sum.sumOtherMinutes += credit;
remaining -= credit;
}
}
}
const credit = sum.sumWorkingMinutesRecreationDays + sum.sumWorkingMinutesVacationDays + sum.sumWorkingMinutesSickDays;
sum.saldoApproved = sum.sumWorkingMinutesApproved + credit - sum.timeSpanWorkingMinutes;
sum.saldoSubmitted = sum.sumWorkingMinutesSubmitted + credit - sum.timeSpanWorkingMinutes;
return sum;
}

View File

@@ -0,0 +1,146 @@
import { sql } from 'drizzle-orm';
import { Actor, authorize, digest, rows, TimeService } from './service';
import { fail, normalizeSpan, overlaps, Span } from './domain';
type Candidate = {
span: Span;
origins: {
kind: string;
id: string;
}[];
};
/** Conservative replay: never invent missing ends or silently discard ambiguous points. */
export function previewLegacy(events: any[], entries: any[], connects: any[]) {
const issues: string[] = [], candidates: Candidate[] = [];
const invalidated = new Set(events.map(e => e.invalidates_event_id).filter(Boolean));
const valid = events.filter(e => !invalidated.has(e.id) && e.eventtype !== 'invalidated');
const states = new Map<string, string>();
for (const e of [...valid].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id))) {
if (['submitted', 'approved', 'rejected'].includes(e.eventtype) && e.related_event_id)
states.set(e.related_event_id, e.eventtype);
}
const points = valid.filter(e => !['submitted', 'approved', 'rejected'].includes(e.eventtype))
.sort((a, b) => Date.parse(a.eventtime) - Date.parse(b.eventtime) || String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id));
let current: any = null, origins: Candidate['origins'] = [];
const close = () => {
if (!current)
return;
const statusSet = new Set(origins.map(o => states.get(o.id) || 'factual'));
if (statusSet.size > 1)
issues.push(`Uneinheitliche Freigaben ab ${current.started_at}; vor Übernahme vereinheitlichen.`);
current.status = statusSet.size === 1 ? [...statusSet][0] : 'factual';
if (!current.ended_at)
issues.push(`Offener Alt-Eintrag ab ${current.started_at}; zuerst abschließen.`);
try {
const normalized = normalizeSpan(current);
normalized.status = current.status;
candidates.push({ span: normalized, origins: [...origins] });
}
catch (e: any) {
issues.push(`${current.started_at}: ${e.message}`);
}
current = null;
origins = [];
};
for (const e of points) {
const type = e.eventtype === 'START' ? 'work_start' : e.eventtype === 'STOP' || e.eventtype === 'auto_stop' ? 'work_end' : e.eventtype;
const time = new Date(e.eventtime).toISOString();
if (type === 'pause_start' || type === 'pause_end') {
if (!current || current.type !== 'work') {
issues.push(`Pause ohne Anwesenheit: ${e.id}`);
continue;
}
const open = current.breaks.find((p: any) => !p.ended_at);
if (type === 'pause_start' && !open)
current.breaks.push({ started_at: time, ended_at: null });
else if (type === 'pause_end' && open)
open.ended_at = time;
else
issues.push(`Widersprüchliche Pause: ${e.id}`);
origins.push({ kind: 'event', id: e.id });
continue;
}
if (type.endsWith('_start')) {
if (current) {
issues.push(`Neuer Beginn vor vorherigem Ende: ${e.id}`);
close();
}
current = { type: type.slice(0, -6), started_at: time, ended_at: null, description: e.metadata?.description || e.payload?.description || '', breaks: [], projects: [], timezone: 'Europe/Berlin' };
origins = [{ kind: 'event', id: e.id }];
if (e.metadata?.project)
current.projects.push({ project_id: Number(e.metadata.project), started_at: time, ended_at: null, notes: current.description });
}
else if (type.endsWith('_end')) {
if (!current || current.type !== type.slice(0, -4)) {
issues.push(`Ende ohne passenden Beginn: ${e.id}`);
continue;
}
current.ended_at = time;
for (const p of current.breaks)
if (!p.ended_at)
p.ended_at = time;
for (const p of current.projects)
p.ended_at = time;
origins.push({ kind: 'event', id: e.id });
close();
}
else
issues.push(`Unbekanntes Ereignis ${e.eventtype}: ${e.id}`);
}
close();
for (const e of entries.filter(e => !e.archived)) {
try {
const span = normalizeSpan({ ...e, ended_at: e.stopped_at, timezone: 'Europe/Berlin', breaks: [], projects: connects.filter(c => c.time_entry_id === e.id).map(c => ({ ...c, ended_at: c.stopped_at })) });
span.status = e.state === 'draft' ? 'factual' : e.state;
if (!span.ended_at)
issues.push(`Offener Alt-Eintrag ${e.id}; zuerst abschließen.`);
candidates.push({ span, origins: [{ kind: 'entry', id: e.id }] });
}
catch (err: any) {
issues.push(`Alt-Eintrag ${e.id}: ${err.message}`);
}
}
candidates.sort((a, b) => Date.parse(a.span.started_at) - Date.parse(b.span.started_at));
for (let i = 1; i < candidates.length; i++)
if (overlaps(candidates[i - 1].span, candidates[i].span))
issues.push(`Überlappende Alt-Einträge ab ${candidates[i].span.started_at}.`);
const totalMinutes = candidates.reduce((n, { span: s }) => n + (s.type === 'work' && s.ended_at ? (Date.parse(s.ended_at) - Date.parse(s.started_at) - s.breaks.reduce((v, p) => v + (p.ended_at ? Date.parse(p.ended_at) - Date.parse(p.started_at) : 0), 0)) / 60000 : 0), 0);
return { candidates, issues, totalMinutes, count: candidates.length, fingerprint: digest({ events, entries, connects }) };
}
export class TimeMigration {
constructor(public service: TimeService) { }
async preview(a: Actor, user: string, db = this.service.db) {
authorize(a, user, 'activate');
const events = await rows(db, sql `SELECT * FROM staff_time_events WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY event_time,id`);
// Raw SQL column names differ from the historical Drizzle property names.
const mapped = events.map(e => ({ ...e, eventtype: e.event_type, eventtime: e.event_time }));
const entries = await rows(db, sql `SELECT * FROM staff_time_entries WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY started_at,id`);
const connects = await rows(db, sql `SELECT c.* FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE e.tenant_id=${a.tenant} AND e.user_id=${user}::uuid ORDER BY c.started_at,c.id`);
const result = previewLegacy(mapped, entries, connects);
for (const { span } of result.candidates)
for (const project of span.projects) {
if (!(await rows(db, sql `SELECT id FROM projects WHERE id=${project.project_id} AND tenant=${a.tenant}`)).length)
result.issues.push(`Projekt ${project.project_id} fehlt im Mandanten.`);
}
return { ...result, active: await this.service.active(a, user, db) };
}
async activate(a: Actor, user: string, fingerprint: string) {
authorize(a, user, 'activate');
return this.service.db.transaction(async (tx: any) => {
await this.service.lock(tx, a, user);
if (await this.service.active(a, user, tx))
return { active: true, alreadyActivated: true };
const preview = await this.preview(a, user, tx);
if (preview.fingerprint !== fingerprint)
fail('Altdaten wurden geändert. Vorschau erneut laden.', 409);
if (preview.issues.length)
fail('Altdaten enthalten Konflikte. Vor Aktivierung korrigieren.', 409);
for (const c of preview.candidates) {
const created = await this.service.save(tx, a, user, c.span, null, 'migration', 'Übernahme aus bisheriger Zeiterfassung', 'migration');
for (const origin of c.origins)
await tx.execute(sql `INSERT INTO staff_time_span_origins(tenant_id,source_kind,source_id,span_id) VALUES(${a.tenant},${origin.kind},${origin.id}::uuid,${created.id}::uuid)`);
}
await tx.execute(sql `INSERT INTO staff_time_v2_accounts(tenant_id,user_id,activated_by) VALUES(${a.tenant},${user}::uuid,${a.user}::uuid)`);
return { active: true, count: preview.count, totalMinutes: preview.totalMinutes };
});
}
}

View 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 } };
}
}

View File

@@ -1,3 +1,5 @@
import { TimeService } from "../time-v2/service";
import { legacySpans } from "../time-v2/compatibility";
import { FastifyInstance } from "fastify";
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
import {
@@ -13,6 +15,14 @@ export async function generateTimesEvaluation(
startDateInput: string,
endDateInput: string
) {
const service = new TimeService(server.db);
const actor = { tenant: tenant_id, user: user_id };
if (await service.active(actor, user_id)) {
const result = await service.evaluation(actor, user_id, startDateInput, endDateInput);
return { ...result.summary, sumWorkingMinutesEingereicht: result.summary.sumWorkingMinutesSubmitted,
saldo: result.summary.saldoApproved, saldoInOfficial: result.summary.saldoSubmitted,
times: legacySpans(result.spans).map(s => ({ ...s, started_at: s.startedAt, stopped_at: s.endedAt, state: s.status })) };
}
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);

View File

@@ -1,3 +1,6 @@
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { TimeService, digest, rows } from "../../modules/time-v2/service";
import { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm";
import { authProfiles, devices, stafftimeevents } from "../../../db/schema";
@@ -8,7 +11,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
async (req, reply) => {
try {
// 1. Timestamp aus dem Body holen (optional)
const { rfid_id, timestamp } = req.body as {
const { rfid_id, timestamp, request_id } = req.body as {
request_id?: string,
rfid_id: string,
timestamp?: number // Kann undefined sein (Live) oder Zahl (Offline)
};
@@ -51,11 +55,28 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
return reply.code(400).send(`Profile for Token ${rfid_id} not found`);
}
const timeService = new TimeService(server.db);
const actor = { tenant: device.tenant, user: profile.user_id, trusted: true };
if (profile.user_id && await timeService.active(actor, profile.user_id)) {
return await server.db.transaction(async tx => {
await timeService.lock(tx, actor, profile.user_id);
const scoped = new TimeService(tx);
const at = timestamp && timestamp > 0 ? new Date(timestamp * 1000).toISOString() : new Date().toISOString();
const key = `rfid:${digest({ terminal_id, rfid_id, event: request_id || timestamp || randomUUID() })}`;
const [cached] = await rows(tx, sql`SELECT response FROM staff_time_v2_requests WHERE tenant_id=${device.tenant} AND user_id=${profile.user_id}::uuid AND request_id=${key}`);
if (cached) return cached.response;
const current = await scoped.current(actor, profile.user_id);
return scoped.mutate(actor, profile.user_id, key, current
? { action: 'stop', id: current.id, version: current.version, at }
: { action: 'start', at }, 'terminal');
});
}
// 4. Letztes Event suchen (für Status-Toggle Work Start/End)
const lastEvent = await server.db
.select()
.from(stafftimeevents)
.where(eq(stafftimeevents.user_id, profile.user_id))
.where(and(eq(stafftimeevents.user_id, profile.user_id), eq(stafftimeevents.tenant_id, device.tenant)))
.orderBy(desc(stafftimeevents.eventtime))
.limit(1)
.then(rows => rows[0]);

View File

@@ -1,3 +1,4 @@
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
@@ -18,6 +19,7 @@ import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
installTimeCompatibility(server, true);
server.post("/staff/time/event", async (req, reply) => {

View File

@@ -0,0 +1,62 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { sql } from 'drizzle-orm';
import { Actor, can, rows, TimeService } from '../../modules/time-v2/service';
import { TimeMigration } from '../../modules/time-v2/migration';
import { fail, range } from '../../modules/time-v2/domain';
export function timeActor(req: FastifyRequest): Actor {
return { tenant: req.user.tenant_id!, user: req.user.user_id, admin: req.user.is_admin, permissions: req.permissions };
}
export function uuid(value: unknown): string {
if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
fail('Ungültige ID.');
return value as string;
}
export default async function timeV2Routes(server: FastifyInstance) {
const service = new TimeService(server.db), migration = new TimeMigration(service);
server.setErrorHandler((error: any, req, reply) => {
if (error.statusCode && error.statusCode < 500)
return reply.code(error.statusCode).send({ error: error.message });
req.log.error(error);
return reply.code(500).send({ error: 'Zeiterfassung konnte nicht verarbeitet werden.' });
});
server.get('/v2/time/context', async (req) => {
const a = timeActor(req);
const all = can(a, 'staff.time.read_all') || can(a, 'staff.time.write_all') || can(a, 'staff.time.approve') || can(a, 'staff.time.activate');
const profiles = await rows(server.db, sql `SELECT p.user_id,p.full_name,a.activated_at FROM auth_profiles p
LEFT JOIN staff_time_v2_accounts a ON a.tenant_id=p.tenant_id AND a.user_id=p.user_id
WHERE p.tenant_id=${a.tenant} AND p.user_id IS NOT NULL ${all ? sql `` : sql `AND p.user_id=${a.user}::uuid`} ORDER BY p.full_name`);
const projects = await rows(server.db, sql `SELECT id,name FROM projects WHERE tenant=${a.tenant} AND NOT archived ORDER BY name`);
return { profiles, projects, permissions: { readAll: all, writeAll: can(a, 'staff.time.write_all'), approve: can(a, 'staff.time.approve'), activate: can(a, 'staff.time.activate') } };
});
server.get('/v2/time/spans', async (req) => {
const q = req.query as any, a = timeActor(req), user = uuid(q.user_id || a.user);
if (!q.from || !q.to)
fail('Zeitraum erforderlich.');
range(q.from, q.to);
const items = await service.list(a, user, q.from, q.to);
const active = await service.current(a, user);
return { items, active, account: await service.active(a, user) };
});
server.get('/v2/time/evaluation', async (req) => {
const q = req.query as any, a = timeActor(req);
range(q.from, q.to);
return service.evaluation(a, uuid(q.user_id || a.user), q.from, q.to);
});
server.get('/v2/time/spans/:id/history', req => service.history(timeActor(req), uuid((req.params as any).id)));
server.post('/v2/time/commands', async (req) => {
const b = req.body as any, a = timeActor(req);
if (!b || typeof b.command !== 'object' || !b.command)
fail('Aktion erforderlich.');
if (b.command.id)
uuid(b.command.id);
return service.mutate(a, uuid(b.user_id || a.user), b.request_id, b.command);
});
server.get('/v2/time/migration', req => {
const a = timeActor(req);
return migration.preview(a, uuid((req.query as any).user_id || a.user));
});
server.post('/v2/time/activate', req => {
const b = req.body as any, a = timeActor(req);
return migration.activate(a, uuid(b?.user_id || a.user), b?.fingerprint);
});
}

View File

@@ -1,3 +1,4 @@
import { installTimeCompatibility } from "../../modules/time-v2/compatibility";
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
@@ -18,6 +19,7 @@ import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutes(server: FastifyInstance) {
installTimeCompatibility(server);
server.post("/staff/time/event", async (req, reply) => {

View File

@@ -1,9 +1,31 @@
import { sql } from "drizzle-orm";
import { authorize, rows, TimeService } from "../../modules/time-v2/service";
import { timeActor, uuid } from "./time-v2";
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
import { asc, eq } from "drizzle-orm";
import { stafftimenetryconnects } from "../../../db/schema";
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
server.addHook('preHandler', async (req, reply) => {
try {
const a = timeActor(req), params = req.params as any;
const id = uuid(params.id || params.connectId);
const [owner] = await rows(server.db, params.id
? sql`SELECT e.user_id,e.id FROM staff_time_entries e WHERE e.id=${id}::uuid AND e.tenant_id=${a.tenant}`
: sql`SELECT e.user_id,e.id FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE c.id=${id}::uuid AND e.tenant_id=${a.tenant}`);
if (!owner) return reply.code(404).send({error:'Zeiteintrag nicht gefunden.'});
authorize(a,owner.user_id,req.method === 'GET' ? 'read' : 'write');
const service = new TimeService(server.db);
if (await service.active(a,owner.user_id)) {
if (req.method !== 'GET') return reply.code(409).send({error:'Projektzeiten unter 2.0 → Zeiterfassung bearbeiten.'});
const [origin] = await rows(server.db,sql`SELECT span_id FROM staff_time_span_origins WHERE tenant_id=${a.tenant} AND source_kind='entry' AND source_id=${owner.id}::uuid`);
const span = origin ? await service.get(a,origin.span_id) : null;
return reply.send((span?.projects || []).map(p => ({...p,stopped_at:p.ended_at})));
}
} catch (e:any) { return reply.code(e.statusCode || 400).send({error:e.message}); }
});
// ▶ Connect anlegen
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
@@ -48,7 +70,8 @@ export default async function staffTimeConnectRoutes(server: FastifyInstance) {
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const patchData = { ...req.body } as any
const patchData = Object.fromEntries(Object.entries(req.body).filter(([key]) =>
['started_at', 'stopped_at', 'project_id', 'notes'].includes(key))) as any
if (patchData.started_at) patchData.started_at = new Date(patchData.started_at)
if (patchData.stopped_at) patchData.stopped_at = new Date(patchData.stopped_at)
if (patchData.project_id !== undefined) {

View File

@@ -302,6 +302,20 @@ export const buildTenantFullExport = async (
addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
}
// Span children have no tenant column; include them through their scoped parent IDs.
const timeSpanIds = collectIds(tables.staff_time_spans || [], "id")
if (timeSpanIds.length) {
for (const table of ["staff_time_span_breaks", "staff_time_span_projects", "staff_time_span_history"]) {
if (columnsByTable.has(table)) {
addRows(tables, table, await loadRows(client, table, "span_id = any($1::uuid[])", [timeSpanIds]))
}
}
}
const legacyTimeIds = collectIds(tables.staff_time_entries || [], "id")
if (legacyTimeIds.length && columnsByTable.has("staff_time_entry_connects")) {
addRows(tables, "staff_time_entry_connects", await loadRows(client, "staff_time_entry_connects", "time_entry_id = any($1::uuid[])", [legacyTimeIds]))
}
const communicationRoomIds = collectIds(tables.communication_rooms || [], "id")
if (communicationRoomIds.length) {
addRows(tables, "communication_room_members", await loadRows(client, "communication_room_members", "room_id = any($1::uuid[])", [communicationRoomIds]))

View File

@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import Fastify from 'fastify';
import { normalizeSpan, evaluate, range, transition, overlaps } from '../src/modules/time-v2/domain';
import { TimeService, authorize } from '../src/modules/time-v2/service';
import { TimeMigration, previewLegacy } from '../src/modules/time-v2/migration';
import { legacySpans, installTimeCompatibility, legacyEvent } from '../src/modules/time-v2/compatibility';
import timeV2Routes from '../src/routes/staff/time-v2';
import { timeSpanInput } from '../../frontend/utils/timeSpanInput';
import { staffTimeRows, workingMinutes } from '../../frontend/utils/staffTimeSpans';
const at = (day: string, time: string) => `${day}T${time}:00Z`;
const work = (start = '2026-10-01T06:00:00Z', end: string | null = '2026-10-01T14:30:00Z') => normalizeSpan({ type: 'work', started_at: start, ended_at: end,
breaks: end ? [{ started_at: '2026-10-01T10:00:00Z', ended_at: '2026-10-01T10:30:00Z' }] : [] });
const profile = { weekly_regular_working_hours: { 1: 8, 2: 8, 3: 8, 4: 8, 5: 8 }, recreation_days_compensation: true };
test('attendance subtracts breaks and clips both at month boundaries', () => {
const s = work();
s.status = 'approved';
const summary = evaluate([s], profile, [], '2026-10-01', '2026-10-01');
assert.equal(summary.presenceMinutes, 510);
assert.equal(summary.breakMinutes, 30);
assert.equal(summary.sumWorkingMinutesApproved, 480);
assert.equal(summary.saldoApproved, 0);
const night = normalizeSpan({ type: 'work', started_at: '2026-09-30T21:00:00Z', ended_at: '2026-10-01T02:00:00Z', breaks: [{ started_at: '2026-09-30T21:45:00Z', ended_at: '2026-09-30T22:15:00Z' }] });
night.status = 'approved';
assert.equal(evaluate([night], profile, [], '2026-10-01', '2026-10-01').sumWorkingMinutesApproved, 225);
});
test('DST uses elapsed instants; all-day absence uses working days and avoids holiday double credit', () => {
const dst = normalizeSpan({ type: 'work', started_at: '2026-10-25T00:00:00Z', ended_at: '2026-10-25T03:00:00Z' });
dst.status = 'approved';
assert.equal(evaluate([dst], profile, [], '2026-10-25', '2026-10-25').sumWorkingMinutesApproved, 180);
const vacation = normalizeSpan({ type: 'vacation', date_from: '2026-10-01', date_to: '2026-10-06' });
vacation.status = 'approved';
const r = evaluate([vacation], profile, ['2026-10-02', '2026-10-02'], '2026-10-01', '2026-10-05');
assert.equal(r.sumVacationDays, 2);
assert.equal(r.sumWorkingMinutesVacationDays, 960);
assert.equal(r.sumWorkingMinutesRecreationDays, 480);
assert.equal(r.saldoApproved, 0);
const comp = normalizeSpan({ type: 'overtime_compensation', date_from: '2026-10-01', date_to: '2026-10-02' });
comp.status = 'approved';
assert.equal(evaluate([comp], profile, [], '2026-10-01', '2026-10-01').saldoApproved, -480);
assert.throws(() => range('2026-02-30', '2026-03-01'));
});
test('reject invalid intervals, pause collisions and projects inside breaks; allow adjacent attendances', () => {
assert.throws(() => normalizeSpan({ type: 'work', started_at: at('2026-10-01', '08:00'), ended_at: at('2026-10-01', '07:00') }));
const s = work();
assert.throws(() => normalizeSpan({ ...s, breaks: [...s.breaks, { started_at: '2026-10-01T10:15:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
assert.throws(() => normalizeSpan({ ...s, projects: [{ project_id: 1, started_at: '2026-10-01T09:00:00Z', ended_at: '2026-10-01T11:00:00Z' }] }));
assert.equal(overlaps(s, { started_at: s.ended_at!, ended_at: null }), false);
assert.throws(() => transition(work(undefined, null), 'submit'));
assert.throws(() => transition(s, 'approve'));
s.status = 'submitted';
assert.throws(() => transition(s, 'reject', ''));
assert.equal(transition(s, 'reject', 'Korrektur'), 'rejected');
});
test('migration preserves the overtime type and flags missing boundaries, mixed approvals and duplicate models', () => {
const user = randomUUID();
const events = [{ id: 'a', eventtype: 'overtime_compensation_start', eventtime: at('2026-10-01', '06:00') }, { id: 'b', eventtype: 'overtime_compensation_end', eventtime: at('2026-10-01', '14:00') }];
let p = previewLegacy(events, [], []);
assert.equal(p.issues.length, 0);
assert.equal(p.candidates[0].span.type, 'overtime_compensation');
p = previewLegacy(events.slice(0, 1), [], []);
assert.match(p.issues.join(' '), /Offener/);
p = previewLegacy([...events, { id: 'c', eventtype: 'approved', related_event_id: 'a', created_at: at('2026-10-02', '08:00') }], [], []);
assert.match(p.issues.join(' '), /Uneinheitliche/);
p = previewLegacy(events, [{ id: 'd', user_id: user, type: 'work', started_at: events[0].eventtime, stopped_at: events[1].eventtime, state: 'draft' }], []);
assert.match(p.issues.join(' '), /Überlappende/);
});
test('own access and role separation', () => {
const a = { tenant: 1, user: 'a' };
assert.doesNotThrow(() => authorize(a, 'a', 'write'));
assert.throws(() => authorize(a, 'b', 'read'));
assert.throws(() => authorize(a, 'a', 'approve'));
assert.doesNotThrow(() => authorize({ ...a, permissions: ['staff.time.approve'] }, 'b', 'read'));
assert.throws(() => authorize({ ...a, permissions: ['staff.time.read_all'] }, 'b', 'write'));
});
const databaseUrl = process.env.TIME_V2_TEST_DATABASE_URL;
async function fixture() {
if (!databaseUrl || new URL(databaseUrl).pathname !== '/time_v2_test')
throw new Error('Use an explicitly designated /time_v2_test database.');
const schema = `time_v2_${randomUUID().replaceAll('-', '')}`;
const admin = new Pool({ connectionString: databaseUrl, max: 1 });
await admin.query(`CREATE SCHEMA ${schema}`);
const pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 });
const db = drizzle(pool);
try {
await pool.query(`CREATE TABLE tenants(id bigint PRIMARY KEY);
CREATE TABLE auth_users(id uuid PRIMARY KEY);
CREATE TABLE auth_profiles(user_id uuid,tenant_id bigint,full_name text,weekly_regular_working_hours jsonb,state_code text,recreation_days_compensation boolean);
CREATE TABLE projects(id bigint PRIMARY KEY,tenant bigint,name text,archived boolean DEFAULT false);
CREATE TABLE holidays(date date,state_code text);
CREATE TABLE staff_time_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,event_type text,event_time timestamptz,created_at timestamptz DEFAULT now(),invalidates_event_id uuid,related_event_id uuid,metadata jsonb);
CREATE TABLE staff_time_entries(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id bigint,user_id uuid,type text,started_at timestamptz,stopped_at timestamptz,state text,description text,archived boolean DEFAULT false);
CREATE TABLE staff_time_entry_connects(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),time_entry_id uuid,project_id bigint,started_at timestamptz,stopped_at timestamptz,notes text);`);
await pool.query(await readFile(new URL('../db/migrations/0080_staff_time_spans.sql', import.meta.url), 'utf8'));
const user = randomUUID(), other = randomUUID(), outsider = randomUUID();
await pool.query('INSERT INTO tenants VALUES (1),(2)');
await pool.query('INSERT INTO auth_users VALUES ($1),($2),($3)', [user, other, outsider]);
await pool.query(`INSERT INTO auth_profiles VALUES ($1,1,'Anna',$4,'DE-NI',true),($2,1,'Ben',$4,'DE-NI',true),($3,2,'Outside',$4,'DE-NI',true)`, [user, other, outsider, JSON.stringify(profile.weekly_regular_working_hours)]);
await pool.query("INSERT INTO projects VALUES (1,1,'Project',false),(2,2,'Other',false)");
const service = new TimeService(db), migration = new TimeMigration(service), actor = { tenant: 1, user, admin: true };
return { pool, db, user, other, outsider, service, migration, actor, close: async () => { await pool.end(); await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } };
}
catch (e) {
await pool.end();
await admin.query(`DROP SCHEMA ${schema} CASCADE`);
await admin.end();
throw e;
}
}
test('PostgreSQL: atomic adoption, idempotence, concurrency, workflow and tenant isolation', { skip: !databaseUrl }, async () => {
const f = await fixture();
const { service: s, migration: m, actor: a, user, pool } = f;
try {
await pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES (1,$1,'work_start','2026-09-01T06:00:00Z'),(1,$1,'pause_start','2026-09-01T10:00:00Z'),(1,$1,'pause_end','2026-09-01T10:30:00Z'),(1,$1,'work_end','2026-09-01T14:30:00Z')`, [user]);
const preview = await m.preview(a, user);
assert.equal(preview.count, 1);
assert.equal(preview.totalMinutes, 480);
await assert.rejects(m.activate(a, user, 'stale'), /Vorschau/);
await m.activate(a, user, preview.fingerprint);
assert.equal((await s.list(a, user)).length, 1);
assert.equal((await m.activate(a, user, preview.fingerprint)).alreadyActivated, true);
await assert.rejects(pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [user]), /2.0/);
const key = randomUUID(), cmd = { action: 'start', at: '2026-10-01T06:00:00Z' };
const [one, two] = await Promise.all([s.mutate(a, user, key, cmd), s.mutate(a, user, key, cmd)]);
assert.equal(one.id, two.id);
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'create', id: one.id, version: one.version, data: work() }), /bestehende ID/);
const concurrent = await Promise.allSettled([s.mutate(a, user, randomUUID(), cmd), s.mutate(a, user, randomUUID(), cmd)]);
assert.equal(concurrent.filter(r => r.status === 'fulfilled').length, 0);
await assert.rejects(s.mutate(a, user, key, { ...cmd, at: '2026-10-01T07:00:00Z' }), /Anfrage-ID/);
await assert.rejects(s.mutate(a, user, randomUUID(), cmd), /überschneidet/);
let item = await s.mutate(a, user, randomUUID(), { action: 'pause', id: one.id, version: one.version, at: '2026-10-01T10:00:00Z' });
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'stop', id: one.id, version: one.version, at: '2026-10-01T10:30:00Z' }), /zwischenzeitlich/);
item = await s.mutate(a, user, randomUUID(), { action: 'stop', id: item.id, version: item.version, at: '2026-10-01T10:30:00Z' });
assert.equal(item.breaks[0].ended_at, item.ended_at);
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.presenceMinutes, 270);
await assert.rejects(s.mutate({ ...a, admin: false }, user, randomUUID(), { action: 'approve', id: item.id, version: item.version }), /Berechtigung/);
item = await s.mutate(a, user, randomUUID(), { action: 'submit', id: item.id, version: item.version });
item = await s.mutate(a, user, randomUUID(), { action: 'approve', id: item.id, version: item.version });
assert.equal((await s.evaluation(a, user, '2026-10-01', '2026-10-01')).summary.sumWorkingMinutesApproved, 240);
const changed = await s.mutate(a, user, randomUUID(), { action: 'update', id: item.id, version: item.version, reason: 'Notiz ergänzt', data: { ...item, description: 'Neu' } });
assert.equal(changed.status, 'factual');
assert.equal((await s.history(a, item.id)).length, 6);
await assert.rejects(s.get({ ...a, tenant: 2, user: f.outsider }, item.id), /nicht gefunden/);
await assert.rejects(s.mutate(a, user, randomUUID(), { action: 'update', id: changed.id, version: changed.version, reason: 'Projekt', data: { ...changed, projects: [{ project_id: 2, started_at: '2026-10-01T06:00:00Z', ended_at: '2026-10-01T07:00:00Z' }] } }), /Mandanten/);
assert.equal((await s.get(a, item.id)).version, changed.version);
const compat = legacySpans([changed]);
assert.equal(compat.length, 2);
assert.equal(compat[0].endedAt, '2026-10-01T10:00:00.000Z');
}
finally {
await f.close();
}
});
test('HTTP routes: validation, permissions, compatibility and migration conflicts', { skip: !databaseUrl }, async () => {
const f = await fixture(), app = Fastify();
app.decorate('db', f.db as any);
app.addHook('preHandler', async (req) => { req.user = { user_id: f.user, tenant_id: 1, email: 'test@example.test', is_admin: req.headers['x-admin'] === 'yes' }; req.permissions = []; });
await app.register(timeV2Routes, { prefix: '/api' });
await app.register(async (legacy) => { installTimeCompatibility(legacy); legacy.get('/staff/time/spans', async () => []); legacy.post('/staff/time/event', async () => ({ old: true })); }, { prefix: '/api' });
try {
assert.equal((await app.inject('/api/v2/time/spans?user_id=bad')).statusCode, 400);
assert.equal((await app.inject(`/api/v2/time/spans?user_id=${f.other}&from=2026-10-01&to=2026-10-02`)).statusCode, 403);
assert.equal((await app.inject('/api/v2/time/migration')).statusCode, 403);
const preview = (await app.inject({ url: '/api/v2/time/migration', headers: { 'x-admin': 'yes' } })).json();
assert.equal((await app.inject({ method: 'POST', url: '/api/v2/time/activate', headers: { 'x-admin': 'yes' }, payload: { fingerprint: preview.fingerprint } })).statusCode, 200);
const body = { eventtype: 'work_start', eventtime: '2026-10-01T06:00:00Z', user_id: f.user };
const created = await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body });
assert.equal(created.statusCode, 200, created.body);
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: body })).json().id, created.json().id);
assert.equal((await app.inject('/api/staff/time/spans?from=2026-09-01&to=2026-09-02')).json().length, 1, 'Running attendance survives a different selected month');
const stop = { ...body, eventtype: 'work_end', eventtime: '2026-10-01T08:00:00Z' };
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
assert.equal((await app.inject({ method: 'POST', url: '/api/staff/time/event', payload: stop })).statusCode, 200);
const spans = (await app.inject('/api/staff/time/spans')).json();
assert.equal(spans.length, 1);
assert.equal(spans[0].model, 'spans-v2');
await f.pool.query(`INSERT INTO staff_time_events(tenant_id,user_id,event_type,event_time) VALUES(1,$1,'work_start',now())`, [f.other]);
const p = await f.migration.preview(f.actor, f.other);
assert.ok(p.issues.length);
await assert.rejects(f.migration.activate(f.actor, f.other, p.fingerprint), /Konflikte/);
assert.equal(await f.service.active(f.actor, f.other), null);
}
finally {
await app.close();
await f.close();
}
});
test('editing repeated local times preserves the original instant and rejects ambiguous new inputs', () => {
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T01:15:00.000Z'), '2026-10-25T01:15:00.000Z');
assert.equal(timeSpanInput('2026-10-25T02:15', 'Europe/Berlin', '2026-10-25T00:15:00.000Z'), '2026-10-25T00:15:00.000Z');
assert.throws(() => timeSpanInput('2026-10-25T02:15', 'Europe/Berlin'), /zweimal/);
assert.throws(() => timeSpanInput('2026-03-29T02:15', 'Europe/Berlin'), /existiert nicht/);
assert.equal(timeSpanInput('2026-10-25T01:15', 'UTC'), '2026-10-25T01:15:00.000Z');
});
test('dashboard keeps a paused attendance active and freezes its net working duration', () => {
const s = work(undefined, null); s.id = randomUUID(); s.user_id = randomUUID();
s.breaks = [{started_at:'2026-10-01T10:00:00Z',ended_at:null}];
const entries = staffTimeRows(legacySpans([s]),s.user_id,Date.parse('2026-10-01T11:00:00Z'));
assert.equal(entries.length,1); assert.equal(entries[0].type,'work');
assert.equal((entries[0] as any).paused,true);assert.equal(entries[0].stopped_at,null);
assert.equal(workingMinutes(entries[0],Date.parse('2026-10-01T12:00:00Z')),240);
});