Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
+9 -174
View File
@@ -1,21 +1,14 @@
"use server";
import { join } from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { Framework } from "@prisma/client";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma, dbForTenant } from "@/server/db";
import { provisionTenant } from "@/server/provision";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { protectionFlags } from "@/server/assessment-level";
import { isModuleKey } from "@/lib/modules";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
const POLICY_SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
/**
* Nur Plattform-**Voll**-Admins (SEC4: Read-only-Admins dürfen Mandanten nicht verändern).
* Getrennte Plattform-Session (kein Mandantenkontext).
@@ -27,7 +20,7 @@ async function requirePlatformAdmin() {
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "kunde";
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "betrieb";
export async function createTenant(formData: FormData) {
const session = await requirePlatformAdmin();
@@ -35,20 +28,10 @@ export async function createTenant(formData: FormData) {
const slug = str(formData.get("slug")) || slugify(name);
const adminEmail = z.string().trim().email().parse(formData.get("adminEmail"));
const adminName = str(formData.get("adminName")) || "Administrator";
// F-20: Initialpasswort gegen die vorhandene Policy prüfen (konsistent zu
// tenant-users.ts/platform-users.ts) statt nur min(8). Der Mandant existiert
// noch nicht, daher der Plattform-Default DEFAULT_PASSWORD_POLICY.
// F-20: Initialpasswort gegen die Plattform-Default-Policy prüfen (der Mandant existiert noch nicht).
const adminPassword = z.string().parse(formData.get("adminPassword"));
const pwMissing = validatePassword(adminPassword, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) throw new Error(`Das Initialpasswort benötigt ${pwMissing.join(", ")}.`);
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
const seedPolicies = formData.get("seedPolicies") === "on";
// AP2: geführte Rahmenwerke aus den Checkboxen (Reihenfolge = Primär zuerst; TISAX
// vor ISO). Nichts gewählt → TISAX (rückwärtskompatibler Default).
const frameworks: Framework[] = [];
if (formData.get("fw_tisax") === "on") frameworks.push("TISAX");
if (formData.get("fw_iso") === "on") frameworks.push("ISO_27001");
if (frameworks.length === 0) frameworks.push("TISAX");
const existing = await prisma.tenant.findUnique({ where: { slug } });
if (existing) throw new Error(`Kürzel „${slug}" ist bereits vergeben.`);
@@ -59,9 +42,6 @@ export async function createTenant(formData: FormData) {
short: str(formData.get("short")) || undefined,
sector: str(formData.get("sector")) || undefined,
admin: { email: adminEmail, name: adminName, password: adminPassword },
tisaxLevel,
frameworks,
seedPoliciesDir: seedPolicies ? join(process.cwd(), "seed", "isms-vorlagenpaket-v2") : undefined,
actorId: session.user.id,
});
@@ -80,27 +60,8 @@ export async function setTenantStatus(tenantId: string, status: "ACTIVE" | "SUSP
}
/**
* Kern-Einstellung (nur Superadmin): Assessment-Level (AL2/AL3) des Mandanten —
* einzige Quelle des Schutzbedarfs (Story A2-1). Spiegelt die daraus abgeleiteten
* zentralen Schutzbedarf-Flags ins Richtlinienmodul (`protectionFlags`).
*/
export async function setTenantTisaxLevel(tenantId: string, level: "AL2" | "AL3") {
const session = await requirePlatformAdmin();
const db = dbForTenant(tenantId);
await db.tenantSettings.update({ where: { tenantId }, data: { tisaxLevel: level } });
const flags = protectionFlags(level); // einzige AL→Flag-Ableitung
await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_HIGH_PROTECTION) } });
await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_VERY_HIGH_PROTECTION) } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "assessment_level", entityId: tenantId, after: { level } },
});
revalidatePath(`/admin/${tenantId}`);
}
/**
* Sprachwahl je Mandant (de|en). Steuert, in welcher Sprache das Vorlagenpaket importiert
* wird (resolvePackageForTenant). Wirkt auf künftige Importe/Updates; bereits importierte
* Richtlinien bleiben unverändert, bis der Mandant unter /policies/updates übernimmt.
* Standardsprache je Mandant (de|en) — Fallback für Benachrichtigungen/Dokumente, wenn
* der Empfänger keine eigene Präferenz hat. Die UI-Sprache wählt jede Person selbst.
*/
export async function setTenantLocale(tenantId: string, locale: string) {
const session = await requirePlatformAdmin();
@@ -114,8 +75,7 @@ export async function setTenantLocale(tenantId: string, locale: string) {
/**
* SEC3-a: MFA-Pflicht je Mandant setzen (Superadmin). Wird in `securityPolicy.mfaRequired`
* abgelegt; das (app)-Layout erzwingt daraufhin die MFA-Einrichtung beim Login. Bestehende
* securityPolicy-Felder (z. B. Passwort-Policy) bleiben erhalten.
* abgelegt; das (app)-Layout erzwingt daraufhin die MFA-Einrichtung beim Login.
*/
export async function setTenantMfaRequired(tenantId: string, required: boolean) {
const session = await requirePlatformAdmin();
@@ -131,6 +91,7 @@ export async function setTenantMfaRequired(tenantId: string, required: boolean)
export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) {
const session = await requirePlatformAdmin();
if (!isModuleKey(moduleKey)) throw new Error(`Unbekanntes Modul „${moduleKey}".`);
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
update: { enabled },
@@ -139,134 +100,8 @@ export async function toggleTenantModule(tenantId: string, moduleKey: string, en
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_module", entityId: moduleKey, after: { enabled } },
});
// Story B4-1: Aktivierung des Richtlinien-Moduls importiert das Vorlagenpaket
// mandantenweit nicht-destruktiv (idempotent; erhält Status/Freigabe/Overrides/Variablen).
if (moduleKey === "policies" && enabled) {
// AP1: je Framework des Mandanten importieren (Anforderungen framework-scoped,
// geteilte Inhalte nur beim ersten). Einzel-Framework = unverändert.
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
revalidatePath("/policies", "layout");
}
revalidatePath(`/admin/${tenantId}`);
// Die Mandanten-Navigation (src/app/(app)/layout.tsx) rendert die Modul-Links pro
// Seitenaufruf server-seitig aus `tenantModule`. Ohne Revalidierung der (app)-Routen
// bliebe der Server-Cache stehen → ein deaktiviertes Modul verschwände erst verzögert
// aus der Sidebar. `layout` invalidiert die gesamte Mandanten-Shell.
// Die Mandanten-Navigation rendert die Modul-Links pro Seitenaufruf aus `tenantModule`;
// `layout` invalidiert die gesamte Mandanten-Shell.
revalidatePath("/", "layout");
}
/**
* Frameworks eines bestehenden Mandanten setzen (ISO 27001 und/oder TISAX).
*
* Bisher wurde die Framework-Zugehörigkeit ausschließlich beim Anlegen vergeben
* (`provisionTenant`); ein Bestandsmandant liess sich weder auf ISO umstellen noch ein
* Framework wieder abschalten. Diese Aktion schliesst beides.
*
* Ablauf:
* 1. Zugehörigkeit setzen — erstes Element der Liste ist das Primär-Framework.
* 2. Neu hinzugekommenes Framework: Vorlagenpaket importieren. Der geteilte
* Dokumentensatz wird dabei einmal mit abgeglichen, damit Anforderungen des neuen
* Frameworks nicht auf Dokumente zeigen, die der Mandant noch nicht hat (z. B. VA-21/22).
* Der Abgleich ist nicht-destruktiv: Status, Freigabe, Overrides und gepflegte
* Variablenwerte bleiben erhalten.
* 3. Entferntes Framework: die Zugehörigkeit fällt weg, die **Bewertungen bleiben**.
* Anforderungen werden über `archivedAt` stillgelegt statt gelöscht — der Nachweis,
* dass in der Vergangenheit geprüft wurde, gehört zum Audit-Trail. Ein späteres
* Wiedereinschalten reaktiviert sie über den Re-Import.
* `SoaEntry` bleibt unangetastet: die SoA-Sicht blendet sich ohne aktives
* ISO-Framework ohnehin aus, und so überlebt jede Begründung eine Pause unverändert.
* 4. Sichtbarkeits-Flags nachziehen — sonst führte der Mandant die Norm im Datenmodell,
* sähe in den Dokumenten aber weiter die Anforderungssicht des anderen Frameworks.
*
* Mindestens ein Framework muss aktiv bleiben; ohne Rahmenwerk hätte der Mandant kein
* Regelwerk mehr.
*/
export async function setTenantFrameworks(tenantId: string, frameworks: Framework[]) {
const session = await requirePlatformAdmin();
const wanted = [...new Set(frameworks)];
if (wanted.length === 0) throw new Error("Mindestens ein Framework muss aktiv bleiben.");
const current = await getTenantFrameworks(prisma, tenantId);
const added = wanted.filter((f) => !current.includes(f));
const removed = current.filter((f) => !wanted.includes(f));
const db = dbForTenant(tenantId);
// 1. Zugehörigkeit (erstes = primär)
for (const [i, framework] of wanted.entries()) {
await prisma.tenantFramework.upsert({
where: { tenantId_framework: { tenantId, framework } },
update: { isPrimary: i === 0 },
create: { tenantId, framework, isPrimary: i === 0 },
});
}
// 2. Hinzugekommene Frameworks bestücken
for (const [i, framework] of added.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
// Geteilte Inhalte einmal mitnehmen: der Dokumentensatz kann älter sein als das Paket.
reconcileShared: i === 0,
});
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
}
// 3. Entfernte Frameworks abmelden — stilllegen, nicht löschen
if (removed.length > 0) {
await prisma.tenantFramework.deleteMany({ where: { tenantId, framework: { in: removed } } });
await db.policyRequirement.updateMany({
where: { framework: { in: removed }, archivedAt: null },
data: { archivedAt: new Date() },
});
}
// 4. Sichtbarkeits-Flags im Richtlinienmodul nachziehen
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_TISAX" },
data: { value: String(wanted.includes("TISAX")) },
});
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_ISO27001" },
data: { value: String(wanted.includes("ISO_27001")) },
});
if (added.length > 0) await deriveDomainsCore(db, tenantId);
await prisma.auditLog.create({
data: {
tenantId, scope: "platform", actorId: session.user.id, action: "update",
entity: "tenant_framework", entityId: tenantId,
before: { frameworks: current }, after: { frameworks: wanted, added, removed },
},
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
revalidatePath("/", "layout");
}
/**
* Story B4-1: Plattform-Admin stößt den Vorlagen-Import/-Aktualisierung für einen
* Mandanten manuell an (Button im Admin). Nicht-destruktiv (siehe importPolicies).
*/
export async function importPolicyPackageForTenant(tenantId: string) {
const session = await requirePlatformAdmin();
// AP1: je Framework des Mandanten (framework-scoped; geteilte Inhalte nur beim ersten).
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
await deriveDomainsCore(dbForTenant(tenantId), tenantId); // Fachbereiche ableiten
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "policy_package", after: { manualImport: true } },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
}
-163
View File
@@ -1,163 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const assetSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"]),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
function parseAssetForm(formData: FormData) {
const parsed = assetSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
return {
...parsed,
ownerId: parsed.ownerId || null,
description: parsed.description || null,
location: parsed.location || null,
tags: parsed.tags
? parsed.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [],
};
}
export async function createAsset(formData: FormData) {
const { session, db } = await guard("asset:write");
const data = parseAssetForm(formData);
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
const asset = await db.asset.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset",
entityId: asset.id,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${asset.id}`);
}
export async function updateAsset(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
const data = parseAssetForm(formData);
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${assetId}`);
}
export async function deleteAsset(assetId: string) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset",
entityId: assetId,
before,
});
revalidatePath("/assets");
redirect("/assets");
}
export async function addAssetRelation(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
if (relatedAssetId === assetId) return;
// Beide Assets müssen zum Mandanten gehören (Guard filtert, count prüft)
const count = await db.asset.count({ where: { id: { in: [assetId, relatedAssetId] } } });
if (count !== 2) throw new Error("Asset nicht gefunden");
await db.assetRelation.upsert({
where: {
assetId_relatedAssetId_type: { assetId, relatedAssetId, type: "depends_on" },
},
update: {},
create: {
assetId,
relatedAssetId,
type: "depends_on",
tenantId: session.user.tenantId,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset_relation",
entityId: assetId,
after: { assetId, relatedAssetId, type: "depends_on" },
});
revalidatePath("/assets");
}
export async function removeAssetRelation(assetId: string, relationId: string) {
const { session, db } = await guard("asset:write");
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
if (!before) return;
await db.assetRelation.delete({ where: { id: relationId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset_relation",
entityId: relationId,
before,
});
revalidatePath("/assets");
}
-314
View File
@@ -1,314 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { loadScopeInput, controlsInScope } from "@/server/soa-context";
import { resolveControlDomain } from "@/server/control-domain";
import { createTask } from "@/server/actions/tasks";
import { storage } from "@/server/storage/adapter";
import { controlTitle } from "@/lib/control-titles";
/**
* Nachweise-Tab der Audit-Vorbereitung (V5B, M4). Erzeugt je In-Scope-Control einen
* bereitzustellenden Nachweis (`AuditEvidenceItem`), ordnet automatisch den fachlich
* zuständigen Ansprechpartner zu, erlaubt das Umhängen, erzeugt Nachweis-Aufgaben
* (`evidence_provide`) und nimmt den direkten Datei-Upload je Item entgegen.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt die Modul-Aktivierung `audit` voraus. Der Wrapper `guard`
* erzwingt zusätzlich `isAuditReadinessEnabled` (Opt-in, Default = aus), analog zu
* `src/server/actions/audit-readiness.ts`.
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Wizards; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const NACHWEISE_PATH = "/audit-readiness/[auditId]/nachweise";
/** Generische Nachweisart, wenn zum Control kein Umsetzungshinweis existiert. */
const GENERIC_EVIDENCE = "Richtlinie/Nachweis der Anwendung";
/** Audit laden + Existenz sichern (db ist mandantengebunden → Fremd-Tenant scheidet aus). */
async function requireAudit(db: TenantDb, auditId: string) {
const audit = await db.audit.findUnique({ where: { id: auditId } });
if (!audit) throw new Error("Audit nicht gefunden.");
return audit;
}
/**
* Ansprechpartner für ein Control ermitteln: Control → Bereich/Funktion
* (`resolveControlDomain`, RESPONSIBLE) → `ProjectFunctionAssignment.userId`.
* Unbesetzte Funktion → userId null (Item bleibt offen).
*/
async function resolveOwnerForControl(
db: TenantDb,
tenantId: string,
control: string,
usersByFunction: Map<string, string>,
): Promise<{ functionKey: string | null; userId: string | null }> {
const { rules } = await resolveControlDomain(db, tenantId, control);
const primary = rules.find((r) => r.raci === "RESPONSIBLE") ?? rules[0];
const functionKey = primary?.functionKey ?? null;
const userId = functionKey ? (usersByFunction.get(functionKey) ?? null) : null;
return { functionKey, userId };
}
/**
* Nachweisliste generieren (idempotent). Für jedes In-Scope-Control wird — falls noch
* nicht vorhanden — ein `AuditEvidenceItem` angelegt. Die Nachweisart stammt aus dem
* Umsetzungshinweis (`ImplementationHint.evidence`, global je Control); fehlt einer,
* greift die generische Nachweisart. Der Ansprechpartner wird automatisch über
* `resolveControlDomain` → Funktion → `ProjectFunctionAssignment` zugeordnet.
*/
export async function generateEvidenceList(auditId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const audit = await requireAudit(db, auditId);
const { input } = await loadScopeInput(db, tenantId);
const controls = controlsInScope(input);
// Bereits vorhandene Items (Idempotenz je Control).
const existing = await db.auditEvidenceItem.findMany({ where: { auditId }, select: { control: true } });
const known = new Set(existing.map((e) => e.control));
// Nachweisart je Control aus den Umsetzungshinweisen (global). Erster Hinweis je
// Control liefert Nachweisart + reqId; mehrere Nachweise werden knapp zusammengeführt.
const hints = await db.implementationHint.findMany({ orderBy: { reqId: "asc" }, select: { control: true, reqId: true, evidence: true } });
const hintByControl = new Map<string, { reqId: string; evidence: string }>();
for (const h of hints) {
if (!hintByControl.has(h.control) && h.evidence.trim()) {
hintByControl.set(h.control, { reqId: h.reqId, evidence: h.evidence.trim() });
}
}
// Funktion → User einmalig laden (db ist tenant-gebunden).
const assignments = await db.projectFunctionAssignment.findMany({ where: { userId: { not: null } }, select: { functionKey: true, userId: true } });
const usersByFunction = new Map<string, string>();
for (const a of assignments) {
if (a.userId && !usersByFunction.has(a.functionKey)) usersByFunction.set(a.functionKey, a.userId);
}
let created = 0;
for (const control of controls) {
if (known.has(control)) continue;
const hint = hintByControl.get(control);
const title = (hint?.evidence ?? GENERIC_EVIDENCE).slice(0, 200);
const { functionKey, userId } = await resolveOwnerForControl(db, tenantId, control, usersByFunction);
await db.auditEvidenceItem.create({
data: {
tenantId,
auditId,
control,
title,
reqId: hint?.reqId ?? null,
assignedFunctionKey: functionKey,
assignedUserId: userId,
status: "offen",
},
});
created++;
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_list", entityId: auditId,
after: { created, total: controls.length, auditTitle: audit.title },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/** Item laden + zum Audit gehörig sichern. */
async function requireItem(db: TenantDb, itemId: string) {
const item = await db.auditEvidenceItem.findUnique({ where: { id: itemId } });
if (!item) throw new Error("Nachweis-Position nicht gefunden.");
return item;
}
/**
* Ansprechpartner eines Nachweis-Items umhängen (Dropdown). Leerer Wert → offen lassen.
* Der neue Ansprechpartner muss ein aktiver Nutzer desselben Mandanten sein.
*/
export async function reassignEvidenceItem(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
await requireItem(db, itemId); // Existenz/Mandantenbindung sichern
const userIdRaw = str(formData.get("userId"));
let assignedUserId: string | null = null;
if (userIdRaw) {
const user = await db.user.findFirst({ where: { id: userIdRaw, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Ausgewählte Person ist kein aktiver Nutzer dieses Mandanten.");
assignedUserId = user.id;
}
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { assignedUserId } });
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "audit_evidence_item", entityId: itemId,
after: { assignedUserId },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für ein offenes Nachweis-Item eine `evidence_provide`-Aufgabe beim Ansprechpartner
* anlegen und verlinken (`AuditEvidenceItem.taskId`). Frist = `Audit.preparationDeadline`.
* `createTask` setzt Bereich (`domain`) aus dem Control und die RACI-Mitwirkenden.
* Idempotent: existiert bereits eine Aufgabe für das Item, passiert nichts.
*/
export async function createEvidenceTask(itemId: string) {
const { db } = await guard("onboarding:use");
const item = await requireItem(db, itemId);
if (item.taskId) return; // bereits verknüpft
const audit = await requireAudit(db, item.auditId);
const task = await createTask({
type: "evidence_provide",
title: `Nachweis bereitstellen: ${item.control} — ${item.title}`.slice(0, 200),
description: `Für das Audit „${audit.title}" ist ein Nachweis zu Control ${item.control} (${controlTitle(item.control)}) bereitzustellen.`.slice(0, 5000),
origin: `audit:evidence:${item.auditId}`,
owner: item.assignedUserId ?? undefined,
dueDate: audit.preparationDeadline ?? undefined,
priority: "hoch",
links: { control: item.control },
});
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { taskId: task.id } });
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für alle offenen Items eines Audits ohne verknüpfte Aufgabe je eine
* `evidence_provide`-Aufgabe erzeugen. Idempotent (überspringt bereits verknüpfte).
*/
export async function createEvidenceTasksForAudit(auditId: string) {
const { db } = await guard("onboarding:use");
await requireAudit(db, auditId);
const items = await db.auditEvidenceItem.findMany({
where: { auditId, status: "offen", taskId: null },
select: { id: true },
});
for (const { id } of items) {
await createEvidenceTask(id);
}
revalidatePath(NACHWEISE_PATH, "page");
}
// ── Datei-Upload je Item (Validierung analog policy-upload.ts, F-15) ──────────
//
// Phase 1: Ablage über den gekapselten Storage-Adapter (Stub — speichert nur
// Metadaten/Key, keine Bytes; echte MinIO-Byte-Speicherung ist Folge-Epic). Der
// Upload-Flow inkl. Evidence-Verknüpfung und Statuswechsel ist voll funktionsfähig.
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB
type UploadKind = "pdf" | "docx" | "odt";
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/octet-stream", ""],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) => sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT = ZIP-Container)
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
/**
* Datei-Upload je Nachweis-Item. Legt die Datei über den Storage-Adapter (Stub) ab,
* erzeugt eine `Evidence`-Zeile (mit `control`), verknüpft `AuditEvidenceItem.evidenceId`,
* setzt den Status auf `bereitgestellt` und schließt eine ggf. verknüpfte Aufgabe (DONE).
* Vollständige Eingangsvalidierung (Größe/Endung/MIME/Magic-Bytes), da `f.type` nicht
* vertrauenswürdig ist.
*/
export async function uploadEvidenceFile(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const item = await requireItem(db, itemId);
const file = formData.get("file");
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || (file as File).size === 0) {
throw new Error("Bitte eine Datei auswählen.");
}
const f = file as File;
// (1) Größenlimit VOR dem Einlesen (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
// (3) Client-MIME nur als Zusatzsignal.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) throw new Error("MIME-Typ passt nicht zur Dateiendung.");
// (4) Inhalt einlesen + Magic-Bytes prüfen (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
const stored = await storage.put({ tenantId, filename: f.name, contentType: CANONICAL_MIME[kind], bytes });
// Evidence-Zeile (mit control). Bei bereits verknüpfter Aufgabe daran hängen.
const evidence = await db.evidence.create({
data: {
tenantId,
title: `${item.control} — ${stored.filename}`.slice(0, 200),
kind: "record",
fileRef: stored.storageKey,
control: item.control,
taskId: item.taskId,
createdById: session.user.id,
},
select: { id: true },
});
await db.auditEvidenceItem.update({
where: { id: itemId },
data: { evidenceId: evidence.id, status: "bereitgestellt" },
});
// Verknüpfte Aufgabe erledigt sich.
if (item.taskId) {
await db.task.update({
where: { id: item.taskId },
data: { status: "DONE", resolvedById: session.user.id, resolvedAt: new Date() },
});
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_upload", entityId: itemId,
after: { evidenceId: evidence.id, storageKey: stored.storageKey, size: stored.size, taskDone: Boolean(item.taskId) },
});
revalidatePath(NACHWEISE_PATH, "page");
revalidatePath("/tasks");
}
-157
View File
@@ -1,157 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AuditStatus, AuditType } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
/**
* Audit-Verwaltung (V5A) — Planung/Terminierung von internen und externen Audits.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt voraus, dass das Modul `audit` für den Mandanten aktiviert ist.
* Der lokale Wrapper `guard` erzwingt zusätzlich `isAuditReadinessEnabled` — analog
* zu `src/server/actions/audit-readiness.ts`. Als Schreibrecht dient `onboarding:use`
* (dieselbe Rolle, die den Audit-Wizard fährt: Mandanten-Admin/ISB), konsistent mit
* den bestehenden Actions des Audit-Moduls.
*
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
* dass jede exportierte Action über `await guard(...)` läuft (Datei ↔ Modul `audit`).
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Moduls; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Modul ist nicht aktiviert.");
return { session, db };
}
const AUDIT_TYPES = ["INTERNAL", "EXTERNAL"] as const;
const AUDIT_STATUSES = ["PLANNED", "IN_PREPARATION", "DONE"] as const;
const baseSchema = z.object({
type: z.enum(AUDIT_TYPES),
title: z.string().trim().min(1, "Titel erforderlich").max(200),
plannedDate: z.string().trim().max(120).optional(),
scope: z.string().trim().max(2000).optional(),
// extern
assessmentLevel: z.string().trim().max(20).optional(),
provider: z.string().trim().max(200).optional(),
// intern
auditorUserId: z.string().trim().max(60).optional(),
interval: z.string().trim().max(60).optional(), // Turnus (in `result` als Merker abgelegt)
});
function parse(formData: FormData) {
const p = baseSchema.parse({
type: formData.get("type"),
title: formData.get("title"),
plannedDate: formData.get("plannedDate") || undefined,
scope: formData.get("scope") || undefined,
assessmentLevel: formData.get("assessmentLevel") || undefined,
provider: formData.get("provider") || undefined,
auditorUserId: formData.get("auditorUserId") || undefined,
interval: formData.get("interval") || undefined,
});
const isExternal = p.type === "EXTERNAL";
return {
type: p.type as AuditType,
title: p.title,
plannedDate: p.plannedDate || null,
scope: p.scope || null,
// Externe Felder nur für externe Audits, interne nur für interne — sauber trennen.
assessmentLevel: isExternal ? p.assessmentLevel || null : null,
provider: isExternal ? p.provider || null : null,
auditorUserId: isExternal ? null : p.auditorUserId || null,
interval: isExternal ? null : p.interval || null,
};
}
/** Neues Audit anlegen (Status PLANNED). Intern → nur terminieren, Extern → Wizard-fähig. */
export async function createAudit(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const data = parse(formData);
// Turnus des internen Audits als Freitext-Merker im `result`-Feld (kein Schema-Change).
const result = data.interval ? `Turnus: ${data.interval}` : null;
const created = await db.audit.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
status: "PLANNED",
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "audit", entityId: created.id, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
// Externe Audits: direkt in die Vorbereitung springen; interne bleiben in der Übersicht.
if (created.type === "EXTERNAL") redirect(`/audit-readiness/${created.id}`);
redirect("/audit-readiness");
}
/** Audit aktualisieren (Stammdaten/Terminierung). */
export async function updateAudit(auditId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const data = parse(formData);
const result = data.interval ? `Turnus: ${data.interval}` : before.result;
await db.audit.update({
where: { id: auditId },
data: {
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
redirect("/audit-readiness");
}
/** Audit löschen (inkl. hängender Nachweispositionen via onDelete: Cascade). */
export async function deleteAudit(auditId: string) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
await db.audit.delete({ where: { id: auditId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "audit", entityId: auditId, before });
revalidatePath("/audit-readiness");
redirect("/audit-readiness");
}
const statusSchema = z.enum(AUDIT_STATUSES);
/** Audit-Status setzen (PLANNED → IN_PREPARATION → DONE). Bei DONE optional Ergebnis. */
export async function setAuditStatus(auditId: string, status: string, formData?: FormData) {
const { session, db } = await guard("onboarding:use");
const next = statusSchema.parse(status) as AuditStatus;
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const result = formData?.get("result");
await db.audit.update({
where: { id: auditId },
data: {
status: next,
...(next === "DONE" && typeof result === "string" && result.trim() ? { result: result.trim() } : {}),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { status: next } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
}
-124
View File
@@ -1,124 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { buildDraftInput } from "@/server/control-descriptions-context";
import { draftControlDescription as draftAI } from "@/server/ai/draft-control-description";
/**
* Server-Actions der Control-Beschreibungen (VDA-ISA-Spalte 4) im Audit-Wizard.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte); der Wrapper `guard` erzwingt zusätzlich die Scharfschaltung des
* Audit-Wizards (`isAuditReadinessEnabled`, Default = aus). Der KI-Entwurf läuft
* über `src/server/ai/*` und degradiert bei fehlendem API-Key sauber (kein
* Schreibvorgang, Status bleibt „manuell zu erfassen").
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung; wirft, wenn der Wizard nicht aktiviert ist. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const revalidate = () => revalidatePath("/audit-readiness", "layout");
/**
* KI-Entwurf für eine einzelne Anforderung erzeugen und als Entwurf speichern.
* Ohne konfigurierten API-Key (oder bei API-Fehler) passiert nichts — der Status
* bleibt „open" (manuell zu erfassen).
*/
export async function draftControlDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const input = await buildDraftInput(db, reqId);
if (!input) throw new Error("Anforderung nicht gefunden.");
const result = await draftAI(input);
if (!result) return; // graceful degradation: kein Entwurf, kein Schreibvorgang.
const tenantId = session.user.tenantId;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: input.control, reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", confidence: result.confidence } });
revalidate();
}
/** KI-Entwurf für alle Anforderungen eines Controls erzeugen (sequentiell). */
export async function draftControl(control: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const reqs = await db.policyRequirement.findMany({ where: { control, archivedAt: null }, select: { reqId: true } });
let drafted = 0;
for (const r of reqs) {
const input = await buildDraftInput(db, r.reqId);
if (!input) continue;
const result = await draftAI(input);
if (!result) continue;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId: r.reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control, reqId: r.reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
drafted++;
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: control, after: { drafted } });
revalidate();
}
/** Entwurf übernehmen (Status → „confirmed"); der Nutzer bestätigt den KI-Vorschlag. */
export async function confirmDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.controlDescription.findUnique({ where: { tenantId_reqId: { tenantId, reqId } } });
if (!row?.draftText) throw new Error("Kein Entwurf zum Übernehmen vorhanden.");
await db.controlDescription.update({ where: { tenantId_reqId: { tenantId, reqId } }, data: { status: "confirmed", updatedById: session.user.id } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "confirmed" } });
revalidate();
}
/** Beschreibung manuell anpassen/erfassen (Status → „draft"). */
export async function saveDescription(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const draftText = (formData.get("draftText") as string | null)?.trim() || null;
const sourceRef = (formData.get("sourceRef") as string | null)?.trim() || null;
const confidenceRaw = (formData.get("confidence") as string | null)?.trim();
const confidence = confidenceRaw === "high" || confidenceRaw === "medium" || confidenceRaw === "low" ? confidenceRaw : null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", manual: true } });
revalidate();
}
/** Gezielte Rückfrage zum fehlenden Beleg beantworten/speichern (`openAnswer`). */
export async function saveOpenAnswer(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const openAnswer = (formData.get("openAnswer") as string | null)?.trim() || null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { openAnswer, updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, openAnswer, updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { openAnswer: Boolean(openAnswer) } });
revalidate();
}
-73
View File
@@ -1,73 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildGapItems } from "@/server/gap-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
/**
* Gap-Konsolidierung (Story A8) — Server-Aktion für den GAP-Schritt des Audit-Wizards
* (M4, Step-Key „audit_gap"). Legt für konsolidierte offene Punkte ohne bestehende
* Aufgabe idempotente Aufgaben an (dedup-bewusst: ein Punkt = eine Aufgabe, C8 §2), mit
* Priorität aus C8 §1 und allen Control-/Risiko-Bezügen. Bereits verknüpfte Punkte
* werden nicht doppelt angelegt.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
// Fortschritts-Key des GAP-Schritts im Audit-Wizard (s. AUDIT_STEP_KEYS).
const GAP_STEP_KEY = "audit_gap";
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } },
update: { status },
create: { tenantId, stepKey: GAP_STEP_KEY, status },
});
}
}
/** Aufgaben für alle konsolidierten offenen Punkte ohne bestehende Aufgabe anlegen. */
export async function createConsolidatedGapTasks() {
const { session, db } = await guard("onboarding:use");
const { items } = await buildGapItems(db, session.user.tenantId);
let created = 0;
for (const item of items) {
if (item.taskId) continue; // bereits verknüpfte Aufgabe → keine neue (C8 §2)
const exists = await db.task.findFirst({ where: { origin: item.taskOrigin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
const links: Record<string, string> = {};
if (item.controls[0]) links.control = item.controls[0];
if (item.source === "risk") links.risk = item.id.replace(/^risk:/, "");
// Cockpit (M3, 1.4): Bereich aus dem führenden Control ableiten (falls vorhanden).
const domain = await domainForControl(db, session.user.tenantId, item.controls[0] ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: item.action,
origin: item.taskOrigin,
status: "PROPOSED",
priority: item.priority,
createdById: session.user.id,
domain,
links,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: item.controls[0] ?? null, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "gap_consolidation", after: { points: items.length, created } });
revalidatePath("/audit-readiness");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
-34
View File
@@ -1,34 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
/**
* Aus einem C6-Umsetzungshinweis (Story B5-2) eine Aufgabe erzeugen — v. a. bei
* Beschaffungs-/Umsetzungsbedarf (`procurement`). Nutzt createTask (F1). Idempotent
* über `origin = "hint:<reqId>"`: existiert bereits ein offener Task, passiert nichts.
*/
const guard = moduleGuard("policies");
export async function createHintTask(reqId: string) {
const { db } = await guard("policy:write");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Kein Umsetzungshinweis gefunden.");
const origin = `hint:${reqId}`;
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!existing) {
await createTask({
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
origin,
priority: "mittel",
links: { control: hint.control },
});
}
revalidatePath("/tasks");
revalidatePath("/policies/hints");
redirect("/tasks");
}
-105
View File
@@ -1,105 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Betreiber-Provisionierung der E-Mail-Intake-Konfiguration.
*
* Der Betreiber legt beim Onboarding (oder später) je Kunde die Intake-Konfiguration
* an: ein global eindeutiger Token wird erzeugt, daraus die Catch-all-Adresse
* `vorfall-<token>@in.certvia.de` abgeleitet. Der Kunde richtet dann bei sich eine
* Weiterleitung ein; sobald die erste Test-Mail als Ticket ankommt, springt der
* Status automatisch auf `verifiziert` (process.ts). Der Betreiber kann den Status
* hier zusätzlich manuell setzen/zurücksetzen.
*
* Plattform-Fähigkeit (eigene Auth über requirePlatformFullAdmin), kein Fachmodul-Guard.
* Alle Änderungen landen im Plattform-Audit.
*/
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
/** Intake-Konfiguration anlegen/aktualisieren (Onboarding + spätere Pflege). */
export async function provisionIncidentIntake(tenantId: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
if (!tenantId) throw new Error("Kein Mandant angegeben.");
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const existing = await prisma.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
const row = existing
? await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress },
select: { id: true, token: true },
})
: await prisma.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress },
select: { id: true, token: true },
});
await writePlatformAudit({
actorId: admin.id,
action: existing ? "update" : "create",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, allowlistDomains, sourceAddress },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Provisionierungs-Status manuell setzen (verifiziert ⇄ weiterleitung_ausstehend). */
export async function setIncidentIntakeStatus(tenantId: string, verified: boolean) {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: {
status: verified ? "verifiziert" : "weiterleitung_ausstehend",
verifiedAt: verified ? new Date() : null,
},
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, status: verified ? "verifiziert" : "weiterleitung_ausstehend" },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Betreiber-Review erledigen (Inbound-Mail ohne/mit unbekanntem Token gesichtet). */
export async function resolveInboundReview(reviewId: string, status: "erledigt" | "zugeordnet") {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentInboundReview.update({
where: { id: reviewId },
data: { status },
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_inbound_review",
entityId: row.id,
after: { status },
});
revalidatePath("/admin");
}
-70
View File
@@ -1,70 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Mandantenseitige Pflege der E-Mail-Intake-Konfiguration (Modul „Vorfälle").
*
* Der Kunden-Admin pflegt hier die Allowlist-Domänen, die optionale Quelladresse und
* den Benachrichtigungsempfänger. Die Intake-Adresse selbst (`vorfall-<token>@…`) wird
* NICHT hier gesetzt, sondern aus dem global eindeutigen Token abgeleitet und nur
* read-only angezeigt. Existiert noch keine Konfiguration (Betreiber hat nicht
* provisioniert), wird sie beim ersten Speichern mit frischem Token angelegt —
* Status bleibt `weiterleitung_ausstehend`, bis die erste Test-Mail ankommt.
*
* moduleGuard("incidents") stellt sicher, dass das Modul aktiv ist; die Bearbeitung
* verlangt zusätzlich `tenant:manage`.
*/
const guard = moduleGuard("incidents");
/** Domänenliste aus Textarea/Feld normalisieren (eine je Zeile oder komma-/leerzeichengetrennt). */
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
export async function updateIncidentIntake(formData: FormData) {
// Guard prüft aktives Modul „incidents" + tenant:manage (autoritativ aus DB, F-06).
const { session, db } = await guard("tenant:manage");
const tenantId = session.user.tenantId;
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const notifyRaw = String(formData.get("notifyEmail") ?? "").trim().toLowerCase();
const notifyEmail = notifyRaw && notifyRaw.includes("@") ? notifyRaw : null;
const existing = await db.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
if (existing) {
await db.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress, notifyEmail },
});
} else {
// Erste Pflege durch den Kunden: Token erzeugen (global eindeutig; bei extrem
// unwahrscheinlicher Kollision greift der UNIQUE-Constraint → seltener Retry).
await db.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress, notifyEmail },
});
}
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_intake_config",
after: { allowlistDomains, sourceAddress, notifyEmail },
});
revalidatePath("/settings/incident-intake");
}
-872
View File
@@ -1,872 +0,0 @@
"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";
import { severityFromIncident, INCIDENT_SEVERITIES } from "@/lib/incident-severity";
import { nextIncidentRefNo } from "@/server/incident-refno";
import {
INCIDENT_CATEGORIES,
INCIDENT_PRIORITIES,
INCIDENT_SOURCES,
INCIDENT_STATUSES,
canSeeRestricted,
canTransition,
missingRequiredFields,
type IncidentStatus,
} from "@/lib/incident";
import {
computeDeadlines,
isReportable,
knowledgeTime,
nextReportStatus,
REPORT_STATUS_ORDER,
type ReportStatus,
} from "@/lib/incident-deadlines";
import {
incidentManagerIds,
notifyIncidentEvent,
} from "@/server/mail/incident-notifications";
const guard = moduleGuard("incidents");
type GuardDb = Awaited<ReturnType<typeof guard>>["db"];
/**
* Meldepflicht-Felder (Fristen + Meldung-Track-Status) aus den Flags und dem
* Kenntniszeitpunkt berechnen (§6). NIS2-Timer greifen nur bei
* nis2Category ∈ {wichtig, wesentlich} UND nis2Relevant; DSGVO bei Personenbezug.
* Wird ein noch nicht gemeldeter Vorfall meldepflichtig, springt reportStatus
* `none → pruefung`; entfällt die Meldepflicht wieder, zurück auf `none`.
*/
async function reportingFieldsFor(
db: GuardDb,
tenantId: string,
flags: { nis2Relevant: boolean; dsgvoRelevant: boolean },
times: { detectedAt: Date | null; reportedAt: Date | null; occurredAt: Date | null },
currentReportStatus: string,
) {
const settings = await db.tenantSettings.findUnique({
where: { tenantId },
select: { nis2Category: true },
});
const nis2Category = settings?.nis2Category ?? "keine";
const knownAt = knowledgeTime({ ...times, createdAt: new Date() });
const deadlines = computeDeadlines({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
knownAt,
});
const reportable = isReportable({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
});
let reportStatus = currentReportStatus;
if (reportable) {
if (currentReportStatus === "none") reportStatus = "pruefung";
} else if (currentReportStatus === "none" || currentReportStatus === "pruefung") {
reportStatus = "none";
}
return { ...deadlines, reportStatus };
}
const impactScale = z.coerce.number().int().min(0).max(4);
const optDate = z
.string()
.trim()
.optional()
.transform((v) => (v ? new Date(v) : null));
const incidentBaseSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(10000).optional(),
source: z.enum(INCIDENT_SOURCES).default("manual"),
reporterName: z.string().trim().max(200).optional(),
reporterContact: z.string().trim().max(200).optional(),
occurredAt: optDate,
detectedAt: optDate,
reportedAt: optDate,
category: z.enum(INCIDENT_CATEGORIES).default("other"),
impactC: impactScale,
impactI: impactScale,
impactA: impactScale,
urgency: impactScale,
affectedDataCategories: z.string().trim().max(2000).optional(),
personalData: z.coerce.boolean().default(false),
prototypeData: z.coerce.boolean().default(false),
priority: z.enum(INCIDENT_PRIORITIES).default("mittel"),
nis2Relevant: z.coerce.boolean().default(false),
});
function parseIncidentForm(formData: FormData) {
const p = incidentBaseSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
source: formData.get("source") || undefined,
reporterName: formData.get("reporterName") || undefined,
reporterContact: formData.get("reporterContact") || undefined,
occurredAt: formData.get("occurredAt") || undefined,
detectedAt: formData.get("detectedAt") || undefined,
reportedAt: formData.get("reportedAt") || undefined,
category: formData.get("category") || undefined,
impactC: formData.get("impactC") ?? 0,
impactI: formData.get("impactI") ?? 0,
impactA: formData.get("impactA") ?? 0,
urgency: formData.get("urgency") ?? 2,
affectedDataCategories: formData.get("affectedDataCategories") || undefined,
personalData: formData.get("personalData") === "on" || formData.get("personalData") === "true",
prototypeData:
formData.get("prototypeData") === "on" || formData.get("prototypeData") === "true",
priority: formData.get("priority") || undefined,
nis2Relevant: formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true",
});
const dataCategories = (p.affectedDataCategories ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Severity aus der Default-Matrix (§5); dsgvoRelevant folgt dem Personenbezug (§4).
const severity = severityFromIncident(p.impactC, p.impactI, p.impactA, p.urgency);
return {
title: p.title,
description: p.description || null,
source: p.source,
reporterName: p.reporterName || null,
reporterContact: p.reporterContact || null,
occurredAt: p.occurredAt,
detectedAt: p.detectedAt,
reportedAt: p.reportedAt,
category: p.category,
impactC: p.impactC,
impactI: p.impactI,
impactA: p.impactA,
urgency: p.urgency,
affectedDataCategories: dataCategories,
personalData: p.personalData,
prototypeData: p.prototypeData,
priority: p.priority,
severity,
nis2Relevant: p.nis2Relevant,
dsgvoRelevant: p.personalData,
};
}
export async function createIncident(formData: FormData) {
const { session, db } = await guard("incident:report");
const data = parseIncidentForm(formData);
const refNo = await nextIncidentRefNo(prisma, session.user.tenantId);
// Meldefristen (§6) aus Kenntniszeitpunkt setzen, falls bereits meldepflichtig.
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
"none",
);
const incident = await db.incident.create({
data: {
...data,
...reporting,
refNo,
tenantId: session.user.tenantId,
createdBy: session.user.id,
// Melder wird, sofern nicht gesetzt, mit dem anlegenden Nutzer initialisiert.
ownerId: null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident",
entityId: incident.id,
after: { refNo, title: data.title, severity: data.severity, status: "neu" },
});
// §7 — neuer Vorfall → ISB/Incident-Manager.
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_created",
incidentId: incident.id,
refNo,
title: data.title,
recipientIds: await incidentManagerIds(session.user.tenantId),
actorId: session.user.id,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incident.id}`);
}
export async function updateIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const data = parseIncidentForm(formData);
// Meldepflicht/Fristen bei geänderten Flags/Zeiten neu berechnen (§6).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
before.reportStatus,
);
await db.incident.update({ where: { id: incidentId }, data: { ...data, ...reporting } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { title: before.title, severity: before.severity, category: before.category },
after: { title: data.title, severity: data.severity, category: data.category },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Statuswechsel (§3) mit Pflichtfeld-Prüfung je Übergang (§8) und Audit-Eintrag.
* Übergang nach „abgeschlossen" verlangt das Recht incident:close, sonst manage.
*/
export async function transitionIncident(incidentId: string, formData: FormData) {
const target = z.enum(INCIDENT_STATUSES).parse(formData.get("status"));
const permission = target === "abgeschlossen" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const from = before.status as IncidentStatus;
if (from === target) throw new Error("Kein Statuswechsel (gleicher Status).");
if (!canTransition(from, target)) {
throw new Error(`Übergang ${from} → ${target} ist nicht erlaubt.`);
}
// Pflichtfelder je Zielstatus (§8): entweder bereits gesetzt oder in diesem Formular mitgeliefert.
const patch: Record<string, string | null> = {};
const textField = (key: string): string | null => {
const raw = formData.get(key);
return raw !== null && String(raw).trim() ? String(raw).trim() : null;
};
const effective: Record<string, unknown> = {
rootCause: before.rootCause,
resolution: before.resolution,
closingNote: before.closingNote,
lessonsLearned: before.lessonsLearned,
};
for (const field of ["rootCause", "resolution", "closingNote", "lessonsLearned"] as const) {
const provided = textField(field);
if (provided) {
patch[field] = provided;
effective[field] = provided;
}
}
// Optionale Abschluss-Zusatzfelder (§8 IM-C): Wirksamkeit der Maßnahmen und
// Post-Incident-Review — beim Abschluss mitschreibbar, nicht erzwungen.
for (const field of ["measuresEffectiveness", "postIncidentReview"] as const) {
const provided = textField(field);
if (provided) patch[field] = provided;
}
const missing = missingRequiredFields(target, effective);
if (missing.length > 0) {
throw new Error(`Pflichtfeld(er) für Status „${target}" fehlt/fehlen: ${missing.join(", ")}`);
}
// interne Meldung (reportedAt) beim ersten Verlassen von „neu" festhalten
const reportedAt = from === "neu" && !before.reportedAt ? new Date() : before.reportedAt;
// Fristen ggf. neu ableiten (Kenntniszeitpunkt kann sich durch reportedAt ändern).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: before.nis2Relevant, dsgvoRelevant: before.dsgvoRelevant },
{ detectedAt: before.detectedAt, reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: {
status: target,
...patch,
...reporting,
reportedAt,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { status: from },
after: { status: target },
});
// §7 — Statuswechsel an Beteiligte; Abschluss zusätzlich an Melder + Manager.
const beteiligte = [before.ownerId, before.assigneeId, before.createdBy];
if (target === "abgeschlossen") {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_closed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [...beteiligte, ...(await incidentManagerIds(session.user.tenantId))],
actorId: session.user.id,
});
} else {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: beteiligte,
actorId: session.user.id,
detail: target,
dedupeSuffix: target,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldepflicht setzen/prüfen (§3/§6): NIS2-Relevanz und Personenbezug markieren,
* Fristen aus dem Kenntniszeitpunkt (neu) berechnen und den Meldung-Track ggf.
* `none → pruefung` schalten. Recht incident:manage, mit Audit.
*/
export async function setIncidentReportability(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const nis2Relevant =
formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true";
const personalData =
formData.get("personalData") === "on" || formData.get("personalData") === "true";
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant, dsgvoRelevant: personalData },
{ detectedAt: before.detectedAt, reportedAt: before.reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: { nis2Relevant, personalData, dsgvoRelevant: personalData, ...reporting },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: {
nis2Relevant: before.nis2Relevant,
dsgvoRelevant: before.dsgvoRelevant,
reportStatus: before.reportStatus,
},
after: { nis2Relevant, dsgvoRelevant: personalData, reportStatus: reporting.reportStatus },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldung-Track vorwärts schalten (§3 Parallel-Track): pruefung → Erstmeldung →
* Folgemeldung → Abschlussbericht. Übermittlung an die Behörde MANUELL — hier nur
* Status + Timer. Abschluss verlangt incident:close, sonst incident:manage. Audit.
*/
export async function advanceReportStatus(incidentId: string, formData: FormData) {
const target = z
.enum(["erstmeldung", "folgemeldung", "abschluss"])
.parse(formData.get("reportStatus"));
const permission = target === "abschluss" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const current = before.reportStatus as ReportStatus;
if (REPORT_STATUS_ORDER[current] === undefined || current === "none") {
throw new Error("Für diesen Vorfall besteht keine Meldepflicht (Track nicht aktiv).");
}
const allowed = nextReportStatus(current);
if (allowed !== target) {
throw new Error(`Meldung-Übergang ${current} → ${target} ist nicht erlaubt.`);
}
await db.incident.update({ where: { id: incidentId }, data: { reportStatus: target } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { reportStatus: current },
after: { reportStatus: target },
});
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [before.ownerId, before.assigneeId, before.createdBy],
actorId: session.user.id,
detail: `Meldung: ${target}`,
dedupeSuffix: `report_${target}`,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function addIncidentComment(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const internal = formData.get("internal") === "on" || formData.get("internal") === "true";
const incident = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { id: true },
});
if (!incident) throw new Error("Vorfall nicht gefunden");
await db.incidentComment.create({
data: { tenantId: session.user.tenantId, incidentId, authorId: session.user.id, body, internal },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident_comment",
entityId: incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function editIncidentComment(commentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const before = await db.incidentComment.findFirst({
where: { id: commentId, tenantId: session.user.tenantId },
select: { id: true, authorId: true, incidentId: true },
});
if (!before) throw new Error("Kommentar nicht gefunden");
// Nur der Autor darf seinen Kommentar bearbeiten (sofern nicht manage/close).
if (before.authorId !== session.user.id && !canSeeRestricted(session)) {
throw new Error("Nur der Autor darf den Kommentar bearbeiten.");
}
await db.incidentComment.update({ where: { id: commentId }, data: { body } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_comment",
entityId: before.incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${before.incidentId}`);
}
async function requireIncident(
db: Awaited<ReturnType<typeof guard>>["db"],
tenantId: string,
incidentId: string,
) {
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId },
select: { id: true },
});
if (!inc) throw new Error("Vorfall nicht gefunden");
}
export async function linkIncidentAsset(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const assetId = z.string().min(1).parse(formData.get("assetId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Asset nicht gefunden");
await db.incidentAsset.upsert({
where: { incidentId_assetId: { incidentId, assetId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, assetId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_asset", entityId: incidentId, after: { assetId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentAsset(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentAsset.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentAsset.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_asset", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentProcess(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const processId = z.string().min(1).parse(formData.get("processId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.process.count({ where: { id: processId } })) !== 1) throw new Error("Prozess nicht gefunden");
await db.incidentProcess.upsert({
where: { incidentId_processId: { incidentId, processId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, processId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_process", entityId: incidentId, after: { processId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentProcess(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentProcess.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentProcess.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_process", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentRisk(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const riskId = z.string().min(1).parse(formData.get("riskId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.risk.count({ where: { id: riskId } })) !== 1) throw new Error("Risiko nicht gefunden");
await db.incidentRisk.upsert({
where: { incidentId_riskId: { incidentId, riskId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, riskId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentRisk(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentRisk.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentRisk.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_risk", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentControl(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const controlRef = z.string().trim().min(1).max(50).parse(formData.get("controlRef"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incidentControl.upsert({
where: { incidentId_controlRef: { incidentId, controlRef } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, controlRef },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_control", entityId: incidentId, after: { controlRef } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentControl(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentControl.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentControl.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_control", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Verknüpfung zum zentralen Maßnahmen-Modul (§9) — nur verknüpfen, Detailausbau IM-C. */
export async function linkIncidentMeasure(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const measureId = z.string().min(1).parse(formData.get("measureId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.measure.count({ where: { id: measureId } })) !== 1) throw new Error("Maßnahme nicht gefunden");
await db.incidentMeasure.upsert({
where: { incidentId_measureId: { incidentId, measureId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, measureId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentMeasure(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentMeasure.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentMeasure.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_measure", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Gültige aktive Nutzer-ID des eigenen Mandanten oder null (für FK-Sicherheit). */
async function validOwnerOrNull(db: GuardDb, ownerId: string | null): Promise<string | null> {
if (!ownerId) return null;
return (await db.user.count({ where: { id: ownerId, status: "ACTIVE" } })) === 1 ? ownerId : null;
}
/**
* §9 — „Maßnahme direkt aus dem Vorfall anlegen": legt eine Maßnahme im ZENTRALEN
* Maßnahmen-Modul an (nicht doppelt) und verknüpft sie mit dem Vorfall
* (IncidentMeasure). Verlangt incident:manage UND measure:write.
*/
export async function createMeasureForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "measure:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const priority = z.enum(["LOW", "MEDIUM", "HIGH"]).catch("MEDIUM").parse(formData.get("priority") ?? "MEDIUM");
const ownerId = await validOwnerOrNull(db, (formData.get("ownerId") as string | null)?.trim() || null);
const dueRaw = (formData.get("dueDate") as string | null)?.trim() || null;
const last = await prisma.measure.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const measure = await db.measure.create({
data: {
title,
description,
priority,
ownerId,
dueDate: dueRaw ? new Date(dueRaw) : null,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentMeasure.create({
data: { tenantId: session.user.tenantId, incidentId, measureId: measure.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "measure", entityId: measure.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId: measure.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/measures");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §9 — Vorfall erzeugt ein NEUES Risiko: legt es im Risiko-Modul an (Register)
* und verknüpft es (IncidentRisk). Verlangt incident:manage UND risk:write.
* (Ein BESTEHENDES Risiko bestätigt der Vorfall über linkIncidentRisk.)
*/
export async function createRiskForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "risk:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const likelihood = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("likelihood") ?? 3);
const impact = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("impact") ?? 3);
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
title,
description,
likelihood,
impact,
score: likelihood * impact,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentRisk.create({
data: { tenantId: session.user.tenantId, incidentId, riskId: risk.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId: risk.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/risks");
redirect(`/incidents?edit=${incidentId}`);
}
/** Bestehenden Nachweis (zentrales Evidence-Modell) mit dem Vorfall verknüpfen. */
export async function linkIncidentEvidence(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const evidenceId = z.string().min(1).parse(formData.get("evidenceId"));
const note = (formData.get("note") as string | null)?.trim() || null;
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.evidence.count({ where: { id: evidenceId } })) !== 1) throw new Error("Nachweis nicht gefunden");
await db.incidentEvidence.upsert({
where: { incidentId_evidenceId: { incidentId, evidenceId } },
update: { note },
create: { tenantId: session.user.tenantId, incidentId, evidenceId, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* Neuen Nachweis (Referenz/Text) direkt am Vorfall anlegen und verknüpfen — als
* Beweissicherung (ISO A.5.28); Datei-Persistenz folgt mit dem Storage-Paket.
* Verlangt incident:manage UND evidence:write.
*/
export async function createEvidenceForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "evidence:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const kind = z.enum(["record", "protocol", "screenshot", "export"]).catch("record").parse(formData.get("kind") ?? "record");
const fileRef = (formData.get("fileRef") as string | null)?.trim() || null;
const note = (formData.get("note") as string | null)?.trim() || null;
const ev = await db.evidence.create({
data: { tenantId: session.user.tenantId, title, kind, fileRef, createdById: session.user.id },
});
await db.incidentEvidence.create({
data: { tenantId: session.user.tenantId, incidentId, evidenceId: ev.id, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId: ev.id, created: true } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentEvidence(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentEvidence.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentEvidence.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_evidence", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §8 — Post-Incident-Review (Kurzbericht) + Wirksamkeit der Maßnahmen setzen.
* Der Review-Text speist das Management-Review (als Feld/Notiz, da kein eigenes
* Review-Modul). Recht incident:manage.
*/
export async function setIncidentReview(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const measuresEffectiveness = (formData.get("measuresEffectiveness") as string | null)?.trim() || null;
const postIncidentReview = (formData.get("postIncidentReview") as string | null)?.trim() || null;
await db.incident.update({ where: { id: incidentId }, data: { measuresEffectiveness, postIncidentReview } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { postIncidentReview: Boolean(postIncidentReview), measuresEffectiveness: Boolean(measuresEffectiveness) } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Severity/Kategorie manuell setzen (überschreibt/bestätigt die Default-Matrix). */
export async function setIncidentSeverity(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const severity = z.enum(INCIDENT_SEVERITIES).parse(formData.get("severity"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { severity } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { severity } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function setIncidentCategory(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const category = z.enum(INCIDENT_CATEGORIES).parse(formData.get("category"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { category } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { category } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Vertraulichkeit umschalten (§11). Nur manage/close. */
export async function setIncidentRestricted(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const restricted = formData.get("restricted") === "on" || formData.get("restricted") === "true";
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { restricted } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { restricted } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Owner (Incident-Manager) und Bearbeiter (assignee) setzen (§3 Steuerung). */
export async function setIncidentOwners(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const ownerId = (formData.get("ownerId") as string | null)?.trim() || null;
const assigneeId = (formData.get("assigneeId") as string | null)?.trim() || null;
// Zuweisungen müssen aktive Nutzer des eigenen Mandanten sein.
for (const uid of [ownerId, assigneeId]) {
if (uid && (await db.user.count({ where: { id: uid, status: "ACTIVE" } })) !== 1) {
throw new Error("Zugewiesener Nutzer ist ungültig.");
}
}
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { refNo: true, title: true, assigneeId: true },
});
await db.incident.update({ where: { id: incidentId }, data: { ownerId, assigneeId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { ownerId, assigneeId } });
// §7 — Zuweisung → Bearbeiter (nur bei echter Änderung des Bearbeiters).
if (assigneeId && assigneeId !== inc?.assigneeId && inc) {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_assigned",
incidentId,
refNo: inc.refNo,
title: inc.title,
recipientIds: [assigneeId],
actorId: session.user.id,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
-253
View File
@@ -1,253 +0,0 @@
"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");
}
-62
View File
@@ -1,62 +0,0 @@
"use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { WIZARD_QUESTIONS } from "@/lib/onboarding/questions";
import { deriveResult } from "@/lib/onboarding/facts";
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Fragebogen (Story B3, Schritt „context"): Antworten als WizardFacts speichern und
* die Wirkung propagieren — Flags via B2-Regel-Engine ableiten, Aufgaben-Vorschläge
* via B1 (proposeTasksFromTriggers) erzeugen. Schreibt NUR Fakten/Flags, nie die
* gesperrten Zentralvariablen. Nur sichtbare (übermittelte) Fragen werden geschrieben.
*/
const guard = moduleGuard("onboarding");
export async function saveFacts(formData: FormData) {
const { session, db } = await guard("onboarding:use");
let saved = 0;
for (const q of WIZARD_QUESTIONS) {
const raw = formData.get(q.id);
if (raw === null) continue; // nicht sichtbar/nicht übermittelt → Bestand bleibt
const value: Prisma.InputJsonValue = q.type === "boolean" ? String(raw) === "true" : String(raw);
await db.wizardFact.upsert({
where: { tenantId_key: { tenantId: session.user.tenantId, key: q.id } },
update: { value, section: q.section },
create: { tenantId: session.user.tenantId, key: q.id, section: q.section, value },
});
saved++;
}
// Propagation via B2: alle Fakten → Flags + Aufgaben-Trigger. Schutzbedarf-Flags
// zentral aus dem Assessment-Level (A2-1), Prüfziele zentral aus WizardScope (A2-2).
const facts = await db.wizardFact.findMany({ select: { key: true, value: true } });
const level = await getAssessmentLevel(db);
const scope = await db.wizardScope.findFirst({ select: { pruefziele: true } });
const result = deriveResult(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
const proposed = result.tasks.length > 0 ? await proposeTasksFromTriggers(result.tasks) : { created: 0, skipped: 0 };
// State-Machine: Schritt „context" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } } });
if (!prog || prog.status === "offen") {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } },
update: { status: "in_bearbeitung" },
create: { tenantId: session.user.tenantId, stepKey: "context", status: "in_bearbeitung" },
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "wizard_facts",
after: { saved, activeFlags: Object.keys(result.flags).filter((k) => result.flags[k]), proposedTasks: proposed.created },
});
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
-63
View File
@@ -1,63 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
import type { TaskType } from "@/lib/tasks";
import type { TenantDb } from "@/server/db";
/**
* Task-Erzeugung für die Wizard-Schritte 4 (policies) und 6 (risks). Setzen auf den
* Bestandsmodulen /policies und /risks auf (keine Doppel-Datenhaltung); die Aktionen
* erzeugen für erkannte Lücken Aufgaben (F1), idempotent je `origin`.
*/
const guard = moduleGuard("onboarding");
interface Gap { origin: string; title: string; type: TaskType; }
async function createGapTasks(db: TenantDb, gaps: Gap[]): Promise<number> {
let created = 0;
for (const g of gaps) {
const existing = await db.task.findFirst({ where: { origin: g.origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) continue;
await createTask({ type: g.type, title: g.title, origin: g.origin, priority: "mittel" });
created++;
}
return created;
}
/** Schritt 4 (policies): Lücken im Richtlinienmodul → Aufgaben. */
export async function createPolicyGapTasks() {
const { db } = await guard("onboarding:use");
const [state, entwurf, openApprovals] = await Promise.all([
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
]);
const gaps: Gap[] = [];
if (!state?.importedVersion) gaps.push({ origin: "wizard-policies:import", title: "Richtlinien-Vorlagenpaket übernehmen (Paket-Updates)", type: "document_create" });
if (entwurf > 0) gaps.push({ origin: "wizard-policies:entwurf", title: `${entwurf} Richtlinie(n) im Entwurf zur Freigabe bringen`, type: "organizational" });
if (openApprovals === 0 && entwurf > 0) gaps.push({ origin: "wizard-policies:freigabe", title: "Freigabe-Workflow für offene Richtlinien starten", type: "organizational" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=policy");
}
/** Schritt 6 (risks): Lücken im Risikomodul → Aufgaben. */
export async function createRiskGapTasks() {
const { db } = await guard("onboarding:use");
const [risks, highUntreated, catalogTotal, adopted] = await Promise.all([
db.risk.count(),
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
db.riskCatalogEntry.count(),
db.risk.count({ where: { catalogCode: { not: null } } }),
]);
const gaps: Gap[] = [];
if (risks === 0) gaps.push({ origin: "wizard-risks:empty", title: "Risikoregister aufbauen — Standard-Risikokatalog sichten und relevante Risiken übernehmen", type: "organizational" });
else if (adopted === 0 && catalogTotal > 0) gaps.push({ origin: "wizard-risks:catalog", title: "Standard-Risikokatalog (C4) auf Relevanz prüfen und Risiken übernehmen", type: "organizational" });
if (highUntreated > 0) gaps.push({ origin: "wizard-risks:high-untreated", title: `${highUntreated} hohe(s) Risiko/Risiken ohne verknüpfte Maßnahme behandeln`, type: "technical" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=risks");
}
-210
View File
@@ -1,210 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { Domain, ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { getFunctionDef } from "@/lib/onboarding/functions";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
/**
* Team / Funktionszuordnung — Ebene 1 „Fundament" (M1).
*
* Bildet die echte Zuweisung ISMS-Funktion → User(n) ab (`ProjectFunctionAssignment`):
* - `assignFunction` : bestehenden Account einer Funktion zuweisen
* - `unassignFunction` : eine Zuweisung wieder entfernen
* - `markFunctionUnfilled` : Funktion bewusst unbesetzt lassen → Task „Funktion besetzen"
* - `inviteFunctionHolder` : neuen Account per E-Mail einladen (SEC2: Set-Passwort-Link)
*
* Fachregel (im Code erzwungen): der/die unabhängige interne Auditor:in (AUDITOR_INT)
* darf nicht dieselbe Person wie der/die ISB sein (keine Selbstprüfung).
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
const emailSchema = z.string().trim().email();
/** Schiebt den Schritt „roles" von offen → in_bearbeitung an. */
async function nudgeRolesStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
});
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
update: { status },
create: { tenantId, stepKey: "roles", status },
});
}
}
/** Prüft die Funktionstrennung ISB ≠ AUDITOR_INT (keine Selbstprüfung). */
async function assertAuditorIndependence(db: Db, functionKey: string, userId: string) {
if (functionKey === "AUDITOR_INT") {
const isb = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "ISB", userId } });
if (isb) throw new Error("Der/die unabhängige interne Auditor:in darf nicht der/die ISB sein (Funktionstrennung).");
}
if (functionKey === "ISB") {
const auditor = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "AUDITOR_INT", userId } });
if (auditor) throw new Error("Diese Person ist bereits als unabhängige:r interne:r Auditor:in geführt — ISB und Auditor:in müssen getrennt sein.");
}
}
/** Funktion → bestehenden Account zuweisen. */
export async function assignFunction(functionKey: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const userId = String(formData.get("userId") ?? "").trim();
if (!userId) throw new Error("Bitte einen Account auswählen.");
const user = await db.user.findFirst({ where: { id: userId, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Account nicht gefunden oder nicht aktiv.");
await assertAuditorIndependence(db, functionKey, userId);
// Nicht-Mehrfach-Funktionen: bestehende Halter ersetzen. Mehrfach-Funktionen
// (Asset-/Risk-Owner): denselben Account nicht doppelt eintragen.
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return; // bereits zugewiesen
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "project_function_assignment", entityId: functionKey, after: { userId } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Eine Funktionszuweisung entfernen. */
export async function unassignFunction(assignmentId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.projectFunctionAssignment.findFirst({ where: { id: assignmentId } });
if (!row) return;
await db.projectFunctionAssignment.delete({ where: { id: assignmentId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "project_function_assignment", entityId: row.functionKey, after: { removed: assignmentId } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Funktion bewusst unbesetzt lassen → Task „Funktion besetzen" (idempotent). */
export async function markFunctionUnfilled(functionKey: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const origin = `wizard:fill-function:${functionKey}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
await db.task.create({
data: {
tenantId,
type: "organizational",
title: `Funktion besetzen: ${def.label}`,
description: def.desc,
origin,
status: "OPEN",
priority: "hoch",
createdById: session.user.id,
...(def.domain ? { domain: def.domain as Domain } : {}),
},
});
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "task", entityId: origin, after: { functionKey } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Account per E-Mail einladen. Existiert bereits ein aktiver Account mit der Adresse,
* wird dieser der Funktion zugewiesen. Andernfalls wird ein neuer Account angelegt
* (Basisrolle „user", Passwortwechsel erzwungen) und über die bestehende SEC2-
* Infrastruktur ein Set-Passwort-Link (password_reset) versendet — das Klartext-
* Passwort verlässt den Server nie.
*/
export async function inviteFunctionHolder(functionKey: string, formData: FormData) {
// Kontenanlage erfordert zusätzlich user:manage.
const { session, db } = await guard("onboarding:use", "user:manage");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const parsed = emailSchema.safeParse(String(formData.get("email") ?? "").toLowerCase());
if (!parsed.success) throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
const email = parsed.data;
const name = String(formData.get("name") ?? "").trim() || email.split("@")[0]!;
const existing = await db.user.findFirst({ where: { email }, select: { id: true, status: true } });
let userId: string;
let invited = false;
if (existing) {
if (existing.status !== "ACTIVE") throw new Error("Ein Account mit dieser Adresse existiert, ist aber nicht aktiv.");
userId = existing.id;
} else {
const settings = await db.tenantSettings.findUnique({ where: { tenantId } });
const policy = resolvePasswordPolicy(settings?.securityPolicy);
const initialPassword = generateCompliantPassword(policy);
const baseRole = await db.role.findFirst({ where: { key: "user" }, select: { id: true } });
// Option C (WS3): Anlage NUR per Einladung. Bekannte Identity → nur Mitgliedschaft
// (kein Passwort-Reset); unbekannt → Identity anlegen + Einladung (/invite).
// Membership.passwordHash ist Legacy (Login nutzt Identity).
const throwaway = await hashPassword(initialPassword);
const prior = await prisma.identity.findUnique({ where: { email } });
const identity = prior ?? (await prisma.identity.create({
data: { email, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const created = await db.user.create({
data: {
tenantId,
identityId: identity.id,
email,
name,
status: "ACTIVE",
...(baseRole ? { userRoles: { create: [{ roleId: baseRole.id }] } } : {}),
},
});
userId = created.id;
invited = true;
if (!prior) {
const tenant = await db.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
await sendUserInvitationMail({ to: email, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt, locale: settings?.locale });
}
}
await assertAuditorIndependence(db, functionKey, userId);
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return;
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, invitedEmail: invited ? email : undefined, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: invited ? "create" : "update", entity: "project_function_assignment", entityId: functionKey, after: { userId, invited } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/settings/users");
revalidatePath("/dashboard");
}
-211
View File
@@ -1,211 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { MODULE_KEYS } from "@/lib/modules";
import { getVisibleSteps, STEP_KEYS, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
import { advanceTarget, isAwaitingValidation } from "@/lib/onboarding/state";
import { loadRoleContext } from "@/server/roles";
import { evaluateFt } from "@/lib/ft-rules";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Onboarding-Wizard — Fortschritts-/Review-State-Machine (Story A1-1/F2).
* Bearbeiter (`onboarding:use`): `advanceStep` (offen→in_bearbeitung→zur_validierung,
* zurueckgewiesen→in_bearbeitung), `resetStep`. Validator (`validate_objects`):
* `validateStep` (→validiert) und `rejectStep` (→zurueckgewiesen, mit Kommentar).
* Bearbeitung und Validierung sind entkoppelt: jeder sichtbare Schritt ist jederzeit
* bearbeitbar (Parallelisierung), die Freigaben erfolgen unabhängig.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
function isStepKey(v: unknown): v is StepKey {
return typeof v === "string" && (STEP_KEYS as readonly string[]).includes(v);
}
/** Sichtbarkeits-Kontext: alle Modul-Keys außer den ausdrücklich deaktivierten. */
async function loadCtx(db: Db): Promise<StepCtx> {
const rows = await db.tenantModule.findMany();
const disabled = new Set(rows.filter((m) => !m.enabled).map((m) => m.moduleKey));
return { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
}
async function statusMap(db: Db): Promise<Map<string, ObjectReviewStatus>> {
const rows = await db.onboardingProgress.findMany();
return new Map(rows.map((r) => [r.stepKey, r.status]));
}
/**
* Gemeinsame Vorprüfung: Schritt existiert und ist im aktuellen Scope sichtbar.
* Bearbeitung ist von der Validierung entkoppelt (Parallelisierung) — es gibt keinen
* Vorgänger-Validierungs-Zwang mehr; jeder sichtbare Schritt ist bearbeitbar.
*/
async function requireVisibleStep(db: Db, stepKey: string): Promise<{ steps: ReturnType<typeof getVisibleSteps>; current: ObjectReviewStatus }> {
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
const steps = getVisibleSteps(await loadCtx(db));
const idx = steps.findIndex((s) => s.key === stepKey);
if (idx === -1) throw new Error("Schritt ist im aktuellen Scope nicht sichtbar.");
const map = await statusMap(db);
const statusOf = (k: StepKey) => map.get(k) ?? "offen";
return { steps, current: statusOf(stepKey as StepKey) };
}
async function setStatus(db: Db, tenantId: string, stepKey: string, status: ObjectReviewStatus, review?: { reviewerId: string | null; reviewComment: string | null }) {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey } },
update: { status, ...(review ?? {}) },
create: { tenantId, stepKey, status, ...(review ?? {}) },
});
}
/** Bearbeiter-Aktion: Schritt eine Stufe weiterschieben bzw. nach Rückweisung nacharbeiten. */
export async function advanceStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
const { current } = await requireVisibleStep(db, stepKey);
const next = advanceTarget(current);
if (!next) return; // zur_validierung/validiert: keine Bearbeiter-Aktion
await setStatus(db, session.user.tenantId, stepKey, next);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: next } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt bestätigen (nur aus `zur_validierung`). */
export async function validateStep(stepKey: string) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
await setStatus(db, session.user.tenantId, stepKey, "validiert", { reviewerId: session.user.id, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "validiert" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt zurückweisen (nur aus `zur_validierung`), mit Begründung. */
export async function rejectStep(stepKey: string, formData: FormData) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
const comment = (formData.get("comment") as string | null)?.trim() || null;
await setStatus(db, session.user.tenantId, stepKey, "zurueckgewiesen", { reviewerId: session.user.id, reviewComment: comment });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "zurueckgewiesen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Bearbeiter-Aktion: Schritt wieder öffnen. */
export async function resetStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
await setStatus(db, session.user.tenantId, stepKey, "offen", { reviewerId: null, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "offen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Scope (Ebene 1 „Fundament", Story A2-2): Prüfziele, Geltungsbereich, Standorte und
* Ausschlüsse als `WizardScope` speichern. Informationssicherheit ist stets aktiv;
* Prototypenschutz/Datenschutz optional. Schiebt den Schritt „scope" von offen →
* in_bearbeitung an. Das Assessment-Level (Schutzbedarf) ist read-only (A2-1) und
* wird hier nicht geschrieben.
*/
export async function saveScope(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const pruefziele = ["informationssicherheit"];
if (formData.get("pz_prototypenschutz")) pruefziele.push("prototypenschutz");
if (formData.get("pz_datenschutz")) pruefziele.push("datenschutz");
const geltungsbereich = (formData.get("geltungsbereich") as string | null)?.trim() || null;
const ausschluesse = (formData.get("ausschluesse") as string | null)?.trim() || null;
const standorte = ((formData.get("standorte") as string | null) ?? "")
.split("\n").map((s) => s.trim()).filter(Boolean);
await db.wizardScope.upsert({
where: { tenantId: session.user.tenantId },
update: { pruefziele, geltungsbereich, standorte, ausschluesse },
create: { tenantId: session.user.tenantId, pruefziele, geltungsbereich, standorte, ausschluesse },
});
// State-Machine: „scope" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "scope" } } });
if (!prog || prog.status === "offen") {
await setStatus(db, session.user.tenantId, "scope", "in_bearbeitung");
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "wizard_scope", after: { pruefziele, standorte: standorte.length } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Rollen/Funktionstrennung (Schritt 3, Story A4-2): FT-01…06 auswerten und für die
* task-würdigen Findings Aufgaben anlegen. FT-01/FT-04 laufen über die bestehenden
* Fragebogen-Trigger (`isb_equals_it`/`isb_not_named`, idempotent), FT-03/FT-05 als
* neue Vorschläge (idempotent über `origin`). Schiebt den Schritt „roles" an.
*/
export async function createFtTasks() {
const { session, db } = await guard("onboarding:use");
const findings = evaluateFt(await loadRoleContext(db));
const reuse = findings.filter((f) => f.task?.reuse).map((f) => f.task!.origin);
if (reuse.length) await proposeTasksFromTriggers(reuse);
for (const f of findings.filter((x) => x.task && !x.task.reuse)) {
const origin = `wizard:${f.task!.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: f.task!.title, origin,
status: "PROPOSED", priority: "hoch", createdById: session.user.id,
links: f.task!.control ? { control: f.task!.control } : undefined,
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "roles" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "roles", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "ft_check", after: { findings: findings.map((f) => f.code) } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Assets (Schritt 5, Story A5-1): nutzt das bestehende Assetinventar/BIA (keine
* Doppel-Datenhaltung) und erzeugt Aufgaben für Lücken (Assets ohne Eigentümer bzw.
* ohne Schutzbedarfsbewertung). Idempotent über `origin`. Schiebt den Schritt „assets" an.
*/
export async function createAssetGapTasks() {
const { session, db } = await guard("onboarding:use");
const [withoutOwner, unrated] = await Promise.all([
db.asset.count({ where: { ownerId: null } }),
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
]);
const gaps: { origin: string; title: string; control: string }[] = [];
if (withoutOwner > 0) gaps.push({ origin: "asset-no-owner", title: `Eigentümer für ${withoutOwner} Asset(s) zuweisen`, control: "1.3.1" });
if (unrated > 0) gaps.push({ origin: "asset-unrated", title: `Schutzbedarf (C/I/A) für ${unrated} Asset(s) bewerten`, control: "1.3.2" });
for (const g of gaps) {
const origin = `wizard:${g.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: g.title, origin,
status: "PROPOSED", priority: "mittel", createdById: session.user.id, links: { control: g.control },
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "assets" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "assets", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "asset_gap_check", after: { withoutOwner, unrated } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
-320
View File
@@ -1,320 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { type Permission } from "@/server/rbac";
import { isCentralVariable } from "@/lib/policy-variables";
import { writeAuditLog } from "@/server/audit";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { DOMAIN_LABELS } from "@/lib/control-domain";
import type { Domain } from "@prisma/client";
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Optionales Redirect-Ziel aus dem Formular („im Wizard bleiben"). Lässt NUR interne,
* absolute Pfade zu (kein Protokoll/Host, kein `//`, kein Backslash) — verhindert
* Open-Redirects. Ohne gültiges Ziel greift der jeweilige Default.
*/
const safeInternalPath = (v: FormDataEntryValue | null): string | null => {
const s = typeof v === "string" ? v.trim() : "";
if (!s.startsWith("/") || s.startsWith("//") || s.includes("\\") || s.includes("://")) return null;
return s;
};
const guardModule = moduleGuard("policies");
/** Modul-Gating „policies" + RBAC. Default-Recht policy:write; Freigabe nutzt policy:approve. */
async function guard(permission: Permission = "policy:write") {
return guardModule(permission);
}
/* ── Krypto-Register (VA-07) ── */
export async function addCryptoEntry(formData: FormData) {
const { session, db } = await guard();
const last = await db.cryptoEntry.aggregate({ _max: { orderIdx: true } });
await db.cryptoEntry.create({
data: {
tenantId: session.user.tenantId,
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
revalidatePath("/policies", "layout");
}
export async function updateCryptoEntry(id: string, formData: FormData) {
const { session, db } = await guard();
await db.cryptoEntry.update({
where: { id },
data: {
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
revalidatePath("/policies", "layout");
}
export async function deleteCryptoEntry(id: string) {
const { session, db } = await guard();
await db.cryptoEntry.delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
revalidatePath("/policies", "layout");
}
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
export async function updateHandlingRule(classId: string, aspectId: string, formData: FormData) {
const { session, db } = await guard();
const text = str(formData.get("text"));
await db.handlingRule.upsert({
where: { classId_aspectId: { classId, aspectId } },
update: { text },
create: { tenantId: session.user.tenantId, classId, aspectId, text },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
revalidatePath("/policies", "layout");
}
export async function addHandlingAspect(formData: FormData) {
const { session, db } = await guard();
const last = await db.handlingAspect.aggregate({ _max: { orderIdx: true } });
await db.handlingAspect.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
category: str(formData.get("category")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
revalidatePath("/policies", "layout");
}
export async function addClassificationClass(formData: FormData) {
const { session, db } = await guard();
const last = await db.classificationClass.aggregate({ _max: { orderIdx: true } });
await db.classificationClass.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
description: str(formData.get("description")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
revalidatePath("/policies", "layout");
}
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
export async function updateRiskClass(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskMatrixClass.update({
where: { id },
data: {
name: z.string().trim().min(1).parse(formData.get("name")),
maxScore: z.coerce.number().int().min(1).max(999).parse(formData.get("maxScore")),
acceptance: str(formData.get("acceptance")),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
revalidatePath("/policies", "layout");
}
export async function updateEwLevel(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskEwLevel.update({
where: { id },
data: { label: z.string().trim().min(1).parse(formData.get("label")), definition: str(formData.get("definition")) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_ew_level", entityId: id });
revalidatePath("/policies", "layout");
}
export async function updateDamageDimension(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskDamageDimension.update({
where: { id },
data: {
name: z.string().trim().min(1).parse(formData.get("name")),
levels: { "1": str(formData.get("l1")), "2": str(formData.get("l2")), "3": str(formData.get("l3")), "4": str(formData.get("l4")) },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_damage_dimension", entityId: id });
revalidatePath("/policies", "layout");
}
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
/** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */
export async function updatePolicyTemplate(code: string, formData: FormData) {
const { session, db } = await guard();
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } });
// Fehlende {{VARIABLEN}} automatisch anlegen (Experten-Modus: neue Variablen)
const referenced = new Set([...rawMarkdown.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)].map((m) => m[1]));
if (referenced.size) {
const existing = new Set((await db.policyVariable.findMany({ select: { key: true } })).map((v) => v.key));
const last = await db.policyVariable.aggregate({ _max: { orderIdx: true } });
let idx = (last._max.orderIdx ?? 0) + 1;
for (const key of referenced) {
if (existing.has(key)) continue;
const isFlag = key.startsWith("FLAG_");
await db.policyVariable.create({
data: { tenantId: session.user.tenantId, key, title: key, kind: isFlag ? "boolean" : "string", groupName: "Eigene", value: isFlag ? "false" : "", orderIdx: idx++ },
});
}
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit?expert=1`);
}
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */
export async function updatePolicyVariable(key: string, formData: FormData) {
const { session, db } = await guard();
const variable = await db.policyVariable.findFirst({ where: { key }, select: { key: true, groupName: true } });
if (variable && isCentralVariable(variable)) throw new Error("Zentrale Variablen werden ausschließlich in den Einstellungen gepflegt.");
const value = String(formData.get("value") ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
revalidatePath("/policies", "layout");
}
/**
* Dokument-bezogene Variablen gebündelt speichern (Bearbeitungsmodus). Nur die
* übergebenen Schlüssel (Feld `keys` = kommagetrennt) werden aus dem Formular
* gelesen; Werte gelten zentral für alle Dokumente (eine Pflegestelle, §8).
*/
export async function updateScopedVariables(code: string, formData: FormData) {
const { session, db } = await guard();
const keys = String(formData.get("keys") ?? "").split(",").map((k) => k.trim()).filter(Boolean);
// Zentrale Variablen (Organisation/Rollen) niemals aus dem Richtlinien-Editor überschreiben.
const rows = keys.length ? await db.policyVariable.findMany({ where: { key: { in: keys } }, select: { key: true, groupName: true } }) : [];
const centralKeys = new Set(rows.filter(isCentralVariable).map((r) => r.key));
for (const key of keys) {
if (centralKeys.has(key) || !formData.has(`var_${key}`)) continue;
const value = String(formData.get(`var_${key}`) ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variables", entityId: code, after: { keys } });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe ── */
/**
* Richtlinie zur Freigabe einreichen: der Einreicher wählt einen konkreten
* Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher). Das Dokument geht
* auf IN_FREIGABE und es entsteht eine Aufgabe (policy_approval) für den Freigeber.
* Freigabe/Ablehnung erfolgen im Aufgaben-Modul (actions/tasks.ts).
*/
export async function submitForApproval(code: string, formData: FormData) {
const { session, db } = await guard();
const approverId = String(formData.get("approverId") ?? "").trim();
if (!approverId) throw new Error("Bitte einen Freigeber auswählen.");
if (approverId === session.user.id) throw new Error("Vier-Augen-Prinzip: Der Freigeber muss eine andere Person sein.");
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const approver = await db.user.findFirst({
where: { id: approverId, status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
select: { id: true },
});
if (!approver) throw new Error("Ungültiger Freigeber (kein aktiver Nutzer mit Freigaberecht).");
// Vorherige offene Freigabe-Aufgaben dieses Dokuments abschließen (Resubmit).
await db.task.updateMany({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, data: { status: "CANCELLED" } });
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
const note = str(formData.get("note"));
const task = await db.task.create({
data: {
tenantId: session.user.tenantId, type: "policy_approval",
title: `Freigabe: ${doc.code} — ${doc.title}`,
entityType: "policy_document", entityId: doc.id, entityRef: doc.code,
assigneeId: approver.id, createdById: session.user.id, status: "OPEN",
comments: note ? { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: note } } : undefined,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE", approverId: approver.id, taskId: task.id } });
// SEC1: der ausgewählte Freigeber wird über die Anfrage informiert.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_approval_requested",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: approver.id,
actorId: session.user.id,
});
revalidatePath("/policies", "layout");
revalidatePath("/tasks");
revalidatePath("/onboarding", "layout");
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Fachbereich (Domain) — Ableitung & manuelles Überschreiben ── */
/** Fachbereiche idempotent ableiten (programmatisch wiederverwendbar, z. B. Import). */
export async function derivePolicyDomains(): Promise<number> {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
}
return filled;
}
/** Formular-Variante des Ableitens (Button in der Ansicht): leitet mit Ergebnis-Banner zurück. */
export async function submitDeriveDomains(formData: FormData) {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies?view=domains&derived=${filled}`);
}
/** Manuelles Setzen/Zurücksetzen des Fachbereichs (Dropdown in der Ansicht). Leerwert = zurücksetzen. */
export async function setPolicyDomain(code: string, formData: FormData) {
const { session, db } = await guard();
const raw = str(formData.get("domain"));
const domain: Domain | null = raw && raw in DOMAIN_LABELS ? (raw as Domain) : null;
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
await db.policyDocument.update({ where: { id: doc.id }, data: { domain } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { domain } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
-75
View File
@@ -1,75 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { REVIEW_CYCLES, computeNextReview, bumpMinorVersion } from "@/lib/review-cycle";
/**
* AP5 — Dokumentenlenkung (Modul „policies"): Prüfzyklus/-termin (A.5.1), dokumentierte
* Neuversion mit Historie und Lesebestätigung je Version (SPEC §4.6, Klausel 7.3, A.6.3).
* moduleGuard("policies"); Verwaltung verlangt `policy:write`, Bestätigen `policy:read`.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Prüfzyklus setzen und daraus den nächsten Prüftermin berechnen. */
export async function setDocReviewCycle(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const cycleRaw = str(formData.get("reviewCycle"));
const cycle = cycleRaw in REVIEW_CYCLES ? cycleRaw : null;
await db.policyDocument.update({
where: { id: docId },
data: { reviewCycle: cycle, nextReviewAt: computeNextReview(new Date(), cycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { reviewCycle: cycle } });
revalidatePath("/policies/control");
}
/**
* Dokument als geprüft markieren: aktuelle Version in die Historie schreiben, Minor-
* Version erhöhen (neue Version → Lesebestätigungen müssen erneuert werden, da
* versions-scoped) und den nächsten Prüftermin aus dem Zyklus neu setzen.
*/
export async function markDocReviewed(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true, title: true, reviewCycle: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
// Momentaufnahme der bisherigen Version.
await db.policyDocumentVersion.create({
data: {
tenantId: session.user.tenantId,
policyDocumentId: docId,
version: doc.version,
title: doc.title,
changeNote: str(formData.get("changeNote")) || "Turnusmäßige Überprüfung",
createdBy: session.user.id,
},
});
const newVersion = bumpMinorVersion(doc.version);
await db.policyDocument.update({
where: { id: docId },
data: { version: newVersion, nextReviewAt: computeNextReview(new Date(), doc.reviewCycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { from: doc.version, to: newVersion } });
revalidatePath("/policies/control");
}
/** Aktuelle Version des Dokuments als gelesen bestätigen (versions-scoped, idempotent). */
export async function acknowledgePolicy(docId: string) {
const { session, db } = await guard("policy:read");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await db.policyAcknowledgement.upsert({
where: {
tenantId_policyDocumentId_userId_version: {
tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version,
},
},
update: {},
create: { tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version },
});
revalidatePath("/policies/control");
}
-54
View File
@@ -1,54 +0,0 @@
"use server";
import { join } from "node:path";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma, dbForTenant } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
/**
* Richtlinien-Vorlagen (Story B4-1): das Vorlagenpaket mandantenweit nicht-destruktiv
* importieren/aktualisieren (Diff/Upsert, archivedAt; Status/Freigabe/Overrides und
* nutzergepflegte Variablenwerte bleiben erhalten). Kapselt prisma/import-policies.ts
* als serverseitig aufrufbare Action. Einstieg: Button unter /policies (Self-Service)
* und im Admin (siehe importPolicyPackageForTenant). Import bei Modul-Aktivierung:
* siehe toggleTenantModule.
*/
const guard = moduleGuard("policies");
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
export async function importPolicyPackage() {
const { session } = await guard("policy:write");
const tenantId = session.user.tenantId;
// AP1: je Framework des Mandanten importieren. Anforderungen sind framework-scoped,
// die geteilten Inhalte (Dokumente/Variablen/…) werden nur beim ersten Framework
// abgeglichen (reconcileShared). Einzel-Framework-Mandant = unverändertes Verhalten.
const frameworks = await getTenantFrameworks(prisma, tenantId);
const agg = { dA: 0, dU: 0, dAr: 0, dR: 0, rA: 0, rU: 0, rAr: 0 };
for (const [i, framework] of frameworks.entries()) {
// Quelle: veröffentlichte DB-Vorlage in der Sprache des Mandanten (DE-/Datei-Fallback).
const { pkg } = await resolvePackageForTenant(prisma, tenantId, SEED_DIR, framework);
const result = await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
reconcileShared: i === 0,
});
// Story B6: übernommene Paket-Version je Framework stempeln (kontrollierte Übernahme).
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
const d = result.report.documents;
const r = result.report.requirements;
agg.dA += d.added; agg.dU += d.updated; agg.dAr += d.archived; agg.dR += d.reactivated;
agg.rA += r.added; agg.rU += r.updated; agg.rAr += r.archived;
}
// Fachbereiche neuer Richtlinien direkt ableiten (idempotent, tenant-scoped).
await deriveDomainsCore(dbForTenant(tenantId), tenantId);
revalidatePath("/policies", "layout");
revalidatePath("/policies/updates");
revalidatePath("/dashboard");
const q = `d${agg.dA}-${agg.dU}-${agg.dAr}-${agg.dR}_r${agg.rA}-${agg.rU}-${agg.rAr}`;
redirect(`/policies?import=${q}`);
}
-313
View File
@@ -1,313 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Framework } from "@prisma/client";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Bearbeitung der globalen Richtlinien-VORLAGEN (Phase 2). Getrennte
* Plattform-Session (requirePlatformFullAdmin, kein Mandantenkontext). Bearbeitet wird
* ausschließlich der aktuelle ENTWURF (DRAFT) — veröffentlichte Versionen sind
* unveränderlich. Inhalte je Sprache (locale de|en). Die Veröffentlichung (Phase 3)
* wandelt den Entwurf in eine neue veröffentlichte Version.
*
* EXEMPT vom Modul-Guard (eigene Plattform-Auth, kein per TenantModule gegatetes Fachmodul).
*/
const LOCALES = new Set(["de", "en"]);
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
// Der Plattform-Vorlagen-Editor ist framework-fähig (TISAX ODER ISO 27001). Alle
// Draft-/Publish-Operationen sind auf das jeweilige Framework gescoped, damit ein
// Publish nicht die Version des anderen Frameworks archiviert (Falle 1.2). Form-Actions
// erhalten das Framework als verstecktes Feld, gebundene Actions per .bind().
function frameworkFrom(fd: FormData): Framework {
return String(fd.get("framework")) === "ISO_27001" ? "ISO_27001" : "TISAX";
}
function bumpVersion(v?: string | null): string {
if (!v) return "1.0";
const m = v.match(/^(\d+)\.(\d+)/);
return m ? `${m[1]}.${Number(m[2]) + 1}` : `${v}.1`;
}
async function getDraft(framework: Framework) {
return prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } });
}
/** Sicherstellen, dass die Ziel-Version ein Entwurf ist (nur Entwürfe sind bearbeitbar). */
async function assertDraftVersion(versionId: string) {
const v = await prisma.policyTemplateVersion.findUnique({ where: { id: versionId }, select: { status: true } });
if (!v || v.status !== "DRAFT") {
throw new Error("Nur der Entwurf ist bearbeitbar. Bitte zuerst einen Entwurf anlegen.");
}
}
/** Kinder einer Version (alle Sprachen) in eine andere Version kopieren. */
async function cloneChildren(fromId: string, toId: string) {
const [docs, reqs, vars, baseline, evidence] = await Promise.all([
prisma.policyTemplateDoc.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateRequirement.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateVariable.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateBaselineParam.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateEvidence.findMany({ where: { versionId: fromId } }),
]);
await prisma.$transaction([
prisma.policyTemplateDoc.createMany({
data: docs.map((d) => ({
versionId: toId, locale: d.locale, code: d.code, type: d.type, title: d.title,
docVersion: d.docVersion, policyCode: d.policyCode, fulfills: d.fulfills, domain: d.domain,
rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx,
})),
}),
prisma.policyTemplateRequirement.createMany({
data: reqs.map((r) => ({
versionId: toId, locale: r.locale, reqId: r.reqId, policyCode: r.policyCode, control: r.control,
obligation: r.obligation, condition: r.condition, requirement: r.requirement,
implementation: r.implementation, vaCodes: r.vaCodes, nachweisLink: r.nachweisLink, orderIdx: r.orderIdx,
})),
}),
prisma.policyTemplateVariable.createMany({
data: vars.map((v) => ({
versionId: toId, locale: v.locale, key: v.key, title: v.title, kind: v.kind,
groupName: v.groupName, value: v.value, required: v.required, orderIdx: v.orderIdx,
})),
}),
prisma.policyTemplateBaselineParam.createMany({
data: baseline.map((b) => ({
versionId: toId, locale: b.locale, blId: b.blId, section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx,
})),
}),
prisma.policyTemplateEvidence.createMany({
data: evidence.map((e) => ({
versionId: toId, locale: e.locale, nr: e.nr, policyCode: e.policyCode, nachweis: e.nachweis,
quelle: e.quelle, verantwortlich: e.verantwortlich, turnus: e.turnus,
})),
}),
]);
}
/**
* Aktuellen Entwurf liefern oder neu anlegen. Ein neuer Entwurf wird als Kopie der
* zuletzt veröffentlichten Version erstellt (Bearbeitung startet vom aktuellen Stand,
* inkl. beider Sprachen). Version des Entwurfs = nächste freie Minor-Version.
*/
export async function ensureDraft(framework: Framework): Promise<string> {
const { admin } = await requirePlatformFullAdmin();
const existing = await getDraft(framework);
if (existing) return existing.id;
const published = await prisma.policyTemplateVersion.findFirst({
where: { status: "PUBLISHED", framework },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
// Freie Versionsnummer finden (darf mit keiner bestehenden Version dieses Frameworks kollidieren).
let version = bumpVersion(published?.version);
while (await prisma.policyTemplateVersion.findUnique({ where: { framework_version: { framework, version } }, select: { id: true } })) {
version = bumpVersion(version);
}
const draft = await prisma.policyTemplateVersion.create({ data: { framework, version, status: "DRAFT" } });
if (published) await cloneChildren(published.id, draft.id);
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_version", entityId: draft.id, after: { framework, version, clonedFrom: published?.version ?? null } });
revalidatePath("/templates");
return draft.id;
}
/** Form-tauglicher Void-Wrapper (Button „Entwurf anlegen/bearbeiten"), per .bind(framework). */
export async function startDraftAction(framework: Framework): Promise<void> {
await ensureDraft(framework);
}
/** Entwurf verwerfen (löscht die Entwurfs-Version samt Inhalten; Cascade). */
export async function discardDraft(framework: Framework): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(framework);
if (!draft) return;
await prisma.policyTemplateVersion.delete({ where: { id: draft.id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_version", entityId: draft.id, after: { discarded: draft.version } });
revalidatePath("/templates");
}
/**
* Entwurf veröffentlichen: wird zur neuen veröffentlichten Version. Bisher veröffentlichte
* Versionen werden archiviert (nur die neue bleibt PUBLISHED). Neue Mandanten erhalten sie
* automatisch bei der Provisionierung; bestehende sehen unter /policies/updates „Update
* verfügbar" und übernehmen selbst (nicht-destruktiv). Optionaler Änderungshinweis (notes).
*/
export async function publishDraft(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const framework = frameworkFrom(formData);
const draft = await getDraft(framework);
if (!draft) throw new Error("Kein Entwurf zum Veröffentlichen vorhanden.");
const docCount = await prisma.policyTemplateDoc.count({ where: { versionId: draft.id } });
if (docCount === 0) throw new Error("Der Entwurf enthält keine Dokumente.");
const version = (str(formData, "version") || draft.version).trim();
if (!/^\d+(\.\d+)*$/.test(version)) throw new Error("Ungültige Versionsnummer (z. B. 2.2).");
const notes = str(formData, "notes") || null;
const clash = await prisma.policyTemplateVersion.findFirst({ where: { version, framework, id: { not: draft.id } }, select: { id: true } });
if (clash) throw new Error(`Version ${version} existiert bereits. Bitte eine andere Versionsnummer wählen.`);
await prisma.$transaction([
// Bisher veröffentlichte Versionen DIESES Frameworks archivieren — nur die neue bleibt PUBLISHED.
prisma.policyTemplateVersion.updateMany({ where: { status: "PUBLISHED", framework }, data: { status: "ARCHIVED" } }),
prisma.policyTemplateVersion.update({
where: { id: draft.id },
data: { status: "PUBLISHED", version, notes, publishedAt: new Date(), publishedBy: admin.id },
}),
]);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_version", entityId: draft.id, after: { published: version } });
revalidatePath("/templates");
revalidatePath("/policies/updates");
revalidatePath("/policies", "layout");
}
// ── Dokumente ───────────────────────────────────────────────────────────────
/** Fehlende {{VARIABLEN}} aus dem Rohtext als String-Variablen im Entwurf anlegen (Parität zum Mandanten-Editor). */
async function ensureVariablesForMarkdown(versionId: string, locale: string, markdown: string) {
const found = new Set<string>();
for (const m of markdown.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)) found.add(m[1]);
if (found.size === 0) return;
const existing = await prisma.policyTemplateVariable.findMany({ where: { versionId, locale }, select: { key: true } });
const have = new Set(existing.map((v) => v.key));
const missing = [...found].filter((k) => !have.has(k));
if (missing.length === 0) return;
const maxOrder = existing.length;
await prisma.policyTemplateVariable.createMany({
data: missing.map((key, i) => ({ versionId, locale, key, title: key, kind: "string", groupName: "Eigene", value: "", required: false, orderIdx: maxOrder + i })),
skipDuplicates: true,
});
}
export async function updateTemplateDoc(docId: string, formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const doc = await prisma.policyTemplateDoc.findUnique({ where: { id: docId } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await assertDraftVersion(doc.versionId);
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
const title = str(formData, "title") || doc.title;
await prisma.policyTemplateDoc.update({ where: { id: docId }, data: { rawMarkdown, title } });
await ensureVariablesForMarkdown(doc.versionId, doc.locale, rawMarkdown);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_doc", entityId: docId, after: { code: doc.code, locale: doc.locale } });
revalidatePath(`/templates/${doc.locale}/${doc.code}`);
revalidatePath("/templates");
}
export async function createTemplateDoc(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden. Bitte zuerst einen Entwurf anlegen.");
const locale = str(formData, "locale");
const code = str(formData, "code").toUpperCase();
const type = str(formData, "type") || "RICHTLINIE";
const title = str(formData, "title") || code;
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!/^[A-Z0-9][A-Z0-9-]*$/.test(code)) throw new Error("Ungültiger Code (z. B. R15, VA-21, EIG-1).");
const dup = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: draft.id, locale, code } }, select: { id: true } });
if (dup) throw new Error(`Dokument ${code} (${locale}) existiert bereits im Entwurf.`);
const maxOrder = await prisma.policyTemplateDoc.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateDoc.create({
data: {
versionId: draft.id, locale, code, type: type as never, title,
rawMarkdown: `# ${title}\n\n`, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1,
},
});
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_doc", entityId: `${code}`, after: { code, locale, type } });
revalidatePath("/templates");
}
// ── Anforderungen (Control-Mapping) ───────────────────────────────────────────
export async function upsertTemplateRequirement(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
reqId: str(formData, "reqId"),
policyCode: str(formData, "policyCode"),
control: str(formData, "control"),
obligation: str(formData, "obligation") || "MUSS",
condition: str(formData, "condition") || null,
requirement: str(formData, "requirement"),
implementation: str(formData, "implementation"),
vaCodes: str(formData, "vaCodes").split(",").map((s) => s.trim()).filter(Boolean),
nachweisLink: str(formData, "nachweisLink") || null,
};
if (id) {
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) throw new Error("Anforderung nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.update({ where: { id }, data });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.reqId) throw new Error("Anforderungs-ID fehlt.");
const dup = await prisma.policyTemplateRequirement.findUnique({ where: { versionId_locale_reqId: { versionId: draft.id, locale, reqId: data.reqId } }, select: { id: true } });
if (dup) throw new Error(`Anforderung ${data.reqId} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateRequirement.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateRequirement.create({ data: { versionId: draft.id, locale, ...data, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_requirement", entityId: data.reqId, after: { locale } });
revalidatePath(`/templates/${locale}/requirements`);
}
export async function deleteTemplateRequirement(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
}
// ── Variablen ─────────────────────────────────────────────────────────────────
export async function upsertTemplateVariable(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
key: str(formData, "key").toUpperCase().replace(/[^A-Z0-9_]/g, "_"),
title: str(formData, "title"),
kind: str(formData, "kind") === "boolean" ? "boolean" : "string",
groupName: str(formData, "groupName") || null,
value: str(formData, "value"),
required: formData.get("required") === "on" || formData.get("required") === "true",
};
if (id) {
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) throw new Error("Variable nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.update({ where: { id }, data: { title: data.title || ex.title, kind: data.kind, groupName: data.groupName, value: data.value, required: data.required } });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.key) throw new Error("Variablen-Key fehlt.");
const dup = await prisma.policyTemplateVariable.findUnique({ where: { versionId_locale_key: { versionId: draft.id, locale, key: data.key } }, select: { id: true } });
if (dup) throw new Error(`Variable ${data.key} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateVariable.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateVariable.create({ data: { versionId: draft.id, locale, ...data, title: data.title || data.key, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_variable", entityId: data.key, after: { locale } });
revalidatePath(`/templates/${locale}/variables`);
}
export async function deleteTemplateVariable(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
}
-186
View File
@@ -1,186 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { storage } from "@/server/storage/adapter";
import { escapeHtml } from "@/lib/policy-render";
/**
* Eigene Richtlinie hochladen (Story B4-2). Legt ein PolicyDocument (Typ EIGENES)
* mit Pflicht-Control-Zuordnung an; die Zuordnung erzeugt PolicyRequirement-Zeilen
* (Namensraum EIG-*, außerhalb des Paket-Namensraums → vom Re-Import unberührt) und
* fließt zusätzlich als <!-- REQ <control> -->-Anker in den Rohtext (Nachweislage,
* Schritt 7). Die Datei wird über den gekapselten Storage-Adapter (Stub, S1 folgt)
* abgelegt — im Stub nur Metadaten/Key, keine Bytes.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Upload-Vertrag (F-15) — verbindliche Anforderungen an JEDES Storage-Backend,
* insbesondere die echte Implementierung aus Epic S1 (S3/MinIO). Der aktuelle
* Storage-Adapter ist ein Stub, der keine Bytes persistiert; die untenstehenden
* Kontrollen dürfen daher NICHT als „durch den Stub abgesichert" missverstanden
* werden, sondern sind Pflicht auf Anwendungsebene:
* 1. Größenlimit VOR dem Einlesen in den RAM prüfen (kein DoS über Riesendatei).
* 2. Allowlist für Endungen UND MIME-Typen (PDF, DOCX, ODT).
* 3. Inhaltsprüfung über Magic Bytes — dem clientseitig gesetzten `f.type`
* wird NICHT vertraut; der abgeleitete, geprüfte Typ wird gespeichert.
* Beim echten Backend zusätzlich: Ablage außerhalb des Web-Roots, Auslieferung
* mit `Content-Disposition: attachment` und `X-Content-Type-Options: nosniff`
* (F-07), sowie AV-Scan vor Freigabe.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB Obergrenze
type UploadKind = "pdf" | "docx" | "odt";
// Endung → erlaubter Typ.
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
// Kanonischer, serverseitig gesetzter MIME-Typ je geprüftem Inhalt.
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
// Vom Client zulässigerweise gesetzte MIME-Typen (nur als Zusatzsignal geprüft,
// nicht als alleinige Wahrheit — die Magic-Byte-Prüfung ist maßgeblich).
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/octet-stream",
"",
],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) =>
sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT sind ZIP-Container)
/** Prüft, ob der Inhalt (Magic Bytes) zum per Endung erwarteten Typ passt. */
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
// DOCX und ODT sind beide ZIP-Container.
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
// ODT legt als erstes, unkomprimiertes Element die "mimetype"-Datei ab; ihr
// Inhalt "…opendocument.text" liegt in den ersten ~80 Bytes im Klartext.
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
/** Endung aus dem (bereits an anderer Stelle bereinigten) Dateinamen ziehen. */
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
export async function uploadOwnPolicy(formData: FormData) {
const { session, db } = await guard("policy:write");
const title = str(formData.get("title"));
if (!title) throw new Error("Bitte einen Titel angeben.");
const controls = formData.getAll("controls").map((c) => String(c).trim()).filter(Boolean);
if (controls.length === 0) throw new Error("Bitte mindestens ein Control zuordnen (Pflicht).");
const reqIds = str(formData.get("reqIds")).split(",").map((s) => s.trim()).filter(Boolean);
// Datei über den gekapselten Storage-Adapter (Stub) ablegen — mit vollständiger
// Eingangsvalidierung (F-15), da der clientseitige `f.type` nicht vertrauenswürdig ist.
const file = formData.get("file");
let stored: { storageKey: string; filename: string; size: number } | null = null;
if (file && typeof file === "object" && "arrayBuffer" in file && (file as File).size > 0) {
const f = file as File;
// (1) Größenlimit VOR dem Einlesen in den RAM (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) {
throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
}
// (3) Vom Client gemeldeten MIME-Typ nur als Zusatzsignal prüfen.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) {
throw new Error("MIME-Typ passt nicht zur Dateiendung.");
}
// (4) Inhalt einlesen und über Magic Bytes verifizieren (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) {
throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
}
// Nicht dem clientseitigen `f.type` vertrauen: kanonischen, geprüften MIME speichern.
stored = await storage.put({
tenantId: session.user.tenantId,
filename: f.name,
contentType: CANONICAL_MIME[kind],
bytes,
});
}
// Nächsten freien EIG-Code vergeben.
const count = await db.policyDocument.count({ where: { type: "EIGENES" } });
const code = `EIG-${count + 1}`;
// Titel für die Markdown-Einbettung HTML-kodieren (F-03/F-15): der Titel fließt
// roh in den gespeicherten Rohtext und damit in die Renderpipeline; anders als
// der Dateiname war er bislang unbereinigt. Das DB-Feld `title` bleibt der
// Klartext (React kodiert bei der Anzeige selbst).
const titleMd = escapeHtml(title);
const anchors = controls.map((c) => `<!-- REQ ${c} -->`).join("\n");
const meta = [
`# ${titleMd}`,
"",
`> Eigenes Dokument (Upload).${stored ? ` Datei: ${stored.filename} (${stored.size} Bytes). Storage: ${stored.storageKey}` : " Ohne Datei."}`,
reqIds.length ? `> Zugeordnete Anforderungs-IDs: ${reqIds.join(", ")}` : "",
"",
`Zugeordnete Controls: ${controls.join(", ")}.`,
"",
anchors,
"",
].filter((l) => l !== "").join("\n");
await db.policyDocument.create({
data: {
tenantId: session.user.tenantId,
code, type: "EIGENES", title, status: "ENTWURF",
owner: session.user.id, rawMarkdown: meta, orderIdx: 1000 + count,
},
});
// Control-Mapping → PolicyRequirement-Zeilen (erscheinen in Coverage/Nachweislage).
for (const control of controls) {
await db.policyRequirement.create({
data: {
tenantId: session.user.tenantId,
reqId: `${code}-${control}`, policyCode: code, control,
obligation: "MUSS", condition: null,
requirement: title, implementation: "Eigenes hochgeladenes Dokument.",
vaCodes: [], nachweisLink: stored?.storageKey ?? null,
},
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "policy_document", entityId: code,
after: { type: "EIGENES", controls, hasFile: Boolean(stored) },
});
revalidatePath("/policies", "layout");
redirect(`/policies/${code}`);
}
-553
View File
@@ -1,553 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AssetType, InfoLabel, ProcessCategory } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("bia");
/** Katalog-Vorschläge zu einem übernommenen Prozess (M2, prozessgeführter Flow). */
export interface ProcessCatalogSuggestion {
processId: string;
processName: string;
/** Code des zugrunde liegenden ProcessCatalogEntry (Match über den Namen). */
catalogCode: string;
/** Vorgeschlagene Träger-Asset-Typen (SECONDARY) — Assets-Step. */
suggestedAssetTypes: AssetType[];
/** Vorgeschlagene Klassifizierungslabels primärer Werte — Information-Step. */
suggestedInfoLabels: InfoLabel[];
/** Vorgeschlagene Standard-Risiken (→ RiskCatalogEntry.code) — Risks-Step. */
suggestedRiskCodes: string[];
}
/**
* M2 Strukturanalyse: Lädt die geseedeten Katalog-Vorschläge
* (`ProcessCatalogEntry.suggestedAssetTypes/suggestedInfoLabels/suggestedRiskCodes`)
* zu den bereits ÜBERNOMMENEN Prozessen des Mandanten. Zuordnung Prozess → Katalog
* erfolgt über den Namen (adoptCatalogProcess legt den Prozess unter `entry.name` an).
* Genutzt von den Wizard-Schritten Information/Assets/Risiken, um die Vorschläge als
* Chips/Defaults bzw. Ein-Klick-Übernahme auszuspielen.
*/
export async function getProcessCatalogSuggestions(): Promise<ProcessCatalogSuggestion[]> {
const { db } = await guard("bia:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({
select: {
code: true,
name: true,
suggestedAssetTypes: true,
suggestedInfoLabels: true,
suggestedRiskCodes: true,
},
}),
]);
// v3A: bevorzugt über catalogCode zuordnen (robust), Name nur als Fallback für Altbestand.
const byCode = new Map(catalog.map((c) => [c.code, c]));
const byName = new Map(catalog.map((c) => [c.name, c]));
return processes.flatMap((p) => {
const entry = (p.catalogCode ? byCode.get(p.catalogCode) : undefined) ?? byName.get(p.name);
if (!entry) return [];
return [
{
processId: p.id,
processName: p.name,
catalogCode: entry.code,
suggestedAssetTypes: entry.suggestedAssetTypes,
suggestedInfoLabels: entry.suggestedInfoLabels,
suggestedRiskCodes: entry.suggestedRiskCodes,
},
];
});
}
const processSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
ownerId: z.string().optional(),
// TISAX v3A — mehr fachliche Prozess-Informationen (additiv).
purpose: z.string().trim().max(5000).optional(),
parentId: z.string().optional(),
deputyOwnerId: z.string().optional(),
legalBasis: z.string().trim().max(2000).optional(),
interfaces: z.string().trim().max(2000).optional(),
catalogCode: z.string().trim().max(100).optional(),
});
/** Checkbox („on"/fehlt) → Boolean. */
const bool = (v: FormDataEntryValue | null) => v === "on" || v === "true" || v === "1";
function parseProcessForm(formData: FormData) {
const parsed = processSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
category: formData.get("category"),
ownerId: formData.get("ownerId") || undefined,
purpose: formData.get("purpose") || undefined,
parentId: formData.get("parentId") || undefined,
deputyOwnerId: formData.get("deputyOwnerId") || undefined,
legalBasis: formData.get("legalBasis") || undefined,
interfaces: formData.get("interfaces") || undefined,
catalogCode: formData.get("catalogCode") || undefined,
});
return {
...parsed,
description: parsed.description || null,
ownerId: parsed.ownerId || null,
purpose: parsed.purpose || null,
parentId: parsed.parentId || null,
deputyOwnerId: parsed.deputyOwnerId || null,
legalBasis: parsed.legalBasis || null,
interfaces: parsed.interfaces || null,
catalogCode: parsed.catalogCode || null,
dataProtectionRelevant: bool(formData.get("dataProtectionRelevant")),
prototypeRelevant: bool(formData.get("prototypeRelevant")),
};
}
/** Ein Katalog-Eintrag, wie ihn `adoptEntry` zum Anlegen/Verknüpfen benötigt. */
type AdoptableEntry = { code: string; name: string; category: ProcessCategory };
/**
* Übernimmt EINEN Katalog-Eintrag ins mandanteneigene Register (idempotent über
* catalogCode/Name) und setzt optional `parentId` (Katalog-`parentCode` → Instanz).
* Liefert die Instanz-Id. Interner Helfer für {@link adoptCatalogProcess} — keine
* Server-Action (nicht exportiert).
*/
async function adoptEntry(
db: TenantDb,
actor: { id: string; tenantId: string },
entry: AdoptableEntry,
ownerId: string | null,
parentId: string | null,
): Promise<string> {
// Zuordnung Prozess↔Katalog über catalogCode (Name nur Fallback für Altbestand).
const already = await db.process.findFirst({
where: { OR: [{ catalogCode: entry.code }, { name: entry.name }] },
select: { id: true },
});
if (already) {
await db.process.update({
where: { id: already.id },
data: {
catalogCode: entry.code,
...(ownerId ? { ownerId } : {}),
...(parentId ? { parentId } : {}),
},
});
return already.id;
}
const process = await db.process.create({
data: {
tenantId: actor.tenantId,
name: entry.name,
category: entry.category,
catalogCode: entry.code,
parentId,
ownerId,
createdBy: actor.id,
},
select: { id: true },
});
await writeAuditLog({
tenantId: actor.tenantId,
actorId: actor.id,
action: "create",
entity: "process",
entityId: process.id,
after: { name: entry.name, category: entry.category, fromCatalog: entry.code, parentId },
});
return process.id;
}
/**
* M2 Strukturanalyse: Übernimmt einen Prozess aus dem globalen `ProcessCatalogEntry`
* ins mandanteneigene Register (idempotent je catalogCode). Owner optional. Wird im
* prozessgeführten Wizard-Schritt „Prozesse" genutzt.
*
* TISAX v4B: Beim Übernehmen eines Katalog-Prozesses werden dessen **Teilprozesse**
* (`ProcessCatalogEntry.parentCode == code`) mit angelegt und über `Process.parentId`
* an die übernommene Instanz gehängt (Katalog-`parentCode` → Instanz-`parentId`). Ist
* der übernommene Eintrag selbst ein Teilprozess, wird — sofern der Hauptprozess
* bereits übernommen wurde — dessen Instanz als `parentId` gesetzt.
*/
export async function adoptCatalogProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const code = z.string().min(1).parse(formData.get("code"));
const ownerId = (formData.get("ownerId") as string) || null;
const actor = { id: session.user.id, tenantId: session.user.tenantId };
const entry = await db.processCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden");
// Ist dieser Eintrag ein Teilprozess: bereits übernommene Hauptprozess-Instanz suchen.
const parentInstanceId = entry.parentCode
? (
await db.process.findFirst({
where: { catalogCode: entry.parentCode },
select: { id: true },
})
)?.id ?? null
: null;
const processId = await adoptEntry(db, actor, entry, ownerId, parentInstanceId);
// Teilprozesse des übernommenen Katalog-Prozesses mit anlegen (parentId = Instanz).
const children = await db.processCatalogEntry.findMany({
where: { parentCode: code },
orderBy: { orderIdx: "asc" },
});
for (const child of children) {
await adoptEntry(db, actor, child, null, processId);
}
revalidatePath("/onboarding");
revalidatePath("/processes");
}
export async function createProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const data = parseProcessForm(formData);
// tenantId explizit für die Typen, der Guard injiziert ohnehin
const process = await db.process.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process",
entityId: process.id,
after: data,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
// Aus dem Prozesshaus (Onboarding) heraus angelegt → direkt ins BIA-Popup springen;
// sonst (Modul) in den Bearbeiten-Dialog. Steuerung über verstecktes `returnTo`.
const returnTo = (formData.get("returnTo") as string | null)?.trim();
if (returnTo === "house") {
redirect(`/onboarding?step=processes&bia=${process.id}&biaStep=1`);
}
redirect(`/processes?edit=${process.id}`);
}
export async function updateProcess(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId } });
if (!before) throw new Error("Prozess nicht gefunden");
const data = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: data,
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
/**
* Prozess endgültig entfernen (nicht nur `inScope` deaktivieren). Rechte-/Tenant-gegated
* über den Guard; Audit-Log. Verknüpfungen werden sauber behandelt:
* - **Risiken** (`Risk.processId` optional): Prozessbezug wird gelöst (SetNull), die
* Risiken selbst bleiben erhalten.
* - **Teilprozesse** (`parentId`): werden auf die oberste Ebene gehoben statt
* mitgelöscht (kein stiller Datenverlust).
* - **Träger-Zuordnungen** (`ProcessAsset`) und **BIA-Eintrag** (`BiaEntry`): entfernt
* (DB-seitig ohnehin `onDelete: Cascade`, hier zusätzlich explizit).
*
* `returnTo=house` kehrt ins Prozesshaus (Onboarding) zurück, sonst ins Prozess-Modul.
*/
export async function deleteProcess(processId: string, formData?: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId }, include: { bia: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.risk.updateMany({ where: { processId }, data: { processId: null } });
await db.process.updateMany({ where: { parentId: processId }, data: { parentId: null } });
await db.processAsset.deleteMany({ where: { processId } });
await db.biaEntry.deleteMany({ where: { processId } });
// Prozess-Abhängigkeiten in beide Richtungen lösen (DB-seitig ohnehin Cascade).
await db.processDependency.deleteMany({
where: { OR: [{ sourceProcessId: processId }, { targetProcessId: processId }] },
});
await db.process.delete({ where: { id: processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process",
entityId: processId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
const returnTo = (formData?.get("returnTo") as string | null)?.trim();
if (returnTo === "house") redirect("/onboarding?step=processes");
redirect("/processes");
}
export async function assignAsset(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const processCount = await db.process.count({ where: { id: processId } });
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role },
create: { processId, assetId, role, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
export async function unassignAsset(processId: string, processAssetId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
if (!before) return;
await db.processAsset.delete({ where: { id: processAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_asset",
entityId: processAssetId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/**
* Strukturierte Prozess-Abhängigkeit anlegen/aktualisieren: `source` benötigt `target`
* (Auswahl innerhalb des Mandanten). Idempotent über das Unique-Paar; Selbstbezug
* ausgeschlossen. Beide Prozesse müssen im Mandanten existieren (RLS + Zählung).
*/
export async function addProcessDependency(sourceProcessId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const targetProcessId = z.string().min(1).parse(formData.get("targetProcessId"));
const note = (formData.get("note") as string)?.trim() || null;
if (targetProcessId === sourceProcessId) {
throw new Error("Ein Prozess kann nicht von sich selbst abhängen.");
}
const count = await db.process.count({ where: { id: { in: [sourceProcessId, targetProcessId] } } });
if (count !== 2) throw new Error("Prozess nicht gefunden");
await db.processDependency.upsert({
where: { sourceProcessId_targetProcessId: { sourceProcessId, targetProcessId } },
update: { note },
create: { tenantId: session.user.tenantId, sourceProcessId, targetProcessId, note },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process_dependency",
entityId: sourceProcessId,
after: { sourceProcessId, targetProcessId, note },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/** Prozess-Abhängigkeit wieder entfernen (per Kanten-Id). */
export async function removeProcessDependency(dependencyId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processDependency.findUnique({ where: { id: dependencyId } });
if (!before) return;
await db.processDependency.delete({ where: { id: dependencyId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_dependency",
entityId: dependencyId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
const hours = z
.union([z.literal(""), z.coerce.number().int().min(0).max(100000)])
.transform((v) => (v === "" ? null : v));
export async function saveBia(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const processCount = await db.process.count({ where: { id: processId } });
if (processCount !== 1) throw new Error("Prozess nicht gefunden");
const level = z.coerce.number().int().min(1).max(4);
const data = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
// Kritikalität nach Max-Prinzip aus den Schadenshöhen (SPEC §4.1.2)
const criticality = Math.max(data.impactC, data.impactI, data.impactA);
const before = await db.biaEntry.findUnique({ where: { processId } });
await db.biaEntry.upsert({
where: { processId },
update: { ...data, criticality },
create: { ...data, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: before ? "update" : "create",
entity: "bia_entry",
entityId: processId,
before: before ?? undefined,
after: { ...data, criticality },
});
revalidatePath("/processes");
}
/**
* Kombiniertes Speichern für den aufgeräumten Bearbeiten-Dialog: Stammdaten
* und BIA in einem Vorgang (ein Speichern-Button). Asset-Zuordnung läuft
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
*/
export async function saveProcessAll(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({
where: { id: processId },
include: { bia: true },
});
if (!before) throw new Error("Prozess nicht gefunden");
const master = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data: master });
const level = z.coerce.number().int().min(1).max(4);
const biaData = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
const criticality = Math.max(biaData.impactC, biaData.impactI, biaData.impactA);
await db.biaEntry.upsert({
where: { processId },
update: { ...biaData, criticality },
create: { ...biaData, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: { ...master, bia: { ...biaData, criticality } },
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
// ── TISAX v3A: Prozesshaus & BIA-Popup ───────────────────────────────────────
/**
* Prozesshaus-Aktivierung: `inScope` je Prozess umschalten (Kachel-Schalter). Kein
* Redirect — der Nutzer bleibt im Prozesshaus.
*/
export async function toggleProcessScope(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const inScope = bool(formData.get("inScope"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, inScope: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { inScope } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { inScope: before.inScope },
after: { inScope },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Abschluss des BIA-Popups (Schritt 5): setzt `process.biaStatus` (offen | teilweise |
* komplett) → steuert die Farbkennzeichnung der Prozesshaus-Kachel. Kehrt ins
* Prozesshaus zurück (schließt das Popup).
*/
export async function setBiaStatus(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const biaStatus = z.enum(["offen", "teilweise", "komplett"]).parse(formData.get("biaStatus"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, biaStatus: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { biaStatus } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { biaStatus: before.biaStatus },
after: { biaStatus },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
redirect("/onboarding?step=processes");
}
-92
View File
@@ -1,92 +0,0 @@
"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";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const projectSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
ownerId: z.string().optional(),
classification: z.string().trim().max(200).optional(),
status: z.enum(["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"]),
isbInvolved: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = projectSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
ownerId: formData.get("ownerId") || undefined,
classification: formData.get("classification") || undefined,
status: formData.get("status") || "GEPLANT",
isbInvolved: formData.get("isbInvolved") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
ownerId: p.ownerId || null,
},
profile: {
classification: p.classification || null,
status: p.status,
isbInvolved: p.isbInvolved === "on",
notes: p.notes || null,
},
};
}
export async function createProject(formData: FormData) {
const { session, db } = await guard("asset:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "PROJECT", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.projectProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.projectProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "project", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/assets");
redirect(`/assets?type=PROJECT&edit=${created.id}`);
}
export async function updateProject(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { projectProfile: true } });
if (!before) throw new Error("Projekt nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.projectProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "project", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/assets?type=PROJECT";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteProject(assetId: string, returnTo: string = "/assets?type=PROJECT") {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Projekt nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "project", entityId: assetId, before });
revalidatePath("/assets");
redirect(returnTo);
}
-69
View File
@@ -1,69 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
/**
* Editierbare verwaltete Register (WP3.0). Zeilen werden gegen die Pflichtspalten
* der ManagedRegister-Definition gepflegt; optionale Cross-Links an Lieferant/Asset.
* Modul „policies", Recht policy:write.
*/
const guard = moduleGuard("policies");
interface Column { key: string; label: string }
function readValues(columns: Column[], formData: FormData): Record<string, string> {
const values: Record<string, string> = {};
for (const c of columns) values[c.key] = String(formData.get(`col_${c.key}`) ?? "").trim();
return values;
}
const optRef = (v: FormDataEntryValue | null) => {
const s = v ? String(v).trim() : "";
return s || null;
};
export async function addRegisterRow(code: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const reg = await db.managedRegister.findFirst({ where: { code } });
if (!reg) throw new Error("Register nicht gefunden");
const columns = reg.columns as unknown as Column[];
const last = await db.registerRow.aggregate({ where: { registerId: reg.id }, _max: { orderIdx: true } });
await db.registerRow.create({
data: {
tenantId: session.user.tenantId, registerId: reg.id,
values: readValues(columns, formData),
supplierRef: reg.supplierLink ? optRef(formData.get("supplierRef")) : null,
assetRef: reg.assetLink ? optRef(formData.get("assetRef")) : null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "register_row", entityId: reg.code });
revalidatePath(`/policies/${code}`);
}
export async function updateRegisterRow(rowId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: true } });
if (!row) throw new Error("Zeile nicht gefunden");
const columns = row.register.columns as unknown as Column[];
await db.registerRow.update({
where: { id: rowId },
data: {
values: readValues(columns, formData),
supplierRef: row.register.supplierLink ? optRef(formData.get("supplierRef")) : row.supplierRef,
assetRef: row.register.assetLink ? optRef(formData.get("assetRef")) : row.assetRef,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
export async function deleteRegisterRow(rowId: string) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: { select: { code: true } } } });
if (!row) return;
await db.registerRow.delete({ where: { id: rowId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
-234
View File
@@ -1,234 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import type { TenantDb } from "@/server/db";
/**
* AP4 — Managementklauseln (Modul „review"): Kennzahlen (9.1), Managementbewertung
* (9.3) und Nichtkonformität/Korrekturmaßnahme (10.2). moduleGuard("review") stellt das
* aktive Modul sicher; Bearbeitung verlangt `review:manage`.
*/
const guard = moduleGuard("review");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const dateOrNull = (v: FormDataEntryValue | null) => {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
};
async function audit(tenantId: string, actorId: string, action: "create" | "update" | "delete", entity: string, entityId: string, after?: unknown) {
await writeAuditLog({ tenantId, actorId, action, entity, entityId, after: after as object | undefined });
}
// ── 9.1 Kennzahlen ────────────────────────────────────────────────────────────
export async function createKpi(formData: FormData) {
const { session, db } = await guard("review:manage");
const name = str(formData.get("name"));
if (!name) throw new Error("Name der Kennzahl fehlt.");
const kpi = await db.kpi.create({
data: {
tenantId: session.user.tenantId,
name,
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "kpi", kpi.id, { name });
revalidatePath("/review");
}
export async function updateKpi(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
await db.kpi.update({
where: { id: kpiId },
data: {
name: str(formData.get("name")),
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
active: formData.get("active") !== "false",
},
});
await audit(session.user.tenantId, session.user.id, "update", "kpi", kpiId);
revalidatePath("/review");
}
/** Messwert je Periode erfassen (Upsert über (kpi, period)). */
export async function recordKpiValue(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const period = str(formData.get("period"));
const value = str(formData.get("value"));
if (!period || !value) throw new Error("Periode und Wert sind erforderlich.");
await db.kpiValue.upsert({
where: { tenantId_kpiId_period: { tenantId: session.user.tenantId, kpiId, period } },
update: { value, note: str(formData.get("note")) || null, recordedAt: new Date() },
create: { tenantId: session.user.tenantId, kpiId, period, value, note: str(formData.get("note")) || null },
});
await audit(session.user.tenantId, session.user.id, "update", "kpi_value", kpiId, { period, value });
revalidatePath("/review");
}
// ── 9.3 Managementbewertung ───────────────────────────────────────────────────
export async function createManagementReview(formData: FormData) {
const { session, db } = await guard("review:manage");
const review = await db.managementReview.create({
data: {
tenantId: session.user.tenantId,
reviewDate: dateOrNull(formData.get("reviewDate")) ?? new Date(),
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "management_review", review.id);
revalidatePath("/review");
}
export async function updateManagementReview(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
await db.managementReview.update({
where: { id: reviewId },
data: {
reviewDate: dateOrNull(formData.get("reviewDate")) ?? undefined,
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
status: statusRaw === "abgeschlossen" ? "abgeschlossen" : "entwurf",
},
});
await audit(session.user.tenantId, session.user.id, "update", "management_review", reviewId);
revalidatePath("/review");
}
export async function addReviewDecision(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const decision = str(formData.get("decision"));
if (!decision) throw new Error("Beschlusstext fehlt.");
await db.managementReviewDecision.create({
data: {
tenantId: session.user.tenantId,
reviewId,
decision,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "management_review_decision", reviewId);
revalidatePath("/review");
}
export async function setReviewDecisionStatus(decisionId: string, done: boolean) {
const { session, db } = await guard("review:manage");
await db.managementReviewDecision.update({ where: { id: decisionId }, data: { status: done ? "erledigt" : "offen" } });
await audit(session.user.tenantId, session.user.id, "update", "management_review_decision", decisionId, { status: done ? "erledigt" : "offen" });
revalidatePath("/review");
}
// ── 10.2 Nichtkonformität & Korrekturmaßnahme ─────────────────────────────────
/** Nächste Nichtkonformitäts-Kennung „NC-<Jahr>-<lfd>" (mandantengebunden). */
async function nextNcRefNo(db: TenantDb, tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `NC-${year}-`;
const rows = await db.nonconformity.findMany({ where: { tenantId, refNo: { startsWith: prefix } }, select: { refNo: true } });
let max = 0;
for (const r of rows) {
const n = parseInt(r.refNo.slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
}
return `${prefix}${String(max + 1).padStart(4, "0")}`;
}
export async function createNonconformity(formData: FormData) {
const { session, db } = await guard("review:manage");
const description = str(formData.get("description"));
const source = str(formData.get("source"));
if (!description || !source) throw new Error("Herkunft und Beschreibung sind erforderlich.");
const refNo = await nextNcRefNo(db, session.user.tenantId);
const nc = await db.nonconformity.create({
data: {
tenantId: session.user.tenantId,
refNo,
source,
description,
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "nonconformity", nc.id, { refNo });
revalidatePath("/review");
}
export async function updateNonconformity(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = ["offen", "in_bearbeitung", "abgeschlossen"].includes(statusRaw) ? statusRaw : "offen";
await db.nonconformity.update({
where: { id: ncId },
data: {
source: str(formData.get("source")),
description: str(formData.get("description")),
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
status,
closedAt: status === "abgeschlossen" ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "nonconformity", ncId, { status });
revalidatePath("/review");
}
export async function addCorrectiveAction(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const action = str(formData.get("action"));
if (!action) throw new Error("Maßnahmentext fehlt.");
await db.correctiveAction.create({
data: {
tenantId: session.user.tenantId,
nonconformityId: ncId,
action,
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "corrective_action", ncId);
revalidatePath("/review");
}
/** Maßnahme pflegen inkl. dokumentierter Wirksamkeitsprüfung (10.2 d/e). */
export async function updateCorrectiveAction(actionId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = statusRaw === "umgesetzt" ? "umgesetzt" : "geplant";
const effectivenessCheck = str(formData.get("effectivenessCheck")) || null;
const confirmed = formData.get("effectivenessConfirmed") === "on" || formData.get("effectivenessConfirmed") === "true";
await db.correctiveAction.update({
where: { id: actionId },
data: {
action: str(formData.get("action")),
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
status,
effectivenessCheck,
// Wirksamkeit gilt erst als bestätigt, wenn Haken UND eine Bewertung vorliegen.
effectivenessConfirmedAt: confirmed && effectivenessCheck ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "corrective_action", actionId, { status });
revalidatePath("/review");
}
-242
View File
@@ -1,242 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import type { TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Risikowert > 9 (hoch/sehr hoch) erfordert dokumentierte Akzeptanz (VA-09). */
const ACCEPT_THRESHOLD = 9;
/**
* Standard-Risikokatalog (Story A6-1). Übernimmt ein Katalog-Risiko (C4) mit seiner
* Default-Bewertung (E×S) ins mandanteneigene Risk-Register — dort frei anpassbar
* (Bewertung, Behandlung, Maßnahmen → A6-2). Idempotent je `catalogCode`.
*/
const guard = moduleGuard("risk");
/**
* Gemeinsame, idempotente Übernahme eines Katalog-Risikos ins Register. Gibt `true`
* zurück, wenn ein neues Risiko angelegt wurde, `false`, wenn es bereits existierte.
* Kapselt die Logik für den Katalog-Pfad (`adoptCatalogRisk`, mit Redirect) und den
* prozessgeführten Wizard-Pfad (`adoptSuggestedRisk`, ohne Redirect).
*/
async function createRiskFromCatalog(
db: TenantDb,
tenantId: string,
userId: string,
code: string,
processId?: string,
): Promise<boolean> {
const entry = await db.riskCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Risiko nicht gefunden.");
// Im prozessgeführten BIA-Popup wird das Katalog-Risiko JE PROZESS übernommen: schon
// vorhandenes Risiko am selben Prozess = idempotent; an anderem Prozess = eigener
// Eintrag. Ohne Prozess (globaler Katalog-Pfad) bleibt es idempotent je Code.
const existing = await db.risk.findFirst({
where: processId ? { catalogCode: code, processId } : { catalogCode: code },
select: { id: true },
});
if (existing) return false;
const last = await db.risk.aggregate({ _max: { refNo: true } });
const likelihood = entry.defaultLikelihood;
const impact = entry.defaultImpact;
await db.risk.create({
data: {
tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: entry.title,
description: entry.description,
threat: `Standard-Risikokatalog ${entry.code} (${entry.category})`,
catalogCode: entry.code,
processId: processId ?? null,
likelihood, impact, score: likelihood * impact,
treatment: "MITIGATE",
createdBy: userId,
},
});
await writeAuditLog({ tenantId, actorId: userId, action: "create", entity: "risk", after: { fromCatalog: code, likelihood, impact, processId } });
return true;
}
export async function adoptCatalogRisk(code: string) {
const { session, db } = await guard("risk:write");
const created = await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
if (!created) redirect("/risks?catalog=exists");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
redirect("/risks?catalog=adopted");
}
/** Ein Katalog-Vorschlag im Risks-Step, mit Übernahme-Status je Prozess. */
export interface SuggestedRisk {
code: string;
title: string;
category: string;
adopted: boolean;
}
export interface ProcessRiskSuggestion {
processId: string;
processName: string;
risks: SuggestedRisk[];
}
/**
* M2 Risks-Step: löst die `suggestedRiskCodes` der übernommenen Katalog-Prozesse
* (Match über den Prozessnamen) zu den zugehörigen `RiskCatalogEntry` auf und markiert,
* welche bereits ins Register übernommen wurden. Grundlage für die Ein-Klick-Übernahme.
*/
export async function getSuggestedRisksForProcesses(): Promise<ProcessRiskSuggestion[]> {
const { db } = await guard("risk:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({ select: { code: true, name: true, suggestedRiskCodes: true } }),
]);
// v3A: Zuordnung Prozess↔Katalog bevorzugt über catalogCode (Name nur als Fallback).
const byCatCode = new Map(catalog.map((c) => [c.code, c.suggestedRiskCodes]));
const byCatName = new Map(catalog.map((c) => [c.name, c.suggestedRiskCodes]));
const allCodes = Array.from(new Set(catalog.flatMap((c) => c.suggestedRiskCodes)));
if (allCodes.length === 0) return [];
const [entries, adoptedRows] = await Promise.all([
db.riskCatalogEntry.findMany({
where: { code: { in: allCodes } },
select: { code: true, title: true, category: true },
}),
// Übernahme JE PROZESS bewerten (processId), damit derselbe Katalog-Code an mehreren
// Prozessen unabhängig als „übernommen" markiert werden kann.
db.risk.findMany({ where: { catalogCode: { in: allCodes } }, select: { catalogCode: true, processId: true } }),
]);
const entryByCode = new Map(entries.map((e) => [e.code, e]));
const adoptedByProcess = new Set(
adoptedRows.filter((r) => r.processId).map((r) => `${r.processId}:${r.catalogCode}`),
);
return processes.flatMap((p) => {
const codes = (p.catalogCode ? byCatCode.get(p.catalogCode) : undefined) ?? byCatName.get(p.name);
if (!codes || codes.length === 0) return [];
const risks = codes.flatMap((code) => {
const e = entryByCode.get(code);
if (!e) return [];
return [{ code: e.code, title: e.title, category: e.category, adopted: adoptedByProcess.has(`${p.id}:${code}`) }];
});
if (risks.length === 0) return [];
return [{ processId: p.id, processName: p.name, risks }];
});
}
/**
* Prozessgeführte Ein-Klick-Übernahme eines vorgeschlagenen Katalog-Risikos aus dem
* Wizard (Risks-Step). Wie `adoptCatalogRisk`, aber ohne Redirect — der Nutzer bleibt
* im Wizard. Idempotent je `catalogCode`.
*/
export async function adoptSuggestedRisk(code: string) {
const { session, db } = await guard("risk:write");
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
revalidatePath("/onboarding");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
}
/**
* BIA-Popup Schritt 4: übernimmt ein vorgeschlagenes Katalog-Risiko und verknüpft es
* direkt mit DIESEM Prozess (processId gesetzt). So erscheint es im selben Popup-Schritt
* zur Bewertung. Idempotent je (catalogCode, processId). Kein Redirect (bleibt im Popup).
*/
export async function adoptProcessRisk(processId: string, code: string) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code, processId);
revalidatePath("/onboarding");
revalidatePath("/risks");
}
/**
* Standardmaßnahme eines Katalog-Risikos als echte, mit dem Risiko verknüpfte Maßnahme
* übernehmen (nicht als separate Aufgabe). So erscheint sie im Risiko unter „Maßnahmen"
* und mindert nach Pflege der Minderung das Restrisiko. Idempotent je Risiko/Maßnahmentitel.
*/
export async function adoptStandardMeasure(riskId: string) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Nichtssagende
// Fehlermeldung für den Objekt-Lookup (F-16, Score-/Existenz-Oracle schließen).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, catalogCode: true, score: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
if (!risk.catalogCode) throw new Error("Standardmaßnahme nur für Katalog-Risiken verfügbar.");
const entry = await db.riskCatalogEntry.findUnique({ where: { code: risk.catalogCode } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden.");
const title = entry.standardMeasure.slice(0, 200);
const existing = await db.riskMeasure.findFirst({ where: { riskId, measure: { title } }, select: { id: true } });
if (!existing) {
// Transaktionsklammer (F-02): Maßnahme UND Verknüpfung entstehen atomar —
// kein Teilzustand, falls ein Schritt scheitert.
const measure = await db.$transaction(async (tx) => {
const last = await tx.measure.aggregate({ _max: { refNo: true } });
const m = await tx.measure.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title,
description: entry.rationale || null,
priority: risk.score >= 10 ? "HIGH" : "MEDIUM",
createdBy: session.user.id,
},
});
await tx.riskMeasure.create({ data: { tenantId: session.user.tenantId, riskId, measureId: m.id, reductionLikelihood: 0, reductionImpact: 0 } });
return m;
});
// Restrisiko-Neuberechnung NACH dem Commit: der Tenant-Guard schreibt
// `findUnique` auf ein `findFirst` des rohen Clients um (F-02, Verhindern statt
// Erkennen); dieser sähe die in der Transaktion frisch angelegte Verknüpfung
// nicht. Erst nach dem Commit liefert der Lesezugriff den konsistenten Stand.
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, fromCatalog: entry.code } });
}
revalidatePath("/risks");
revalidatePath("/measures");
redirect(`/risks?detail=${riskId}&measure=1`);
}
/**
* Story A6-2: Risiko akzeptieren (Behandlung ACCEPT). Liegt das (Rest-)Risiko über
* der Akzeptanzschwelle, ist eine dokumentierte Akzeptanz (VA-09) Pflicht.
*/
export async function acceptRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Der Objekt-Lookup gibt eine
// nichtssagende Meldung zurück — sonst ließe der Unterschied zwischen „nicht
// gefunden" und „über der Akzeptanzschwelle" ein Existenz-/Score-Oracle zu (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, score: true, residualScore: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
const rationale = str(formData.get("rationale"));
const effScore = risk.residualScore ?? risk.score;
if (effScore > ACCEPT_THRESHOLD && !rationale) {
throw new Error("Restrisiko über der Akzeptanzschwelle — bitte die Akzeptanz dokumentiert begründen (VA-09).");
}
await db.risk.update({
where: { id: riskId },
data: { treatment: "ACCEPT", acceptanceRationale: rationale || null, acceptedById: session.user.id, acceptedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { treatment: "ACCEPT", effScore, documented: Boolean(rationale) } });
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}&accepted=1`);
}
-368
View File
@@ -1,368 +0,0 @@
"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";
import { createObjectReviewTask } from "@/server/object-review";
import { syncTaskFromObject } from "@/server/task-sync";
import { riskRef } from "@/lib/risk";
const guard = moduleGuard("risk");
import { recomputeResidualRisk } from "@/server/risk-calc";
const scale = z.coerce.number().int().min(1).max(5);
const riskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
threat: z.string().trim().max(500).optional(),
vulnerability: z.string().trim().max(500).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
ownerId: z.string().optional(),
processId: z.string().optional(),
});
function parseRiskForm(formData: FormData) {
const parsed = riskSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
threat: formData.get("threat") || undefined,
vulnerability: formData.get("vulnerability") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
processId: formData.get("processId") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
threat: parsed.threat || null,
vulnerability: parsed.vulnerability || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
ownerId: parsed.ownerId || null,
processId: parsed.processId || null,
};
}
export async function createRisk(formData: FormData) {
const { session, db } = await guard("risk:write");
const data = parseRiskForm(formData);
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
...data,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
// 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,
action: "create",
entity: "risk",
entityId: risk.id,
after: data,
});
revalidatePath("/risks");
revalidatePath("/assets");
redirect(`/risks?edit=${risk.id}`);
}
export async function updateRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
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);
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — Risiko ACCEPTED/CLOSED schließt die
// verknüpften Aufgaben bis DONE (Restrisiko dokumentiert). „audit-ready" bleibt separat.
if (data.status === "ACCEPTED" || data.status === "CLOSED") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"risk",
{ matchKey: [riskId, riskRef(before.refNo), String(before.refNo)], reached: true },
);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: data,
});
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}`);
}
/**
* BIA-Popup Schritt 4: Inline-Bewertung eines prozessverknüpften Risikos (Eintritts-
* wahrscheinlichkeit × Auswirkung = Wert, Behandlung) — dieselbe Bewertungslogik wie
* `updateRisk`, aber schlank (nur Bewertungsfelder) und mit Rücksprung ins BIA-Popup.
*/
export async function rateProcessRisk(processId: string, riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, likelihood: true, impact: true, treatment: true, status: true },
});
if (!before) throw new Error("Vorgang nicht möglich.");
const data = z
.object({
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
})
.parse({
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
});
await db.risk.update({
where: { id: riskId },
data: { ...data, score: data.likelihood * data.impact, processId },
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: { ...data, score: data.likelihood * data.impact, viaBiaPopup: processId },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (bestehendes Risiko zuordnen): ordnet ein bereits vorhandenes
* Risiko diesem Prozess zu (setzt `processId`). Rücksprung ins Popup. Idempotent.
*/
export async function linkProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const riskId = z.string().min(1).parse(formData.get("riskId"));
const [risk, processCount] = await Promise.all([
db.risk.findFirst({ where: { id: riskId, tenantId: session.user.tenantId }, select: { id: true, processId: true } }),
db.process.count({ where: { id: processId } }),
]);
if (!risk || processCount !== 1) throw new Error("Vorgang nicht möglich.");
await db.risk.update({ where: { id: riskId }, data: { processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before: { processId: risk.processId },
after: { processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (neues Risiko anlegen): legt ein neues Risiko an und verknüpft es
* direkt mit DIESEM Prozess — inkl. der vorhandenen Bewertung (Eintritt × Auswirkung =
* Wert, Behandlung, Status). Schlanke Variante von `createRisk`, mit Rücksprung ins Popup.
*/
export async function createProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const parsed = z
.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]).default("MITIGATE"),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]).default("OPEN"),
})
.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment") || undefined,
status: formData.get("status") || undefined,
});
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: parsed.title,
description: parsed.description || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
processId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: { title: parsed.title, score: parsed.likelihood * parsed.impact, processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
export async function deleteRisk(riskId: string) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
await db.risk.delete({ where: { id: riskId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk",
entityId: riskId,
before,
});
revalidatePath("/risks");
redirect("/risks");
}
export async function addRiskAsset(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const riskCount = await db.risk.count({ where: { id: riskId } });
if (assetCount !== 1 || riskCount !== 1) throw new Error("Nicht gefunden");
await db.riskAsset.upsert({
where: { riskId_assetId: { riskId, assetId } },
update: {},
create: { riskId, assetId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk_asset",
entityId: riskId,
after: { riskId, assetId },
});
revalidatePath("/risks");
revalidatePath("/assets");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
const { session, db } = await guard("risk:write");
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
if (!before) return;
await db.riskAsset.delete({ where: { id: riskAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_asset",
entityId: riskAssetId,
before,
});
revalidatePath("/risks");
revalidatePath("/assets");
}
/**
* Risiko zur Validierung einreichen (Story A3-1, generisches Objekt-Review). Erzeugt
* eine `validation`-Aufgabe an einen berechtigten Validator (Vier-Augen). Der
* Review-Status wird aus der Aufgabe abgeleitet — kein Statusfeld am Risiko.
*/
export async function submitRiskForReview(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit in die Abfrage, damit die Isolation
// nicht allein am zentralen Guard hängt (F-02). findFirst statt findUnique,
// damit der tenantId-Filter direkt greift. Nichtssagende Fehlermeldung (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { refNo: true, title: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
await createObjectReviewTask(db, {
tenantId: session.user.tenantId,
entityType: "risk",
entityId: riskId,
entityRef: riskRef(risk.refNo),
title: `Validierung: ${riskRef(risk.refNo)} — ${risk.title}`,
approverId: String(formData.get("approverId") ?? "").trim(),
submitterId: session.user.id,
note: (formData.get("note") as string | null)?.trim() || null,
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { review: "submitted" } });
revalidatePath("/risks", "layout");
revalidatePath("/tasks");
}
-126
View File
@@ -1,126 +0,0 @@
"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("suppliers");
import { withQuery } from "@/lib/supplier";
const level = z.coerce.number().int().min(1).max(4);
const serviceSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
internal: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = serviceSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
internal: formData.get("internal") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
internal: p.internal === "on",
notes: p.notes || null,
},
};
}
export async function createService(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.iTServiceProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.iTServiceProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "it_service", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=services&edit=${created.id}`);
}
export async function updateService(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
if (!before) throw new Error("Service nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.iTServiceProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=services";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Service nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
export async function addRaci(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
await db.serviceControlResponsibility.create({
data: {
assetId,
tenantId: session.user.tenantId,
controlRef: z.string().trim().min(1).parse(formData.get("controlRef")),
title: (formData.get("title") as string)?.trim() || null,
applicable: formData.get("applicable") !== "off",
responsibility: z.enum(["PROVIDER", "US", "SHARED"]).parse(formData.get("responsibility") ?? "SHARED"),
evidenceRef: (formData.get("evidenceRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
export async function cycleRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
if (!row) return;
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
-52
View File
@@ -1,52 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { SOA_STATUS } from "@/lib/soa";
/**
* AP3 — Pflege der Anwendbarkeitserklärung (SoA). Der ISB bearbeitet je Control die
* normativen Pflichtangaben (Anwendbarkeit + Begründung/Ausschluss + Umsetzungsstatus)
* sowie Herkunft, Verantwortlichen, Richtlinie und Nachweis. moduleGuard("soa") stellt
* das aktive Modul sicher; die Bearbeitung verlangt `soa:write`. Die Vorbefüllung der
* 93 Controls erledigt der Seiten-Load idempotent (ensureSoaEntries).
*/
const guard = moduleGuard("soa");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
export async function updateSoaEntry(entryId: string, formData: FormData) {
const { session, db } = await guard("soa:write");
// RLS stellt die Mandantenbindung sicher; existierende Zeile laden (für Audit-Kontext).
const entry = await db.soaEntry.findUnique({ where: { id: entryId }, select: { id: true, control: true } });
if (!entry) throw new Error("SoA-Eintrag nicht gefunden.");
const applicable = formData.get("applicable") === "on" || formData.get("applicable") === "true";
const statusRaw = str(formData.get("implementationStatus"));
const implementationStatus = (SOA_STATUS as readonly string[]).includes(statusRaw) ? statusRaw : "geplant";
await db.soaEntry.update({
where: { id: entryId },
data: {
applicable,
justification: str(formData.get("justification")),
source: str(formData.get("source")) || null,
implementationStatus,
ownerId: str(formData.get("ownerId")) || null,
policyCode: str(formData.get("policyCode")) || null,
evidenceId: str(formData.get("evidenceId")) || null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "soa_entry",
entityId: entryId,
after: { control: entry.control, applicable, implementationStatus },
});
revalidatePath("/soa");
}
-185
View File
@@ -1,185 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildControlRows } from "@/server/soa-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { syncTaskFromObject } from "@/server/task-sync";
/**
* Control-Assessment (Story A7) — Server-Aktionen für Schritt 7 „controls".
* Bearbeiter (`onboarding:use`): Reifegrad je Control bestätigen/überschreiben
* (Pflichtbegründung bei Absenkung unter den Vorschlag, C5 §4.6), alle offenen
* Vorschläge übernehmen und Gap-Aufgaben (C5 §4) anlegen. Der Reifegrad-Vorschlag
* selbst ist reine Logik (src/lib/maturity.ts) und wird hier nur persistiert.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: "controls" } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "controls" } },
update: { status },
create: { tenantId, stepKey: "controls", status },
});
}
}
/** Einen Control-Reifegrad bestätigen oder überschreiben (Pflichtbestätigung, C5 §5-Vier-Augen). */
export async function confirmControlMaturity(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const control = String(formData.get("control") ?? "");
const value = Number(formData.get("value"));
const justification = String(formData.get("justification") ?? "").trim() || null;
if (!Number.isInteger(value) || value < 0 || value > 3) throw new Error("Ungültiger Reifegrad (0–3).");
const { rows } = await buildControlRows(db, session.user.tenantId);
const row = rows.find((r) => r.control === control);
if (!row) throw new Error("Control liegt nicht im aktuellen Scope.");
// Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).
if (value < row.suggestion.value && !justification) {
throw new Error("Absenkung unter den Vorschlag erfordert eine Begründung.");
}
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control } },
update: { suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control, suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
});
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", entityId: control, after: { confirmedValue: value, suggested: row.suggestion.value, target: row.target } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Alle noch nicht bestätigten Controls mit ihrem Reifegrad-Vorschlag bestätigen. */
export async function confirmAllSuggestions() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
let count = 0;
for (const row of rows) {
if (row.confirmed !== null) continue;
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control: row.control } },
update: { suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control: row.control, suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
});
count++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", after: { bulkConfirmed: count } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus den offenen Punkten (C5 §4) idempotente Aufgaben je Control/Belegklasse anlegen. */
export async function createControlGapTasks() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
const gaps = rows.flatMap((r) => r.gaps);
let created = 0;
for (const g of gaps) {
const origin = `wizard:control-gap:${g.control}:${g.kind}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
// Cockpit (M3, 1.4): Bereich aus Kapitel→Domain-Mapping ableiten.
const domain = await domainForControl(db, session.user.tenantId, g.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: g.action,
origin,
status: "PROPOSED",
priority: g.kind === "widerspruch" ? "hoch" : "mittel",
createdById: session.user.id,
domain,
links: { control: g.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: g.control, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_gap_check", after: { gaps: gaps.length, created } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
const IMPL_STATUS = new Set(["offen", "in_umsetzung", "erledigt"]);
/**
* Umsetzungsstatus eines Umsetzungshinweises (Spiegelstrich) dokumentieren (#10):
* offen / in Umsetzung / erledigt (+ Notiz). „erledigt" aller relevanten Hinweise eines
* Controls zählt als operativer Nachweis und hebt den Reifegrad-Vorschlag.
*/
export async function setImplementationStatus(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId }, select: { control: true } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const statusRaw = String(formData.get("status") ?? "");
const status = IMPL_STATUS.has(statusRaw) ? statusRaw : "offen";
const note = String(formData.get("note") ?? "").trim() || null;
await db.controlImplementation.upsert({
where: { tenantId_reqId: { tenantId: session.user.tenantId, reqId } },
update: { status, note, control: hint.control, updatedById: session.user.id },
create: { tenantId: session.user.tenantId, reqId, control: hint.control, status, note, updatedById: session.user.id },
});
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — „erledigt" schließt die dem
// Control zugeordneten Aufgaben bis DONE (umgesetzt). Reifegrad-Anhebung bleibt
// ein separater, manuell zu bestätigender Schritt („Existenz ≠ Wirksamkeit").
if (status === "erledigt") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"control",
{ matchKey: hint.control, reached: true },
);
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_implementation", entityId: reqId, after: { status } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus einem offenen Umsetzungshinweis eine Aufgabe erzeugen (idempotent je reqId). */
export async function createImplementationTask(reqId: string) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const origin = `hint:${reqId}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
const domain = await domainForControl(db, session.user.tenantId, hint.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
description: `Organisatorisch: ${hint.organisational} | Technisch: ${hint.technical} | Nachweise: ${hint.evidence}`,
origin,
status: "PROPOSED",
priority: "mittel",
createdById: session.user.id,
domain,
links: { control: hint.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: hint.control, domain });
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_impl_task", entityId: reqId, after: { origin } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
}
-96
View File
@@ -1,96 +0,0 @@
"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";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const softwareSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
version: z.string().trim().max(100).optional(),
approvalStatus: z.enum(["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"]),
approvedBy: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = softwareSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
version: formData.get("version") || undefined,
approvalStatus: formData.get("approvalStatus") || "BEANTRAGT",
approvedBy: formData.get("approvedBy") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
version: p.version || null,
approvalStatus: p.approvalStatus,
approvedBy: p.approvedBy || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSoftware(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "SOFTWARE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.softwareProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.softwareProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "software", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=software&edit=${created.id}`);
}
export async function updateSoftware(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { softwareProfile: true } });
if (!before) throw new Error("Software nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.softwareProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "software", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=software";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSoftware(assetId: string, returnTo: string = "/suppliers?tab=software") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Software nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "software", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
-446
View File
@@ -1,446 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { normalizeAssetName } from "@/lib/normalize-asset";
/**
* M2 Strukturanalyse (Ebene 2) — prozessgeführte Erhebung.
*
* Kernprinzip (nicht verletzen): Ein Informationswert ist KEIN neues Objekt, sondern
* ein `Asset` mit `type = INFORMATION|DATA` (primärer Wert). Diese Actions erfassen
* primäre Informations-Assets über den Prozess — mit Dedup + Autovervollständigung
* (§2.1) — und übernehmen Prozesse aus dem globalen `ProcessCatalogEntry`.
*
* Träger-Assets (SECONDARY) laufen weiter über `assignAsset` in
* `src/server/actions/processes.ts`; Schutzbedarf C/I/A bleibt am Asset.
*/
const guard = moduleGuard("assets");
const INFO_TYPES = ["INFORMATION", "DATA"] as const;
const INFO_LABELS = ["NONE", "INFO_HIGH", "INFO_VERY_HIGH", "PROTOTYPE", "PERSONAL_DATA"] as const;
export interface AssetSuggestion {
id: string;
name: string;
type: (typeof INFO_TYPES)[number];
label: string;
normalizedName: string | null;
/** Prozessnamen, in denen dieses Asset bereits erfasst ist ("bereits erfasst in Prozess X"). */
processes: string[];
}
/**
* As-you-type-Suche für die Combobox (§2.1). Liefert bestehende primäre
* Informations-Assets (type INFORMATION/DATA) des Mandanten, deren normalisierter
* Name den (ebenfalls normalisierten) Query enthält, inkl. der Prozesse, in denen
* sie bereits verknüpft sind. Der Client rechnet darauf die weiche Fuzzy-Warnung
* (Levenshtein) — die harte Exakt-Dedup erzwingt die DB (@@unique).
*/
export async function searchAssets(query: string): Promise<AssetSuggestion[]> {
const { db } = await guard("asset:read");
const norm = normalizeAssetName(query ?? "");
if (norm.length < 2) return [];
const rows = await db.asset.findMany({
where: {
type: { in: [...INFO_TYPES] },
OR: [
{ normalizedName: { contains: norm } },
// Fallback für Altbestand ohne gesetzten normalizedName.
{ name: { contains: query.trim(), mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
type: true,
label: true,
normalizedName: true,
processAssets: { select: { process: { select: { name: true } } } },
},
orderBy: { name: "asc" },
take: 12,
});
return rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type as (typeof INFO_TYPES)[number],
label: r.label,
normalizedName: r.normalizedName,
processes: r.processAssets.map((pa) => pa.process.name),
}));
}
const newInfoSchema = z.object({
name: z.string().trim().min(1).max(200),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
});
/**
* Verknüpft ein bestehendes Asset als PRIMÄREN Informationswert mit dem Prozess
* (ProcessAsset role=PRIMARY) — der „ein Klick verknüpft"-Pfad der Autocomplete.
*/
export async function linkPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const [assetCount, processCount] = await Promise.all([
db.asset.count({ where: { id: assetId, type: { in: [...INFO_TYPES] } } }),
db.process.count({ where: { id: processId } }),
]);
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role: "PRIMARY" },
create: { processId, assetId, role: "PRIMARY", tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role: "PRIMARY", via: "m2-link-primary" },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Erfasst einen NEUEN primären Informationswert am Prozess — MIT Exakt-Dedup:
* Der normalisierte Name (§2.1) ist über `@@unique([tenantId, normalizedName])`
* eindeutig. Existiert bereits ein Asset mit gleichem Schlüssel, wird es VERKNÜPFT
* statt neu angelegt (auch bei Groß-/Kleinschreibung, Umlaut-Faltung, Satzzeichen).
* Der Unique-Constraint fängt zusätzlich Wettläufe (P2002) ab.
*/
export async function addPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const data = newInfoSchema.parse({
name: formData.get("name"),
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// 1) Exakt-Dedup: bestehendes (primäres) Asset mit gleichem Schlüssel verknüpfen.
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
type: data.type,
label: data.label,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
// Wettlauf: paralleler Insert hat den Schlüssel belegt → bestehendes verknüpfen.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, label: data.label, normalizedName, processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
const level = z.coerce.number().int().min(1).max(4);
// Träger-/Sekundär-Assets sind alle Asset-Typen AUSSER den primären Informationswerten
// (INFORMATION/DATA). Default SYSTEM (häufigster Träger).
const CARRIER_TYPES = ["SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "SOFTWARE", "PROJECT", "PERSON"] as const;
/**
* BIA-Popup Schritt 2 (Träger neu anlegen): erfasst ein NEUES Träger-Asset über die
* volle Asset-Inventar-Maske (AssetForm) und verknüpft es als SECONDARY mit dem Prozess.
* Analog zu `createPrimaryInformationAsset`, aber für Träger-Typen (kein INFORMATION/DATA).
* Exakt-Dedup über den normalisierten Namen: gleicher Schlüssel → verknüpfen statt doppeln.
*/
export async function createSecondaryCarrierAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(CARRIER_TYPES).default("SYSTEM"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// Exakt-Dedup über den Mandanten-Schlüssel (@@unique tenantId,normalizedName): ein
// bestehendes Asset gleichen Namens (beliebigen Typs) wird als Träger verknüpft.
const existing = await db.asset.findFirst({
where: { normalizedName },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({ where: { normalizedName }, select: { id: true } });
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "SECONDARY" },
create: { processId, assetId: assetId!, role: "SECONDARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, carrierFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 1 (manueller Pfad): erfasst einen primären Informationswert über
* die VOLLE Asset-Inventar-Maske (AssetForm) und verknüpft ihn als PRIMARY mit dem
* Prozess. Der Werttyp ist auf INFORMATION/DATA beschränkt (primärer Wert). Exakt-Dedup
* wie bei `addPrimaryInformation`: gleicher normalisierter Name → verknüpfen statt doppeln.
*/
export async function createPrimaryInformationAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
label: data.label,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, primaryFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 3: Schutzbedarf C/I/A direkt am (primären) Informations-Asset
* setzen. Träger erben per Maximum-Prinzip (Anzeige/Analyse), der echte Wert liegt am
* primären Wert. Kein Redirect — der Nutzer bleibt im Popup-Schritt.
*/
export async function saveInfoProtection(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findFirst({
where: { id: assetId },
select: { id: true, confidentiality: true, integrity: true, availability: true },
});
if (!before) throw new Error("Asset nicht gefunden");
const data = {
confidentiality: level.parse(formData.get("confidentiality")),
integrity: level.parse(formData.get("integrity")),
availability: level.parse(formData.get("availability")),
};
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
-299
View File
@@ -1,299 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma, type TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const supplierSchema = z.object({
name: z.string().trim().min(1).max(200),
sector: z.string().trim().max(200).optional(),
serviceDesc: z.string().trim().max(2000).optional(),
criticality: level,
dataCategories: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
nis2Relevant: z.string().optional(),
lifecycle: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
contact: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = supplierSchema.parse({
name: formData.get("name"),
sector: formData.get("sector") || undefined,
serviceDesc: formData.get("serviceDesc") || undefined,
criticality: formData.get("criticality"),
dataCategories: formData.get("dataCategories") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
nis2Relevant: formData.get("nis2Relevant") || undefined,
lifecycle: formData.get("lifecycle"),
contact: formData.get("contact") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
},
profile: {
sector: p.sector || null,
serviceDesc: p.serviceDesc || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
nis2Relevant: p.nis2Relevant === "on",
lifecycle: p.lifecycle,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSupplier(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: {
...asset,
type: "SUPPLIER",
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
const last = await prisma.supplierProfile.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
await db.supplierProfile.create({
data: {
...profile,
assetId: created.id,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?edit=${created.id}`);
}
export async function updateSupplier(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
if (!before) throw new Error("Lieferant nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.supplierProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
/** Stellt sicher, dass das Asset zum Mandanten gehört (Guard hat Modul/Recht bereits geprüft). */
async function ensure(db: TenantDb, assetId: string) {
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
export async function addContract(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: (formData.get("type") as string) || "service",
avDpa: bool("avDpa"),
securityClauses: bool("securityClauses"),
flowdown: bool("flowdown"),
customerRequirementsPassed: bool("customerRequirementsPassed"),
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
reference: (formData.get("reference") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addNda(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.nda.create({
data: {
assetId,
tenantId: session.user.tenantId,
parties: (formData.get("parties") as string)?.trim() || null,
infoScope: (formData.get("infoScope") as string)?.trim() || null,
subject: (formData.get("subject") as string)?.trim() || null,
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addEvidence(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierEvidence.create({
data: {
assetId,
tenantId: session.user.tenantId,
kind: z.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"]).parse(formData.get("kind")),
name: (formData.get("name") as string)?.trim() || null,
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
validTo: optDate(formData.get("validTo")),
adequacyChecked: formData.get("adequacyChecked") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addAssessment(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierAssessment.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addDecision(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.managementDecision.create({
data: {
assetId,
tenantId: session.user.tenantId,
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
decision: z.string().trim().min(1).parse(formData.get("decision")),
decidedBy: (formData.get("decidedBy") as string)?.trim() || session.user.name || null,
recordRef: (formData.get("recordRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
export async function createGateRisk(assetId: string) {
const { session, db } = await guard("supplier:write", "risk:write");
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Lieferant nicht gefunden");
const last = await prisma.risk.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: `Third-Party-Risiko: ${asset.name}`,
description: "Sehr hoher Schutzbedarf ohne gültiges Third-Party-Audit/TISAX-Label — Kompensationsrisiko (VDA-ISA 6.1.1).",
threat: "Ausfall/Kompromittierung eines kritischen Lieferanten",
vulnerability: "Fehlender unabhängiger Sicherheitsnachweis",
likelihood: 3,
impact: 4,
score: 12,
treatment: "MITIGATE",
status: "IN_TREATMENT",
createdBy: session.user.id,
},
});
await db.riskAsset.create({ data: { tenantId: session.user.tenantId, riskId: risk.id, assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { gateRiskFor: assetId } });
revalidatePath("/suppliers");
revalidatePath("/risks");
redirect(`/risks?detail=${risk.id}`);
}
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
const justification = (formData.get("justification") as string)?.trim() || null;
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
throw new Error("Abweichung vom berechneten Wert erfordert eine Begründung.");
}
await db.maturityAssessment.upsert({
where: { assetId },
update: { computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
create: { assetId, tenantId: session.user.tenantId, computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteChild(
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
id: string
) {
const { session, db } = await guard("supplier:write");
const map = {
contract: db.contract,
nda: db.nda,
evidence: db.supplierEvidence,
assessment: db.supplierAssessment,
decision: db.managementDecision,
subcontractor: db.subcontractor,
} as const;
// @ts-expect-error dynamischer Delegate
await map[kind].delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
-643
View File
@@ -1,643 +0,0 @@
"use server";
import { Prisma, type Task } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { pathForEntity } from "@/server/object-review";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { TASK_PRIORITIES, TASK_TYPES, EVIDENCE_KINDS, type CreateTaskInput, isTaskPriority, isTaskType } from "@/lib/tasks";
import { originForTrigger, triggerById } from "@/lib/task-triggers";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { spawnRecurrence, syncTaskFromObject } from "@/server/task-sync";
/**
* Aufgaben-Modul (generisch). Erster Typ: policy_approval — Freigabe/Ablehnung
* einer Richtlinie durch den zugewiesenen Freigeber. Vier-Augen: der Freigeber
* ist eine andere Person als der Einreicher. Kommentare werden historisiert.
*/
const guard = moduleGuard("tasks");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* F-10: Schema-Validierung für `createTask`. Bislang wurde ein beliebiges JS-Objekt
* mit freien JSON-Feldern (`resources`, `links`) ohne Schema und ohne Größenlimit
* entgegengenommen. Zod erzwingt jetzt Typ, Längen- und Feldbegrenzung, damit keine
* beliebig großen JSON-Strukturen in die DB geschrieben werden können.
*/
const shortText = z.string().trim().max(200);
const longText = z.string().trim().max(5000);
const resourceValue = z.string().trim().max(500);
const createTaskSchema = z.object({
type: z.enum(TASK_TYPES),
title: shortText.min(1, "Aufgabe braucht einen Titel."),
description: longText.nullish(),
origin: shortText.min(1, "Aufgabe braucht eine Herkunft (origin)."),
owner: z.string().trim().max(64).nullish(),
dueDate: z.union([z.string().trim().max(64), z.date()]).nullish(),
priority: z.enum(TASK_PRIORITIES).optional(),
resources: z
.object({
tool: resourceValue.optional(),
budget: resourceValue.optional(),
personnel: resourceValue.optional(),
time: resourceValue.optional(),
})
.strict()
.optional(),
links: z
.object({
control: shortText.optional(),
risk: shortText.optional(),
document: shortText.optional(),
asset: shortText.optional(),
})
.strict()
.optional(),
});
/**
* F-10: `assigneeId`/`owner` muss auf einen AKTIVEN Nutzer DESSELBEN Mandanten zeigen.
* `db` ist bereits mandantengebunden (`dbForTenant`), daher genügt id+status. Ein
* leerer/`null`-Wert ist zulässig (keine Zuweisung). Gibt die geprüfte owner-Id oder
* `null` zurück.
*/
async function assertValidOwner(db: TenantDb, owner: string | null | undefined): Promise<string | null> {
const id = (owner ?? "").trim();
if (!id) return null;
const user = await db.user.findFirst({ where: { id, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Zugewiesene Person ist kein aktiver Nutzer dieses Mandanten.");
return user.id;
}
/** Erforderliches Recht je Review-Typ: generische Objekt-Validierung vs. Richtlinien-Freigabe (A3-1). */
function reviewPermission(type: string): "validate_objects" | "policy:approve" {
return type === "validation" ? "validate_objects" : "policy:approve";
}
/** Review-Aufgabe bestätigen (nur zugewiesener Validator/Freigeber, ≠ Einreicher). */
export async function approveTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
if (task.createdById === session.user.id) throw new Error("Vier-Augen-Prinzip: nicht selbst bestätigen.");
requirePermission(session, reviewPermission(task.type));
// Richtlinien-Freigabe hat einen Modell-Seiteneffekt; generische Objekt-Reviews
// (validation) leiten ihren Status aus der Aufgabe ab (keine Statusspalte).
let approvedPolicyCode: string | null = null;
if (task.type === "policy_approval" && task.entityId) {
const doc = await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() }, select: { code: true } });
approvedPolicyCode = doc.code;
}
const note = str(formData.get("note"));
await db.task.update({
where: { id: taskId },
data: {
status: "DONE", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "approve", body: note || "Bestätigt." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DONE", approved: true } });
// Cockpit (M3, 2.2): Wiedervorlage-Serie fortschreiben + Auto-Completion (gedeckelt)
// für weitere Aufgaben, die auf dasselbe (jetzt freigegebene) Dokument verweisen.
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
if (task.type === "policy_approval" && task.entityId) {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"policy_document",
{ entityId: task.entityId, matchKey: approvedPolicyCode ?? undefined, reached: true },
);
}
// SEC1: der Einreicher erfährt die Entscheidung per Mail.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "freigegeben",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Review-Aufgabe zurückweisen (mit Grund). Bei Richtlinien zurück auf ENTWURF. */
export async function rejectTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
requirePermission(session, reviewPermission(task.type));
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Ablehnungs-/Rückweisungsgrund angeben.");
if (task.type === "policy_approval" && task.entityId) {
await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "ENTWURF", submittedBy: null } });
}
await db.task.update({
where: { id: taskId },
data: {
status: "REJECTED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "REJECTED", reason } });
// SEC1: der Einreicher erfährt die Entscheidung. Der Begründungstext bleibt
// bewusst in der Aufgabe — die Mail verlinkt nur darauf.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "zurückgewiesen",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Kommentar zu einer Aufgabe (nur Beteiligte: Freigeber oder Einreicher). */
export async function commentTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId !== session.user.id && task.createdById !== session.user.id) {
throw new Error("Nur Beteiligte können kommentieren.");
}
const body = str(formData.get("body"));
if (!body) return;
await db.taskComment.create({ data: { tenantId: session.user.tenantId, taskId, authorId: session.user.id, kind: "comment", body } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "task_comment", entityId: taskId });
revalidatePath("/tasks");
}
/**
* Generische Aufgabe erzeugen (Contracts §1). Einheitlicher Einstieg für Dev A's
* Wizard-Gates/Trigger und spätere Auto-Generierung (B1). `owner` → `assigneeId`;
* `status` wird serverseitig gesetzt (Default "OPEN"). Der bestehende
* policy_approval-Flow (submitForApproval) bleibt unberührt.
*/
export async function createTask(input: CreateTaskInput): Promise<Task> {
const { session, db } = await guard("task:write");
// F-10: Eingabe strikt validieren (Typ, Längen, JSON-Felder) statt beliebiges Objekt.
const parsed = createTaskSchema.safeParse(input);
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? "Ungültige Aufgabendaten.");
}
const data = parsed.data;
const priority = data.priority ?? "mittel";
const dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (dueDate && Number.isNaN(dueDate.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
// F-10: Owner muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, data.owner);
// Cockpit (M3, 1.4): Bereich aus dem verknüpften Control ableiten.
const domain = await domainForControl(db, session.user.tenantId, data.links?.control ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
description: data.description?.trim() || null,
origin: data.origin,
priority,
assigneeId,
createdById: session.user.id,
dueDate,
domain,
resources: data.resources ? (data.resources as Prisma.InputJsonValue) : undefined,
links: data.links ? (data.links as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen,
// sobald Funktion→User (Ebene 1) gepflegt ist. Der assignee bleibt primär RESPONSIBLE.
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: data.links?.control ?? null,
domain,
assigneeId,
});
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task", entityId: task.id,
after: { type: data.type, origin: data.origin, priority },
});
// SEC1: Zuweisung benachrichtigen (nicht bei Vorschlägen ohne Owner und nicht,
// wenn sich jemand die Aufgabe selbst anlegt).
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
return task;
}
/**
* Aus einem Trigger-Set (C2 §8) Aufgaben-*Vorschläge* erzeugen (Story B1). Wird von
* Dev A's Wizard-Gates/Zurückweisungen aufgerufen. Idempotent über `origin`: existiert
* je Trigger bereits ein offener Vorschlag oder eine offene Aufgabe, wird übersprungen.
*/
export async function proposeTasksFromTriggers(triggerIds: string[]): Promise<{ created: number; skipped: number }> {
const { session, db } = await guard("task:write");
let created = 0;
let skipped = 0;
for (const id of triggerIds) {
const trg = triggerById(id);
if (!trg) { skipped++; continue; }
const origin = originForTrigger(id);
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) { skipped++; continue; }
// Cockpit (M3): Kontext-Verteiler (F2) — expliziter Trigger-Bereich (Fakt→Domain)
// hat Vorrang; sonst aus dem Control-Bezug ableiten (inkl. Tenant-Override).
const domain = trg.domain ?? (await domainForControl(db, session.user.tenantId, trg.links.control ?? null));
// `task` wird für die RACI-Auto-Befüllung (F1) unten benötigt.
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: trg.taskType,
title: trg.title,
origin,
status: "PROPOSED",
priority: trg.priority,
createdById: session.user.id,
domain,
links: trg.links as Prisma.InputJsonValue,
resources: trg.resourcesHint ? (trg.resourcesHint as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch befüllen (Vorschlag ohne Owner).
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: trg.links.control ?? null,
domain,
});
created++;
}
if (created > 0) {
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task_proposal", after: { created, skipped, triggers: triggerIds },
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
return { created, skipped };
}
/**
* Aufgaben-Vorschlag bestätigen (Story B1): PROPOSED → OPEN. Der Bearbeiter kann
* Owner, Fälligkeit, Priorität und Ressourcenfelder anpassen. Nur Beteiligte
* (Ersteller oder zugewiesener Owner) dürfen bestätigen.
*/
export async function confirmProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag bestätigen.");
}
const resources = {
tool: str(formData.get("res_tool")),
budget: str(formData.get("res_budget")),
personnel: str(formData.get("res_personnel")),
time: str(formData.get("res_time")),
};
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const hasResources = Object.values(resources).some(Boolean);
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = (await assertValidOwner(db, owner)) ?? task.assigneeId;
await db.task.update({
where: { id: taskId },
data: {
status: "OPEN",
assigneeId,
dueDate: due ?? task.dueDate,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
resources: (hasResources ? resources : (task.resources ?? undefined)) as Prisma.InputJsonValue,
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Vorschlag bestätigt und übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "OPEN", confirmed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Nicht zugewiesene Aufgabe aus dem Pool übernehmen: setzt sich selbst als Owner.
* Ein Vorschlag (PROPOSED) wird dabei zur offenen Aufgabe. Nur möglich, solange
* niemand sonst zugewiesen ist (kein „Wegnehmen").
*/
export async function claimTask(taskId: string) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId) throw new Error("Aufgabe ist bereits zugewiesen.");
if (!["PROPOSED", "OPEN"].includes(task.status)) throw new Error("Aufgabe ist nicht mehr offen.");
await db.task.update({
where: { id: taskId },
data: {
assigneeId: session.user.id,
status: "OPEN",
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Aufgabe aus dem Pool übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { assigneeId: session.user.id, claimed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgaben-Vorschlag verwerfen (Story B1): PROPOSED → DISCARDED, Grund pflicht. */
export async function discardProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag verwerfen.");
}
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Grund fürs Verwerfen angeben.");
await db.task.update({
where: { id: taskId },
data: {
status: "DISCARDED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DISCARDED", reason } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Editier-/Verwaltungsrecht: Beteiligte(r) oder Aufgaben-Oversight (task:read_all, z. B. PM/Admin). */
function canManageTask(
session: { user: { id: string; permissions?: string[] } },
task: { assigneeId: string | null; createdById: string | null },
): boolean {
return task.assigneeId === session.user.id || task.createdById === session.user.id || Boolean(session.user.permissions?.includes("task:read_all"));
}
const EDITABLE_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"]);
const BOARD_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE"]);
/** Statuswechsel per Kanban-Drag (OPEN/IN_PROGRESS/DONE). */
export async function updateTaskStatus(taskId: string, status: string) {
const { session, db } = await guard();
if (!BOARD_STATUS.has(status)) throw new Error("Ungültiger Status.");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu verschieben.");
if (task.status === status) return;
await db.task.update({
where: { id: taskId },
data: {
status,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Cockpit (M3, 2.3): Reihenfolge einer Kanban-Spalte per DnD persistieren. Der Client
* schickt die neue Reihenfolge der Aufgaben-IDs einer Spalte; `orderIdx` wird
* sequenziell (0..n) geschrieben. Nur verwaltbare Aufgaben werden angefasst.
*/
export async function reorderTasks(orderedIds: string[]) {
const { session, db } = await guard();
if (!Array.isArray(orderedIds) || orderedIds.length === 0) return;
const tasks = await db.task.findMany({
where: { id: { in: orderedIds } },
select: { id: true, assigneeId: true, createdById: true, orderIdx: true },
});
const byId = new Map(tasks.map((t) => [t.id, t]));
await db.$transaction(
orderedIds.flatMap((id, idx) => {
const t = byId.get(id);
if (!t || t.orderIdx === idx || !canManageTask(session, t)) return [];
return [db.task.update({ where: { id }, data: { orderIdx: idx } })];
}),
);
revalidatePath("/tasks");
}
/**
* Aufgabe bearbeiten (analog Maßnahme): Titel, Beschreibung, Status, Owner, Priorität,
* Fälligkeit. Für Beteiligte oder Aufgaben-Oversight. Kein Zugriff auf Review-Zustände
* (PROPOSED/REJECTED/DISCARDED laufen über die eigenen Aktionen).
*/
export async function updateTask(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu bearbeiten.");
const title = str(formData.get("title")).slice(0, 200) || task.title;
const description = str(formData.get("description")).slice(0, 5000) || null;
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const statusRaw = str(formData.get("status"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const status = EDITABLE_STATUS.has(statusRaw) ? statusRaw : task.status;
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, owner);
await db.task.update({
where: { id: taskId },
data: {
title,
description,
status,
assigneeId,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
dueDate: dueRaw ? due : null,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status, title } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
// SEC1: nur bei einem *Wechsel* des Zuständigen benachrichtigen — sonst würde
// jede Bearbeitung (Titel, Fälligkeit) eine Mail auslösen.
if (assigneeId && assigneeId !== task.assigneeId) {
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId,
taskTitle: title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgabe löschen (nur Beteiligte oder Aufgaben-Oversight). */
export async function deleteTask(taskId: string) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu löschen.");
await db.task.delete({ where: { id: taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "task", entityId: taskId });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Eigene Aufgabe über die UI anlegen (z. B. Projektdurchlauf) — direkt als offene Aufgabe.
* Für Maßnahmen (z. B. zu Risiken) gibt es den eigenen Maßnahmen-Weg.
*/
export async function createOwnTask(formData: FormData) {
await guard(); // Modul-/Rechtezugriff (createTask guardet zusätzlich)
const type = str(formData.get("type")) || "organizational";
const prio = str(formData.get("priority"));
const owner = str(formData.get("owner"));
const dueRaw = str(formData.get("dueDate"));
await createTask({
type: isTaskType(type) ? type : "organizational",
title: str(formData.get("title")),
description: str(formData.get("description")) || null,
origin: "manual",
priority: isTaskPriority(prio) ? prio : "mittel",
owner: owner || undefined,
dueDate: dueRaw || undefined,
});
redirect("/tasks");
}
// ── Cockpit (M3, 1.6): Nachweis-/Evidence-Register je Aufgabe ─────────────────
//
// Ein Nachweis (Evidence) ist die Voraussetzung für „audit-ready" (Wirksamkeit):
// die Auto-Completion schließt eine Aufgabe nur bis DONE (dokumentiert/umgesetzt),
// Reifegrad 3 verlangt zusätzlich einen verknüpften Nachweis + Vier-Augen (2.2).
// validUntil koppelt an Task.effectiveUntil (Wiedervorlage) und markiert Ablauf.
const evidenceKindSchema = z.enum(EVIDENCE_KINDS);
/** Optionales Datum aus dem Formular (YYYY-MM-DD) parsen; leer → null. */
function parseDate(raw: string, label: string): Date | null {
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) throw new Error(`Ungültiges ${label}.`);
return d;
}
/** Zugriff auf die Nachweise einer Aufgabe: Beteiligte(r) oder Aufgaben-Oversight. */
async function requireManageableTask(db: TenantDb, session: { user: { id: string; permissions?: string[] } }, taskId: string) {
const task = await db.task.findUnique({ where: { id: taskId }, select: { id: true, assigneeId: true, createdById: true } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung für die Nachweise dieser Aufgabe.");
return task;
}
/**
* Nachweis anlegen und direkt mit der Aufgabe verknüpfen (kein Datei-Upload —
* `fileRef` ist ein Text-/URL-Verweis auf die Ablage). Setzt optional das
* Wirksamkeitsintervall (validFrom/validUntil).
*/
export async function createEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const title = str(formData.get("title")).slice(0, 200);
if (!title) throw new Error("Der Nachweis braucht einen Titel.");
const kindParsed = evidenceKindSchema.safeParse(str(formData.get("kind")));
if (!kindParsed.success) throw new Error("Ungültige Nachweisart.");
const fileRef = str(formData.get("fileRef")).slice(0, 500) || null;
const control = str(formData.get("control")).slice(0, 200) || null;
const validFrom = parseDate(str(formData.get("validFrom")), "Gültig-ab-Datum");
const validUntil = parseDate(str(formData.get("validUntil")), "Gültig-bis-Datum");
const evidence = await db.evidence.create({
data: {
tenantId: session.user.tenantId,
title,
kind: kindParsed.data,
fileRef,
control,
validFrom,
validUntil,
taskId,
createdById: session.user.id,
},
select: { id: true },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "evidence", entityId: evidence.id, after: { taskId, kind: kindParsed.data } });
revalidatePath("/tasks");
}
/** Bestehenden (unverknüpften) Nachweis mit der Aufgabe verknüpfen. */
export async function linkEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const evidenceId = str(formData.get("evidenceId"));
if (!evidenceId) throw new Error("Kein Nachweis ausgewählt.");
// db ist mandantengebunden → das update trifft nur Nachweise dieses Mandanten.
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
await db.evidence.update({ where: { id: evidenceId }, data: { taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { taskId, linked: true } });
revalidatePath("/tasks");
}
/** Nachweis von der Aufgabe entfernen (Verknüpfung lösen — der Nachweis bleibt erhalten). */
export async function unlinkEvidence(evidenceId: string) {
const { session, db } = await guard("task:write");
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true, taskId: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
if (evidence.taskId) await requireManageableTask(db, session, evidence.taskId);
await db.evidence.update({ where: { id: evidenceId }, data: { taskId: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { unlinked: true } });
revalidatePath("/tasks");
}
/** Nachweise einer Aufgabe auflisten (für Server-Komponenten/Detailansicht). */
export async function listTaskEvidence(taskId: string) {
const { db } = await guard();
return db.evidence.findMany({ where: { taskId }, orderBy: { createdAt: "desc" } });
}
+18 -22
View File
@@ -3,14 +3,13 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { syncPolicyVariablesFromSettings } from "@/server/provision";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Kunden-Admin pflegt Stammdaten/Branding/Policy; Stammdaten speisen die ISMS-Variablen. */
/** Mandantenadministrator pflegt Unternehmensdaten, Branding und Regionales. */
export async function updateTenantSettings(formData: FormData) {
const session = await requireSession();
requirePermission(session, "tenant:manage");
@@ -18,40 +17,37 @@ export async function updateTenantSettings(formData: FormData) {
const db = dbForTenant(tenantId);
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
const emailRaw = str(formData.get("email"));
const email = emailRaw ? z.string().trim().email().parse(emailRaw) : null;
// TISAX-/Schutzbedarf-Tiefe ist eine Kern-Einstellung und wird ausschließlich vom
// Superadmin (Admin-Konsole) gesteuert — hier bewusst nicht anfassen.
const data = {
orgName,
orgShort: str(formData.get("orgShort")) || null,
address: str(formData.get("address")) || null,
phone: str(formData.get("phone")) || null,
email,
sector: str(formData.get("sector")) || null,
duns: str(formData.get("duns")) || null,
ismsScope: str(formData.get("ismsScope")) || null,
ismsScopeDescription: str(formData.get("ismsScopeDescription")) || null,
roleManagement: str(formData.get("roleManagement")) || null,
roleIsb: str(formData.get("roleIsb")) || null,
roleItLead: str(formData.get("roleItLead")) || null,
roleDpo: str(formData.get("roleDpo")) || null,
accent: str(formData.get("accent")) || null,
locale: str(formData.get("locale")) || "de",
locale: str(formData.get("locale")) === "en" ? "en" : "de",
timezone: str(formData.get("timezone")) || "Europe/Berlin",
// NIS2-Betroffenheit des Mandanten (steuert später die Meldefristen-Timer, IM-B).
nis2Category: (["keine", "wichtig", "wesentlich"].includes(str(formData.get("nis2Category")))
? str(formData.get("nis2Category"))
: "keine"),
};
const before = await db.tenantSettings.findUnique({ where: { tenantId } });
await db.tenantSettings.upsert({
where: { tenantId },
update: data,
create: { tenantId, ...data },
});
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName } });
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "update",
entity: "tenant_settings",
before: before
? { orgName: before.orgName, address: before.address, phone: before.phone, email: before.email, locale: before.locale }
: undefined,
after: { orgName, address: data.address, phone: data.phone, email: data.email, locale: data.locale },
});
revalidatePath("/settings");
revalidatePath("/policies");
}