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 === "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",