From 1d8f56343d04a1199355006c89863f80f2f398f4 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 22 Jul 2026 11:41:22 +0200 Subject: [PATCH] Freigabe-Workflow + Aufgaben-Modul + Dashboard-Kachel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konzept (abgestimmt): Beim Einreichen wählt der Einreicher einen konkreten Freigeber; die Freigabe/Ablehnung erfolgt im neuen Aufgaben-Modul (Vier-Augen). - Modell: generisches Task + TaskComment (Migration inkl. RLS; Task/TaskComment in TENANT_MODELS). Erster Typ policy_approval. Neues Modul „tasks" (lib/modules, Nav, i18n) — für Bestands-Tenants per Default aktiv. - policies.ts: submitForApproval(code, formData) verlangt einen Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher), setzt IN_FREIGABE und erzeugt eine Aufgabe; Resubmit schließt alte offene Aufgaben. Alte approvePolicy/rejectPolicy entfernt (wandern ins Aufgaben-Modul). - actions/tasks.ts: approveTask/rejectTask (nur zugewiesener Freigeber, ≠ Einreicher; Richtlinie → FREIGEGEBEN bzw. zurück auf ENTWURF) und commentTask (nur Beteiligte); Kommentare/Statuswechsel historisiert, Audit. - /tasks: Aufgaben des Nutzers (offen/erledigt) mit Freigeben/Ablehnen (Grund erforderlich)/Kommentieren + Verlauf. Richtlinien-Editor: Freigeber-Dropdown beim Einreichen, „Zur Freigabe bei …" + Link zur Aufgabe (keine Inline-Freigabe mehr). - Dashboard: Kachel „N offene Aufgabe(n)" verlinkt auf /tasks. - Seed: zweiter ISB „Bea Approver" als Freigeber (ermöglicht den Vier-Augen-Flow). Browser-verifiziert: R08 eingereicht (Freigeber Bea) → Aufgabe; Dashboard-Kachel bei Bea; Freigeben in /tasks → R08 FREIGEGEBEN, Task DONE, Kommentar-Historie (submit+approve). tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 --- messages/de.json | 1 + messages/en.json | 1 + .../20260722092625_tasks/migration.sql | 56 +++++++++ prisma/schema.prisma | 41 ++++++ prisma/seed.ts | 2 + scripts/check-module-guards.ts | 1 + src/app/(app)/dashboard/page.tsx | 18 ++- src/app/(app)/layout.tsx | 2 + src/app/(app)/policies/[code]/edit/page.tsx | 74 ++++++----- src/app/(app)/tasks/layout.tsx | 7 ++ src/app/(app)/tasks/page.tsx | 118 ++++++++++++++++++ src/lib/modules.ts | 1 + src/server/actions/policies.ts | 62 ++++----- src/server/actions/tasks.ts | 78 ++++++++++++ src/server/db.ts | 2 + 15 files changed, 397 insertions(+), 67 deletions(-) create mode 100644 prisma/migrations/20260722092625_tasks/migration.sql create mode 100644 src/app/(app)/tasks/layout.tsx create mode 100644 src/app/(app)/tasks/page.tsx create mode 100644 src/server/actions/tasks.ts diff --git a/messages/de.json b/messages/de.json index bbd6239..1a66bfc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -22,6 +22,7 @@ "risks": "Risikoanalyse", "soa": "SoA & Controls", "measures": "Maßnahmen", + "tasks": "Aufgaben", "incidents": "Vorfälle", "policies": "Richtlinien", "chat": "ISMS-Chat", diff --git a/messages/en.json b/messages/en.json index f4f0963..b288d10 100644 --- a/messages/en.json +++ b/messages/en.json @@ -22,6 +22,7 @@ "risks": "Risk analysis", "soa": "SoA & controls", "measures": "Measures", + "tasks": "Tasks", "incidents": "Incidents", "policies": "Policies", "chat": "ISMS chat", diff --git a/prisma/migrations/20260722092625_tasks/migration.sql b/prisma/migrations/20260722092625_tasks/migration.sql new file mode 100644 index 0000000..0fca78d --- /dev/null +++ b/prisma/migrations/20260722092625_tasks/migration.sql @@ -0,0 +1,56 @@ +-- CreateTable +CREATE TABLE "tasks" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'OPEN', + "entity_type" TEXT, + "entity_id" TEXT, + "entity_ref" TEXT, + "assignee_id" TEXT, + "created_by_id" TEXT, + "due_date" TIMESTAMP(3), + "resolved_by_id" TEXT, + "resolved_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_comments" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "author_id" TEXT, + "kind" TEXT NOT NULL DEFAULT 'comment', + "body" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "task_comments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "tasks_tenant_id_status_idx" ON "tasks"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "tasks_tenant_id_assignee_id_status_idx" ON "tasks"("tenant_id", "assignee_id", "status"); + +-- CreateIndex +CREATE INDEX "task_comments_tenant_id_task_id_idx" ON "task_comments"("tenant_id", "task_id"); + +-- AddForeignKey +ALTER TABLE "task_comments" ADD CONSTRAINT "task_comments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- RLS für die neuen mandantenbezogenen Tabellen (zweite Verteidigungslinie). +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['tasks','task_comments'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))', t); + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4330f8e..11d748f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -746,6 +746,47 @@ model AuditLog { @@map("audit_logs") } +// Generisches Aufgaben-/Freigabe-Modell (erweiterbar). Erster Typ: policy_approval +// (Richtlinien-Freigabe an eine konkrete Person). Kommentare/Statuswechsel historisiert. +model Task { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + type String // policy_approval | … + title String + status String @default("OPEN") // OPEN | DONE | REJECTED | CANCELLED + entityType String? @map("entity_type") // z. B. policy_document + entityId String? @map("entity_id") + entityRef String? @map("entity_ref") // Deep-Link-Referenz, z. B. Policy-Code + assigneeId String? @map("assignee_id") // wer bearbeiten/freigeben soll + createdById String? @map("created_by_id") + dueDate DateTime? @map("due_date") + resolvedById String? @map("resolved_by_id") + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + comments TaskComment[] + + @@index([tenantId, status]) + @@index([tenantId, assigneeId, status]) + @@map("tasks") +} + +model TaskComment { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + taskId String @map("task_id") + authorId String? @map("author_id") + kind String @default("comment") // comment | approve | reject | submit + body String + createdAt DateTime @default(now()) @map("created_at") + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@index([tenantId, taskId]) + @@map("task_comments") +} + // ── Richtlinien & Verfahren (VDA-ISA 2027 Vorlagenpaket) ────────────────────── // Dokumente werden aus ihrer echten Markdown-Vorlage gerendert; Variablen, // Feature-Flags und Baseline-Parameter sind die eine Pflegestelle (§7). diff --git a/prisma/seed.ts b/prisma/seed.ts index 03a21ea..87c1aa2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -102,6 +102,8 @@ async function main() { const passwordHash = await hash(DEMO_PASSWORD); const demoUsers: { email: string; name: string; roles: string[] }[] = [ { email: "admin@demo.example", name: "Anna Admin", roles: ["tenant-admin", "isb"] }, + // Zweiter ISB als Freigeber — ermöglicht den Vier-Augen-Freigabe-Workflow (≠ Einreicher). + { email: "bea.approver@demo.example", name: "Bea Approver", roles: ["isb"] }, { email: "auditor@demo.example", name: "Axel Auditor", roles: ["auditor"] }, { email: "owner@demo.example", name: "Oskar Owner", roles: ["owner"] }, { email: "user@demo.example", name: "Ulla User", roles: ["user"] }, diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 9aa6f5a..4ab0db0 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -26,6 +26,7 @@ const ACTION_MODULE: Record = { "processes.ts": "bia", "risks.ts": "risk", "measures.ts": "measures", + "tasks.ts": "tasks", "suppliers.ts": "suppliers", "services.ts": "suppliers", "policies.ts": "policies", diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index cd2c12c..2f61026 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; +import { ListChecks } from "lucide-react"; import { getFormatter, getTranslations } from "next-intl/server"; import { requireSession } from "@/server/auth"; import { dbForTenant } from "@/server/db"; @@ -9,11 +11,12 @@ export default async function DashboardPage() { const format = await getFormatter(); const db = dbForTenant(session.user.tenantId); - const [assetCount, processCount, criticalCount, openRisks, activities] = await Promise.all([ + const [assetCount, processCount, criticalCount, openRisks, myOpenTasks, activities] = await Promise.all([ db.asset.count(), db.process.count(), db.biaEntry.count({ where: { criticality: { gte: 3 } } }), db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }), + db.task.count({ where: { assigneeId: session.user.id, status: "OPEN" } }), db.auditLog.findMany({ orderBy: { createdAt: "desc" }, take: 6, @@ -44,6 +47,19 @@ export default async function DashboardPage() { {t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}

