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:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 0–4) 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user