Freigabe-Workflow + Aufgaben-Modul + Dashboard-Kachel

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:41:22 +02:00
co-authored by Claude Opus 4.8
parent 73c98137ea
commit 1d8f56343d
15 changed files with 397 additions and 67 deletions
+34 -28
View File
@@ -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`);
}
+78
View File
@@ -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");
}
+2
View File
@@ -28,6 +28,8 @@ const TENANT_MODELS = new Set<string>([
"User",
"Role",
"AuditLog",
"Task",
"TaskComment",
"TenantSettings",
"TenantModule",
"Asset",