Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
7.8 KiB
TypeScript
254 lines
7.8 KiB
TypeScript
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { redirect } from "next/navigation";
|
||
import { z } from "zod";
|
||
import { prisma } from "@/server/db";
|
||
import { moduleGuard } from "@/server/action-guard";
|
||
import { writeAuditLog } from "@/server/audit";
|
||
|
||
const guard = moduleGuard("measures");
|
||
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(),
|
||
});
|
||
|
||
// Minderung dezimal (0,00–4,00) — Komma-Eingaben werden akzeptiert
|
||
const reduction = z
|
||
.preprocess(
|
||
(v) => (typeof v === "string" ? v.replace(",", ".") : v),
|
||
z.coerce.number().min(0).max(4)
|
||
)
|
||
.transform((v) => Math.round(v * 100) / 100);
|
||
|
||
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, db } = await guard("measure:write");
|
||
|
||
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, db } = await guard("measure:write");
|
||
|
||
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, db } = await guard("measure:write");
|
||
|
||
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, db } = await guard("measure:write");
|
||
|
||
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, db } = await guard("risk:write");
|
||
|
||
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, db } = await guard("risk:write", "measure:write");
|
||
|
||
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, db } = await guard("risk:write");
|
||
|
||
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");
|
||
}
|