Iteration 3: Risikoanalyse + Menü-Trennung + Prozesskategorie
- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung, Status, Owner- und Prozess-Bezug, n:m betroffene Assets - /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende, Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status - Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen, Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log - Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact Analyse" und "Risikoanalyse" (Modul-Tabs entfernt) - Prozesskategorie (Kernprozess/Managementprozess/Unterstützender Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag - Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten- Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band) - "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken) und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { writeAuditLog } from "@/server/audit";
|
||||
const processSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
|
||||
ownerId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -18,6 +19,7 @@ function parseProcessForm(formData: FormData) {
|
||||
const parsed = processSchema.parse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
category: formData.get("category"),
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const scale = z.coerce.number().int().min(1).max(5);
|
||||
const optionalScale = z
|
||||
.union([z.literal(""), z.literal(null), scale])
|
||||
.transform((v) => (v === "" || v === null ? null : v));
|
||||
|
||||
const riskSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
threat: z.string().trim().max(500).optional(),
|
||||
vulnerability: z.string().trim().max(500).optional(),
|
||||
likelihood: scale,
|
||||
impact: scale,
|
||||
residualLikelihood: optionalScale,
|
||||
residualImpact: optionalScale,
|
||||
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
|
||||
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
|
||||
ownerId: z.string().optional(),
|
||||
processId: z.string().optional(),
|
||||
});
|
||||
|
||||
function parseRiskForm(formData: FormData) {
|
||||
const parsed = riskSchema.parse({
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description") || undefined,
|
||||
threat: formData.get("threat") || undefined,
|
||||
vulnerability: formData.get("vulnerability") || undefined,
|
||||
likelihood: formData.get("likelihood"),
|
||||
impact: formData.get("impact"),
|
||||
residualLikelihood: formData.get("residualLikelihood") ?? "",
|
||||
residualImpact: formData.get("residualImpact") ?? "",
|
||||
treatment: formData.get("treatment"),
|
||||
status: formData.get("status"),
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
processId: formData.get("processId") || undefined,
|
||||
});
|
||||
const residual =
|
||||
parsed.residualLikelihood != null && parsed.residualImpact != null
|
||||
? parsed.residualLikelihood * parsed.residualImpact
|
||||
: null;
|
||||
return {
|
||||
title: parsed.title,
|
||||
description: parsed.description || null,
|
||||
threat: parsed.threat || null,
|
||||
vulnerability: parsed.vulnerability || null,
|
||||
likelihood: parsed.likelihood,
|
||||
impact: parsed.impact,
|
||||
score: parsed.likelihood * parsed.impact,
|
||||
residualLikelihood: parsed.residualLikelihood,
|
||||
residualImpact: parsed.residualImpact,
|
||||
residualScore: residual,
|
||||
treatment: parsed.treatment,
|
||||
status: parsed.status,
|
||||
ownerId: parsed.ownerId || null,
|
||||
processId: parsed.processId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRisk(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const data = parseRiskForm(formData);
|
||||
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
|
||||
const last = await prisma.risk.aggregate({
|
||||
where: { tenantId: session.user.tenantId },
|
||||
_max: { refNo: true },
|
||||
});
|
||||
const risk = await db.risk.create({
|
||||
data: {
|
||||
...data,
|
||||
refNo: (last._max.refNo ?? 0) + 1,
|
||||
tenantId: session.user.tenantId,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "risk",
|
||||
entityId: risk.id,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/risks");
|
||||
redirect(`/risks?edit=${risk.id}`);
|
||||
}
|
||||
|
||||
export async function updateRisk(riskId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.risk.findUnique({ where: { id: riskId } });
|
||||
if (!before) throw new Error("Risiko nicht gefunden");
|
||||
|
||||
const data = parseRiskForm(formData);
|
||||
await db.risk.update({ where: { id: riskId }, data });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "risk",
|
||||
entityId: riskId,
|
||||
before,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/risks");
|
||||
redirect(`/risks?detail=${riskId}`);
|
||||
}
|
||||
|
||||
export async function deleteRisk(riskId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.risk.findUnique({ where: { id: riskId } });
|
||||
if (!before) throw new Error("Risiko nicht gefunden");
|
||||
|
||||
await db.risk.delete({ where: { id: riskId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "risk",
|
||||
entityId: riskId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/risks");
|
||||
redirect("/risks");
|
||||
}
|
||||
|
||||
export async function addRiskAsset(riskId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const assetId = z.string().min(1).parse(formData.get("assetId"));
|
||||
const assetCount = await db.asset.count({ where: { id: assetId } });
|
||||
const riskCount = await db.risk.count({ where: { id: riskId } });
|
||||
if (assetCount !== 1 || riskCount !== 1) throw new Error("Nicht gefunden");
|
||||
|
||||
await db.riskAsset.upsert({
|
||||
where: { riskId_assetId: { riskId, assetId } },
|
||||
update: {},
|
||||
create: { riskId, assetId, tenantId: session.user.tenantId },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "risk_asset",
|
||||
entityId: riskId,
|
||||
after: { riskId, assetId },
|
||||
});
|
||||
revalidatePath("/risks");
|
||||
}
|
||||
|
||||
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
|
||||
if (!before) return;
|
||||
await db.riskAsset.delete({ where: { id: riskAssetId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "risk_asset",
|
||||
entityId: riskAssetId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/risks");
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user