Iteration 4 (Maßnahmen) + Risikoanalyse nachgeschärft

Risikoanalyse:
- Risiko direkt aus dem Asset-Popup erstellbar ("+ Risiko erstellen"
  → Anlege-Popup mit vorverknüpftem Asset)
- Brutto-/Rest-Risiko detailliert: Eintrittswahrscheinlichkeit und
  Schaden als beschriftete Werte (X × Y = Score-Pille)
- Rest-Risiko wird NICHT mehr manuell erfasst, sondern automatisch aus
  den verknüpften Maßnahmen berechnet (Minderung je Dimension, Summe,
  Untergrenze 1; src/server/risk-calc.ts); Neuberechnung bei jeder
  Änderung an Bewertung oder Maßnahmen-Verknüpfungen
- Risikoregister unterhalb der Heatmap in voller Breite

Maßnahmen-Modul (SPEC §4.4):
- Measure/RiskMeasure-Schema mit RLS, laufende Nummer (M-001),
  Status/Priorität/Owner/Fälligkeit
- Kanban-Board mit Drag-and-Drop (dnd-kit): Karten zwischen Offen /
  In Umsetzung / Erledigt verschieben aktualisiert den Status per
  Server-Action inkl. Audit-Log; überfällige Karten rot markiert
- Maßnahmen-Popups (Detail read-only / Bearbeiten / Anlegen) nach
  App-Muster; Detail zeigt verknüpfte Risiken mit Minderung
- Im Risiko-Bearbeiten: bestehende Maßnahme verknüpfen ODER neue
  Maßnahme direkt anlegen & verknüpfen (jeweils mit Minderung
  Wahrscheinlichkeit/Schaden); "Notwendige Maßnahmen" im Risiko-Detail
  jetzt echt
- Seed: 4 Beispiel-Maßnahmen, Rest-Risiken daraus berechnet

