"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"); }