Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"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");
|
||||
}
|
||||
Reference in New Issue
Block a user