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:
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
"User",
|
||||
"Role",
|
||||
"AuditLog",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
|
||||
Reference in New Issue
Block a user