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

@@ -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) {