feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
93
backend/db/migrations/0080_staff_time_spans.sql
Normal file
93
backend/db/migrations/0080_staff_time_spans.sql
Normal 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();
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -105,3 +105,4 @@ export * from "./scanner_inbox"
|
||||
export * from "./collaboration";
|
||||
|
||||
export * from "./collaboration_federation"
|
||||
export * from './staff_time_spans'
|
||||
|
||||
48
backend/db/schema/staff_time_spans.ts
Normal file
48
backend/db/schema/staff_time_spans.ts
Normal 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] })]);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
|
||||
108
backend/src/modules/time-v2/compatibility.ts
Normal file
108
backend/src/modules/time-v2/compatibility.ts
Normal 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.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
167
backend/src/modules/time-v2/domain.ts
Normal file
167
backend/src/modules/time-v2/domain.ts
Normal 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;
|
||||
}
|
||||
146
backend/src/modules/time-v2/migration.ts
Normal file
146
backend/src/modules/time-v2/migration.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
62
backend/src/routes/staff/time-v2.ts
Normal file
62
backend/src/routes/staff/time-v2.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]))
|
||||
|
||||
207
backend/tests/timeV2.test.ts
Normal file
207
backend/tests/timeV2.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user