+ {myOpenTasks > 0 && ( + +
+
+
+

{myOpenTasks} offene Aufgabe{myOpenTasks === 1 ? "" : "n"}

+

Freigaben, die auf Sie warten.

+
+
+ Zu den Aufgaben → + + )} +
{kpis.map((kpi) => ( diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index dfdaa63..b14118d 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { ShieldAlert, ClipboardCheck, KanbanSquare, + ListChecks, Siren, BookOpenText, MessagesSquare, @@ -63,6 +64,7 @@ export default async function AppLayout({ { href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true }, { href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false }, { href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true }, + { href: "/tasks", label: t("tasks"), icon: ListChecks, enabled: true }, { href: "/incidents", label: t("incidents"), icon: Siren, enabled: false }, { href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true }, { href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false }, diff --git a/src/app/(app)/policies/[code]/edit/page.tsx b/src/app/(app)/policies/[code]/edit/page.tsx index fbf247b..cf36a93 100644 --- a/src/app/(app)/policies/[code]/edit/page.tsx +++ b/src/app/(app)/policies/[code]/edit/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { ArrowLeft, Check, Send, X } from "lucide-react"; +import { ArrowLeft, Send } from "lucide-react"; import { requireSession } from "@/server/auth"; import { dbForTenant } from "@/server/db"; import { hasPermission, requirePermission } from "@/server/rbac"; @@ -12,7 +12,7 @@ import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitP import { isCentralVariable } from "@/lib/policy-variables"; import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals"; import { PolicyExpertEditor } from "@/components/policy-expert-editor"; -import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies"; +import { submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies"; const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]); @@ -59,6 +59,15 @@ export default async function PolicyEditPage({ ]); const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—"; + // Freigeber-Auswahl: aktive Nutzer mit Freigaberecht (≠ Einreicher). + const approvers = await db.user.findMany({ + where: { status: "ACTIVE", id: { not: session.user.id }, userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } }, + select: { id: true, name: true }, orderBy: { name: "asc" }, + }); + const openTask = doc.status === "IN_FREIGABE" + ? await db.task.findFirst({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, select: { assigneeId: true } }) + : null; + const usedKeys = docVariableKeys(doc.rawMarkdown, baseline); // Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden. const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v)); @@ -73,9 +82,6 @@ export default async function PolicyEditPage({ const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline }); const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false }); - const canApprove = hasPermission(session, "policy:approve"); - const isSubmitter = doc.submittedBy === session.user.id; - return (
@@ -142,47 +148,39 @@ export default async function PolicyEditPage({ {/* Rechte Leiste: Freigabe + Variablen */}
- {/* Freigabe-Workflow (Vier-Augen, §8) */} + {/* Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe */}

{t("approval")}

- {doc.status === "ENTWURF" && ( -
- -
+ {(doc.status === "ENTWURF" || doc.status === "FREIGEGEBEN") && ( + approvers.length === 0 ? ( +

+ Kein anderer Nutzer mit Freigaberecht vorhanden. Bitte zuerst einen Freigeber (Rolle mit „policy:approve“) anlegen. +

+ ) : ( +
+ {doc.status === "FREIGEGEBEN" &&

{t("approvedBy")}: {userName(doc.approvedBy)}

} + + + + +
+ ) )} {doc.status === "IN_FREIGABE" && ( - <> +

{t("submittedBy")}: {userName(doc.submittedBy)}

- {canApprove && !isSubmitter ? ( - <> -
- -
-
- - -
- - ) : ( -

- {isSubmitter ? t("fourEyesSelf") : t("fourEyesNoRight")} -

- )} - - )} - {doc.status === "FREIGEGEBEN" && ( - <> -

- {t("approvedBy")}: {userName(doc.approvedBy)} -

-
- -
- +

Zur Freigabe bei: {userName(openTask?.assigneeId ?? null)}

+ Zur Aufgabe → +
)}
-

{t("approvalNote")}

+

Freigabe/Ablehnung erfolgen im Bereich „Aufgaben“ durch den gewählten Freigeber (Vier-Augen).

{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */} diff --git a/src/app/(app)/tasks/layout.tsx b/src/app/(app)/tasks/layout.tsx new file mode 100644 index 0000000..16dd9e1 --- /dev/null +++ b/src/app/(app)/tasks/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Serverseitige Modul-Durchsetzung für den Aufgaben-Bereich (§3.4). */ +export default async function ModuleLayout({ children }: { children: React.ReactNode }) { + await requireModule("tasks"); + return <>{children}; +} diff --git a/src/app/(app)/tasks/page.tsx b/src/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..91cf1a6 --- /dev/null +++ b/src/app/(app)/tasks/page.tsx @@ -0,0 +1,118 @@ +import Link from "next/link"; +import { Check, X } from "lucide-react"; +import { requireSession } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { approveTask, rejectTask, commentTask } from "@/server/actions/tasks"; + +const STATUS_TONE: Record = { OPEN: "info", DONE: "ok", REJECTED: "warn", CANCELLED: "mut" }; +const STATUS_LABEL: Record = { OPEN: "Offen", DONE: "Freigegeben", REJECTED: "Abgelehnt", CANCELLED: "Zurückgezogen" }; +const KIND_LABEL: Record = { submit: "eingereicht", approve: "freigegeben", reject: "abgelehnt", comment: "Kommentar" }; + +interface TaskWithComments { + id: string; type: string; title: string; status: string; + entityType: string | null; entityRef: string | null; + assigneeId: string | null; createdById: string | null; createdAt: Date; + comments: { id: string; authorId: string | null; kind: string; body: string; createdAt: Date }[]; +} + +function entityHref(t: TaskWithComments): string | null { + return t.entityType === "policy_document" && t.entityRef ? `/policies/${t.entityRef}` : null; +} + +function TaskCard({ task, me, name }: { task: TaskWithComments; me: string; name: (id: string | null) => string }) { + const isAssignee = task.assigneeId === me; + const isOpen = task.status === "OPEN"; + const href = entityHref(task); + return ( +
+
+
+

+ {href ? {task.title} : task.title} +

+

+ Eingereicht von {name(task.createdById)} · Freigeber {name(task.assigneeId)} · {task.createdAt.toLocaleDateString("de-DE")} +

+
+ {STATUS_LABEL[task.status] ?? task.status} +
+ + {task.comments.length > 0 && ( +
    + {task.comments.map((c) => ( +
  • + {name(c.authorId)} · {KIND_LABEL[c.kind] ?? c.kind}: {c.body} +
  • + ))} +
+ )} + + {isOpen && isAssignee && ( +
+
+ + +
+
+ + +
+
+ )} + {isOpen && !isAssignee && ( +

Wartet auf Freigabe durch {name(task.assigneeId)}.

+ )} + +
+ + +
+
+ ); +} + +export default async function TasksPage() { + const session = await requireSession(); + const db = dbForTenant(session.user.tenantId); + const me = session.user.id; + + const [tasks, users] = await Promise.all([ + db.task.findMany({ + where: { OR: [{ assigneeId: me }, { createdById: me }] }, + include: { comments: { orderBy: { createdAt: "asc" } } }, + orderBy: [{ createdAt: "desc" }], + }), + db.user.findMany({ select: { id: true, name: true } }), + ]); + const name = (id: string | null) => users.find((u) => u.id === id)?.name ?? "—"; + + const open = tasks.filter((t) => t.status === "OPEN"); + const resolved = tasks.filter((t) => t.status !== "OPEN"); + const myOpen = open.filter((t) => t.assigneeId === me); + + return ( +
+ + +
+

Offen

+
+ {open.length === 0 &&

Keine offenen Aufgaben.

} + {open.map((t) => )} +
+
+ + {resolved.length > 0 && ( +
+

Erledigt

+
+ {resolved.map((t) => )} +
+
+ )} +
+ ); +} diff --git a/src/lib/modules.ts b/src/lib/modules.ts index a5213bf..844ca84 100644 --- a/src/lib/modules.ts +++ b/src/lib/modules.ts @@ -16,6 +16,7 @@ export const MODULES: ModuleDef[] = [ { key: "risk", name: "Risikoanalyse", href: "/risks", nav: "risks" }, { key: "soa", name: "SoA & Controls", href: "/soa", nav: "soa" }, { key: "measures", name: "Maßnahmen / Reviews", href: "/measures", nav: "measures" }, + { key: "tasks", name: "Aufgaben & Freigaben", href: "/tasks", nav: "tasks" }, { key: "incidents", name: "Vorfälle", href: "/incidents", nav: "incidents" }, { key: "policies", name: "Richtlinien & Verfahren", href: "/policies", nav: "policies" }, { key: "suppliers", name: "Lieferanten- / IT-Service-Management", href: "/suppliers", nav: "suppliers" }, diff --git a/src/server/actions/policies.ts b/src/server/actions/policies.ts index 1a896d8..e9d70ca 100644 --- a/src/server/actions/policies.ts +++ b/src/server/actions/policies.ts @@ -209,40 +209,46 @@ export async function updateScopedVariables(code: string, formData: FormData) { redirect(`/policies/${encodeURIComponent(code)}/edit`); } -/* ── Freigabe-Workflow (Vier-Augen, §8) ── */ +/* ── Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe ── */ -export async function submitForApproval(code: string) { +/** + * Richtlinie zur Freigabe einreichen: der Einreicher wählt einen konkreten + * Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher). Das Dokument geht + * auf IN_FREIGABE und es entsteht eine Aufgabe (policy_approval) für den Freigeber. + * Freigabe/Ablehnung erfolgen im Aufgaben-Modul (actions/tasks.ts). + */ +export async function submitForApproval(code: string, formData: FormData) { const { session, db } = await guard(); + const approverId = String(formData.get("approverId") ?? "").trim(); + if (!approverId) throw new Error("Bitte einen Freigeber auswählen."); + if (approverId === session.user.id) throw new Error("Vier-Augen-Prinzip: Der Freigeber muss eine andere Person sein."); + const doc = await db.policyDocument.findFirst({ where: { code } }); if (!doc) throw new Error("Dokument nicht gefunden"); + + const approver = await db.user.findFirst({ + where: { id: approverId, status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } }, + select: { id: true }, + }); + if (!approver) throw new Error("Ungültiger Freigeber (kein aktiver Nutzer mit Freigaberecht)."); + + // Vorherige offene Freigabe-Aufgaben dieses Dokuments abschließen (Resubmit). + await db.task.updateMany({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, data: { status: "CANCELLED" } }); + await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } }); - await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE" } }); - revalidatePath("/policies", "layout"); - redirect(`/policies/${encodeURIComponent(code)}/edit`); -} -export async function approvePolicy(code: string) { - const { session, db } = await guard("policy:approve"); - const doc = await db.policyDocument.findFirst({ where: { code } }); - if (!doc) throw new Error("Dokument nicht gefunden"); - if (doc.status !== "IN_FREIGABE") throw new Error("Dokument ist nicht in Freigabe"); - // Vier-Augen: Freigebender ≠ Einreichender - if (doc.submittedBy && doc.submittedBy === session.user.id) { - throw new Error("Vier-Augen-Prinzip: Freigabe muss durch eine andere Person als den Einreichenden erfolgen."); - } - await db.policyDocument.update({ where: { id: doc.id }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() } }); - await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "FREIGEGEBEN" } }); - revalidatePath("/policies", "layout"); - redirect(`/policies/${encodeURIComponent(code)}`); -} - -export async function rejectPolicy(code: string, formData: FormData) { - const { session, db } = await guard("policy:approve"); - const doc = await db.policyDocument.findFirst({ where: { code } }); - if (!doc) throw new Error("Dokument nicht gefunden"); - const reason = str(formData.get("reason")); - await db.policyDocument.update({ where: { id: doc.id }, data: { status: "ENTWURF", submittedBy: null } }); - await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "ENTWURF", rejected: true, reason } }); + const note = str(formData.get("note")); + const task = await db.task.create({ + data: { + tenantId: session.user.tenantId, type: "policy_approval", + title: `Freigabe: ${doc.code} — ${doc.title}`, + entityType: "policy_document", entityId: doc.id, entityRef: doc.code, + assigneeId: approver.id, createdById: session.user.id, status: "OPEN", + comments: note ? { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: note } } : undefined, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE", approverId: approver.id, taskId: task.id } }); revalidatePath("/policies", "layout"); + revalidatePath("/tasks"); redirect(`/policies/${encodeURIComponent(code)}/edit`); } diff --git a/src/server/actions/tasks.ts b/src/server/actions/tasks.ts new file mode 100644 index 0000000..c4c5cb9 --- /dev/null +++ b/src/server/actions/tasks.ts @@ -0,0 +1,78 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { writeAuditLog } from "@/server/audit"; + +/** + * Aufgaben-Modul (generisch). Erster Typ: policy_approval — Freigabe/Ablehnung + * einer Richtlinie durch den zugewiesenen Freigeber. Vier-Augen: der Freigeber + * ist eine andere Person als der Einreicher. Kommentare werden historisiert. + */ +const guard = moduleGuard("tasks"); +const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : ""); + +/** Richtlinie freigeben (nur zugewiesener Freigeber, ≠ Einreicher). */ +export async function approveTask(taskId: string, formData: FormData) { + const { session, db } = await guard("policy:approve"); + const task = await db.task.findUnique({ where: { id: taskId } }); + if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen."); + if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Freigeber kann diese Aufgabe bearbeiten."); + if (task.createdById === session.user.id) throw new Error("Vier-Augen-Prinzip: nicht selbst freigeben."); + + if (task.type === "policy_approval" && task.entityId) { + await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() } }); + } + const note = str(formData.get("note")); + await db.task.update({ + where: { id: taskId }, + data: { + status: "DONE", resolvedById: session.user.id, resolvedAt: new Date(), + comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "approve", body: note || "Freigegeben." } }, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DONE", approved: true } }); + revalidatePath("/tasks"); + revalidatePath("/policies", "layout"); + revalidatePath("/dashboard"); +} + +/** Richtlinie ablehnen (mit Grund) — zurück auf ENTWURF. */ +export async function rejectTask(taskId: string, formData: FormData) { + const { session, db } = await guard("policy:approve"); + const task = await db.task.findUnique({ where: { id: taskId } }); + if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen."); + if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Freigeber kann diese Aufgabe bearbeiten."); + const reason = str(formData.get("note")); + if (!reason) throw new Error("Bitte einen Ablehnungsgrund angeben."); + + if (task.type === "policy_approval" && task.entityId) { + await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "ENTWURF", submittedBy: null } }); + } + await db.task.update({ + where: { id: taskId }, + data: { + status: "REJECTED", resolvedById: session.user.id, resolvedAt: new Date(), + comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } }, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "REJECTED", reason } }); + revalidatePath("/tasks"); + revalidatePath("/policies", "layout"); + revalidatePath("/dashboard"); +} + +/** Kommentar zu einer Aufgabe (nur Beteiligte: Freigeber oder Einreicher). */ +export async function commentTask(taskId: string, formData: FormData) { + const { session, db } = await guard(); + const task = await db.task.findUnique({ where: { id: taskId } }); + if (!task) throw new Error("Aufgabe nicht gefunden."); + if (task.assigneeId !== session.user.id && task.createdById !== session.user.id) { + throw new Error("Nur Beteiligte können kommentieren."); + } + const body = str(formData.get("body")); + if (!body) return; + await db.taskComment.create({ data: { tenantId: session.user.tenantId, taskId, authorId: session.user.id, kind: "comment", body } }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "task_comment", entityId: taskId }); + revalidatePath("/tasks"); +} diff --git a/src/server/db.ts b/src/server/db.ts index b7c9a75..f6b3552 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -28,6 +28,8 @@ const TENANT_MODELS = new Set([ "User", "Role", "AuditLog", + "Task", + "TaskComment", "TenantSettings", "TenantModule", "Asset",