Verifiziert im Browser: DnD-Statuswechsel (beide Richtungen, Audit-
Einträge), Rest-Risiko-Berechnung (R-001: 4×5=20 → 2×4=8), Risiko-
Anlage aus dem Asset inkl. automatischer Verknüpfung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 16:07:59 +02:00
co-authored by Claude Fable 5
parent df634e356d
commit 668e587881
20 changed files with 1534 additions and 102 deletions
+261
View File
@@ -0,0 +1,261 @@
"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";
import { recomputeResidualRisk } from "@/server/risk-calc";
const measureSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "DONE"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
ownerId: z.string().optional(),
dueDate: z.string().optional(),
});
const reduction = z.coerce.number().int().min(0).max(4);
function parseMeasureForm(formData: FormData) {
const parsed = measureSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
status: formData.get("status") ?? "OPEN",
priority: formData.get("priority") ?? "MEDIUM",
ownerId: formData.get("ownerId") || undefined,
dueDate: formData.get("dueDate") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
status: parsed.status,
priority: parsed.priority,
ownerId: parsed.ownerId || null,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
};
}
async function nextRefNo(tenantId: string) {
const last = await prisma.measure.aggregate({
where: { tenantId },
_max: { refNo: true },
});
return (last._max.refNo ?? 0) + 1;
}
export async function createMeasure(formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const data = parseMeasureForm(formData);
const measure = await db.measure.create({
data: {
...data,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: data,
});
revalidatePath("/measures");
redirect(`/measures?detail=${measure.id}`);
}
export async function updateMeasure(measureId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before) throw new Error("Maßnahme nicht gefunden");
const data = parseMeasureForm(formData);
await db.measure.update({ where: { id: measureId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before,
after: data,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect(`/measures?detail=${measureId}`);
}
/** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */
export async function updateMeasureStatus(measureId: string, status: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before || before.status === parsed) return;
await db.measure.update({ where: { id: measureId }, data: { status: parsed } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before: { status: before.status },
after: { status: parsed },
});
revalidatePath("/measures");
revalidatePath("/risks");
}
export async function deleteMeasure(measureId: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.measure.findUnique({
where: { id: measureId },
include: { riskMeasures: { select: { riskId: true } } },
});
if (!before) throw new Error("Maßnahme nicht gefunden");
await db.measure.delete({ where: { id: measureId } });
// Rest-Risiken der betroffenen Risiken neu berechnen (Links kaskadiert gelöscht)
for (const rm of before.riskMeasures) {
await recomputeResidualRisk(db, rm.riskId);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "measure",
entityId: measureId,
before,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect("/measures");
}
/** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */
export async function linkMeasureToRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const measureId = z.string().min(1).parse(formData.get("measureId"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const riskCount = await db.risk.count({ where: { id: riskId } });
const measureCount = await db.measure.count({ where: { id: measureId } });
if (riskCount !== 1 || measureCount !== 1) throw new Error("Nicht gefunden");
await db.riskMeasure.upsert({
where: { riskId_measureId: { riskId, measureId } },
update: { reductionLikelihood, reductionImpact },
create: {
riskId,
measureId,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk_measure",
entityId: riskId,
after: { riskId, measureId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
/** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */
export async function createMeasureForRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const riskCount = await db.risk.count({ where: { id: riskId } });
if (riskCount !== 1) throw new Error("Risiko nicht gefunden");
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const measure = await db.measure.create({
data: {
title,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.riskMeasure.create({
data: {
riskId,
measureId: measure.id,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: { title, linkedRisk: riskId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } });
if (!before) return;
await db.riskMeasure.delete({ where: { id: riskMeasureId } });
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_measure",
entityId: riskMeasureId,
before,
});
revalidatePath("/risks");
revalidatePath("/measures");
}
+17 -14
View File
@@ -7,11 +7,9 @@ import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
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),
@@ -20,8 +18,6 @@ const riskSchema = z.object({
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(),
@@ -36,17 +32,11 @@ function parseRiskForm(formData: FormData) {
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,
@@ -55,9 +45,6 @@ function parseRiskForm(formData: FormData) {
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,
@@ -85,6 +72,17 @@ export async function createRisk(formData: FormData) {
},
});
// Optional: direkt aus einem Asset heraus angelegt → Asset sofort verknüpfen
const assetId = formData.get("assetId") as string | null;
if (assetId) {
const assetCount = await db.asset.count({ where: { id: assetId } });
if (assetCount === 1) {
await db.riskAsset.create({
data: { riskId: risk.id, assetId, tenantId: session.user.tenantId },
});
}
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
@@ -94,6 +92,7 @@ export async function createRisk(formData: FormData) {
after: data,
});
revalidatePath("/risks");
revalidatePath("/assets");
redirect(`/risks?edit=${risk.id}`);
}
@@ -107,6 +106,8 @@ export async function updateRisk(riskId: string, formData: FormData) {
const data = parseRiskForm(formData);
await db.risk.update({ where: { id: riskId }, data });
// Brutto-Bewertung geändert → Rest-Risiko aus den Maßnahmen neu ableiten
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
@@ -168,6 +169,7 @@ export async function addRiskAsset(riskId: string, formData: FormData) {
after: { riskId, assetId },
});
revalidatePath("/risks");
revalidatePath("/assets");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
@@ -188,4 +190,5 @@ export async function removeRiskAsset(riskId: string, riskAssetId: string) {
before,
});
revalidatePath("/risks");
revalidatePath("/assets");
}
+2
View File
@@ -35,6 +35,8 @@ const TENANT_MODELS = new Set<string>([
"BiaEntry",
"Risk",
"RiskAsset",
"Measure",
"RiskMeasure",
]);
/**
+38
View File
@@ -0,0 +1,38 @@
import type { TenantDb } from "./db";
/**
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
* Verknüpfung trägt eine erwartete Minderung (Stufen 04) je Dimension;
* die Minderungen werden summiert und von der Brutto-Bewertung abgezogen
* (Untergrenze 1). Ohne Maßnahmen gibt es kein Rest-Risiko (null).
*/
export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
const risk = await db.risk.findUnique({
where: { id: riskId },
include: { riskMeasures: { select: { reductionLikelihood: true, reductionImpact: true } } },
});
if (!risk) return;
if (risk.riskMeasures.length === 0) {
await db.risk.update({
where: { id: riskId },
data: { residualLikelihood: null, residualImpact: null, residualScore: null },
});
return;
}
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
const residualLikelihood = Math.max(1, risk.likelihood - reductionL);
const residualImpact = Math.max(1, risk.impact - reductionI);
await db.risk.update({
where: { id: riskId },
data: {
residualLikelihood,
residualImpact,
residualScore: residualLikelihood * residualImpact,
},
});
}