From 03a2ccab4f8b3b8f6bf953d61786c323d6356ee5 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:20 +0200 Subject: [PATCH] =?UTF-8?q?L12=20Zeiterfassung:=20Datenmodell,=20Rechte=20?= =?UTF-8?q?und=20Events=20f=C3=BCr=20die=20Zeitfreigabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration zeiterfassung_freigabe (TimeEntry source/approvalStatus/pendingChange, WorkSession.manual), Rechte field:record_own_time und time:approve, PII approvedById, Events time.approval_requested/approved/rejected. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 24 +++++++++++++++++++ prisma/schema.prisma | 24 +++++++++++++++++++ src/lib/events.ts | 6 ++++- src/server/dsgvo/pii-fields.ts | 1 + src/server/rbac.ts | 6 +++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql diff --git a/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql new file mode 100644 index 0000000..992aac3 --- /dev/null +++ b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql @@ -0,0 +1,24 @@ +-- L12 Zeiterfassung: approval workflow for manual time entries and correction proposals. +-- Additive columns only (existing rows: tracked + approved = unchanged behaviour). No new table -> no RLS/TENANT_MODELS change. + +-- CreateEnum +CREATE TYPE "TimeEntrySource" AS ENUM ('tracked', 'manual'); + +-- CreateEnum +CREATE TYPE "TimeApprovalStatus" AS ENUM ('approved', 'pending', 'rejected'); + +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "approval_status" "TimeApprovalStatus" NOT NULL DEFAULT 'approved', +ADD COLUMN "approved_at" TIMESTAMP(3), +ADD COLUMN "approved_by_id" TEXT, +ADD COLUMN "note" TEXT, +ADD COLUMN "pending_change" JSONB, +ADD COLUMN "rejection_reason" TEXT, +ADD COLUMN "source" "TimeEntrySource" NOT NULL DEFAULT 'tracked'; + +-- AlterTable +ALTER TABLE "work_sessions" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_approval_status_idx" ON "time_entries"("tenant_id", "approval_status"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc29a6c..b0f5a6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -894,6 +894,8 @@ model WorkSession { startedOffline Boolean @default(false) @map("started_offline") deviceInfo String? @map("device_info") clientId String? @map("client_id") + /// L12: container session for manually recorded entries (one per user + order + day, always `ended`) + manual Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -917,6 +919,17 @@ enum TimeEntryType { interruption } +enum TimeEntrySource { + tracked + manual +} + +enum TimeApprovalStatus { + approved + pending + rejected +} + model TimeEntry { id String @id @default(cuid()) tenantId String @map("tenant_id") @@ -930,6 +943,16 @@ model TimeEntry { correctionReason String? @map("correction_reason") correctedById String? @map("corrected_by_id") clientId String? @map("client_id") + // L12 Zeiterfassung: manual entries and correction proposals need approval; only + // `approvalStatus = approved` counts for reports, totals and billing. + source TimeEntrySource @default(tracked) + approvalStatus TimeApprovalStatus @default(approved) @map("approval_status") + approvedById String? @map("approved_by_id") + approvedAt DateTime? @map("approved_at") + rejectionReason String? @map("rejection_reason") + /// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval + pendingChange Json? @map("pending_change") + note String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -937,6 +960,7 @@ model TimeEntry { @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) + @@index([tenantId, approvalStatus]) @@unique([tenantId, clientId]) @@map("time_entries") } diff --git a/src/lib/events.ts b/src/lib/events.ts index 0a05092..16c2c17 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -19,13 +19,17 @@ export const EVENT_TYPES = [ "import.ready_for_review", "import.failed", "sync.failed", + // L12 Zeiterfassung + "time.approval_requested", + "time.approved", + "time.rejected", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; export type DomainEvent = { type: EventType; - entityType: "work_order" | "report" | "import_job" | "sync_operation"; + entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry"; entityId: string; /** Short, human-readable facts for templates (no PII beyond what the recipient may see). */ data?: Record; diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index d7a1ffe..ed081e2 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -33,6 +33,7 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [ { model: "WorkSession", field: "userId" }, { model: "TimeEntry", field: "userId" }, { model: "TimeEntry", field: "correctedById" }, + { model: "TimeEntry", field: "approvedById" }, // L12: approving/rejecting person { model: "ActivityNote", field: "authorId" }, { model: "Document", field: "uploadedById" }, { model: "Photo", field: "takenById" }, diff --git a/src/server/rbac.ts b/src/server/rbac.ts index 1a26d38..f50e0f3 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -36,6 +36,9 @@ export const PERMISSIONS = [ // Einsatz (Mobile): Einsatz starten, Zeiten, Material, Fotos, Notizen "field:execute", "field:correct_time", + "field:record_own_time", // L12: eigene Zeiten nachtragen / Korrektur vorschlagen (mit Freigabe) + // Zeiterfassung: Nachträge/Korrekturen freigeben (Teamleiter: eigenes Team, Backoffice: alle) + "time:approve", // Berichte "report:read", "report:write", @@ -85,6 +88,8 @@ export const ROLE_DEFS: Record