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:
@@ -34,13 +34,13 @@ import { writeAuditLog } from "@/server/audit";
|
||||
* z. B.:
|
||||
*
|
||||
* ```ts
|
||||
* export async function submitRiskForReview(fd: FormData) {
|
||||
* const { session, db } = await moduleGuard("risks")("risk:write");
|
||||
* export async function updateCustomer(fd: FormData) {
|
||||
* const { session, db } = await moduleGuard("customers")("customer:write");
|
||||
* return withActionErrors(
|
||||
* async () => {
|
||||
* // … eigentliche Mutation …
|
||||
* },
|
||||
* { tenantId: session.user.tenantId, actorId: session.user.id, entity: "risk" },
|
||||
* { tenantId: session.user.tenantId, actorId: session.user.id, entity: "customer" },
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
|
||||
@@ -11,9 +11,9 @@ import { isTokenStillValid } from "@/server/sessions";
|
||||
* Modul-Aktivierung (wirft bei Deaktivierung + Audit). Gibt Session und den
|
||||
* mandantengebundenen Prisma-Client zurück.
|
||||
*
|
||||
* `moduleGuard("assets")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit
|
||||
* den benötigten Rechten auf, z. B. `const { session, db } = await guard("asset:write")`.
|
||||
* Mehrere Rechte werden alle geprüft (z. B. `guard("risk:write", "measure:write")`).
|
||||
* `moduleGuard("customers")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit
|
||||
* den benötigten Rechten auf, z. B. `const { session, db } = await guard("customer:write")`.
|
||||
* Mehrere Rechte werden alle geprüft (z. B. `guard("work_order:write", "work_order:assign")`).
|
||||
*
|
||||
* F-06: Kontostatus, Passwortzwang und effektive Rechte werden AUTORITATIV aus der
|
||||
* Datenbank geprüft, NICHT aus dem JWT. Sonst blieben ein deaktiviertes Konto und
|
||||
|
||||
+9
-174
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { getAnthropic, AI_MODEL } from "./client";
|
||||
|
||||
/**
|
||||
* KI-Entwurf einer „Beschreibung der Umsetzung" je ISA-Teilanforderung im
|
||||
* VDA-ISA-ABGABE-Stil. Aus dem Anforderungstext (MUSS/SOLL) sowie den
|
||||
* verknüpften Dokumenten/Nachweisen wird EIN konkreter „wie-umgesetzt"-Satz mit
|
||||
* Dokumentverweis formuliert. Die Konfidenz wird deterministisch aus der
|
||||
* Quellenlage abgeleitet (nicht vom Modell), damit sie nachvollziehbar bleibt.
|
||||
*
|
||||
* Graceful degradation: Ohne konfigurierten API-Key (oder bei einem Fehler des
|
||||
* Aufrufs) liefert `draftControlDescription` `null` — der Aufrufer belässt den
|
||||
* Status dann bei „manuell zu erfassen".
|
||||
*/
|
||||
|
||||
export interface DraftDocument {
|
||||
code: string;
|
||||
title: string;
|
||||
version: string;
|
||||
/** PolicyStatus (FREIGEGEBEN = validiert). */
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DraftInput {
|
||||
control: string;
|
||||
reqId: string;
|
||||
/** MUSS | SOLL */
|
||||
obligation: string;
|
||||
/** Anforderungstext (PolicyRequirement.requirement). */
|
||||
requirement: string;
|
||||
/** Umsetzungshinweis (PolicyRequirement.implementation). */
|
||||
implementation: string;
|
||||
/** Verknüpfte Richtlinien/Verfahren. */
|
||||
documents: DraftDocument[];
|
||||
/** Titel vorhandener Nachweise (Evidence) zum Control. */
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface DraftResult {
|
||||
/** Umsetzungssatz inkl. Dokumentverweis (ABGABE-Stil). */
|
||||
draftText: string;
|
||||
/** Kompakter Quellenverweis (Dokumentliste), semikolon-getrennt. */
|
||||
sourceRef: string | null;
|
||||
confidence: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfidenz aus der Quellenlage: ein freigegebenes (validiertes) Dokument UND
|
||||
* ein Nachweis ⇒ high; irgendein verknüpftes Dokument ⇒ medium; sonst low.
|
||||
*/
|
||||
function deriveConfidence(input: DraftInput): DraftResult["confidence"] {
|
||||
const hasApprovedDoc = input.documents.some((d) => d.status === "FREIGEGEBEN");
|
||||
const hasEvidence = input.evidence.length > 0;
|
||||
if (hasApprovedDoc && hasEvidence) return "high";
|
||||
if (input.documents.length > 0) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/** Semikolon-getrennter Dokumentverweis („R08 – Titel, Version 1.0"). */
|
||||
function buildSourceRef(docs: DraftDocument[]): string | null {
|
||||
if (docs.length === 0) return null;
|
||||
return docs.map((d) => `${d.code} – ${d.title}, Version ${d.version}`).join("; ");
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"Du unterstützt bei der Erstellung einer VDA-ISA-Prüfungsdokumentation (TISAX).",
|
||||
"Formuliere für eine einzelne Anforderung EINEN präzisen deutschen Satz im ABGABE-Stil,",
|
||||
"der beschreibt, WIE die Anforderung umgesetzt ist. Der Satz nennt konkrete Spezifika",
|
||||
"aus dem Umsetzungshinweis und schließt mit einem Dokumentverweis in Klammern ab,",
|
||||
"Form: (<Dokument-Code> – <Titel>, Version <X>, Abschnitt <passend>).",
|
||||
"Erfinde keine Fakten, keine Zahlen und keine Abschnitte, die sich nicht aus den",
|
||||
"Eingaben ableiten lassen. Wenn kein Dokument verknüpft ist, lasse den Klammerzusatz weg.",
|
||||
"Antworte ausschließlich mit dem Satz — ohne Aufzählungszeichen, ohne Anführungszeichen,",
|
||||
"ohne Vor- oder Nachbemerkung und ohne interne XML-Tags.",
|
||||
].join(" ");
|
||||
|
||||
function buildUserPrompt(input: DraftInput): string {
|
||||
const docs =
|
||||
input.documents.length > 0
|
||||
? input.documents
|
||||
.map((d) => `- ${d.code}: ${d.title} (Version ${d.version}, Status ${d.status})`)
|
||||
.join("\n")
|
||||
: "- (keine verknüpften Dokumente)";
|
||||
const evidence =
|
||||
input.evidence.length > 0 ? input.evidence.map((e) => `- ${e}`).join("\n") : "- (keine Nachweise vorhanden)";
|
||||
return [
|
||||
`Control: ${input.control} (${input.reqId}), Verbindlichkeit: ${input.obligation}`,
|
||||
`Anforderung: ${input.requirement}`,
|
||||
`Umsetzungshinweis: ${input.implementation || "(keiner)"}`,
|
||||
`Verknüpfte Dokumente:\n${docs}`,
|
||||
`Vorhandene Nachweise:\n${evidence}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den KI-Entwurf. Liefert `null`, wenn keine KI konfiguriert ist oder
|
||||
* der Aufruf fehlschlägt (kein Fehler-Spam; der Aufrufer degradiert sauber).
|
||||
*/
|
||||
export async function draftControlDescription(input: DraftInput): Promise<DraftResult | null> {
|
||||
const client = getAnthropic();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: AI_MODEL,
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: "user", content: buildUserPrompt(input) }],
|
||||
});
|
||||
|
||||
if (response.stop_reason === "refusal") return null;
|
||||
const text = response.content
|
||||
.filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
.trim();
|
||||
if (!text) return null;
|
||||
|
||||
return {
|
||||
draftText: text,
|
||||
sourceRef: buildSourceRef(input.documents),
|
||||
confidence: deriveConfidence(input),
|
||||
};
|
||||
} catch {
|
||||
// Netzwerk-/API-Fehler dürfen die UI nicht blockieren.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { dbForTenant } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Assessment-Level (AL2/AL3) — **einzige Quelle der Wahrheit** für den Schutzbedarf
|
||||
* (Story A2-1). Wird ausschließlich im Admin-/Superadmin-Portal je Mandant gesetzt
|
||||
* (`setTenantTisaxLevel`, Feld `TenantSettings.tisaxLevel`). Im Tenant und im Wizard
|
||||
* nur read-only. Von hier leiten sich die zentralen Schutzbedarf-Flags ab — dieselbe
|
||||
* Ableitung nutzen Admin-Persistierung (policyVariable) **und** der Wizard-Seed.
|
||||
*/
|
||||
type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
|
||||
export type AssessmentLevel = "AL2" | "AL3";
|
||||
|
||||
/** Aktuelles Assessment-Level des Mandanten (Default AL2). */
|
||||
export async function getAssessmentLevel(db: TenantDb): Promise<AssessmentLevel> {
|
||||
const s = await db.tenantSettings.findFirst({ select: { tisaxLevel: true } });
|
||||
return s?.tisaxLevel === "AL3" ? "AL3" : "AL2";
|
||||
}
|
||||
|
||||
/**
|
||||
* AL → zentrale Schutzbedarf-Flags. HIGH ist im TISAX-Modell stets aktiv (AL2-Baseline),
|
||||
* VERY_HIGH nur bei AL3; ELEVATED ist abgeleitet (HIGH || VERY_HIGH).
|
||||
*/
|
||||
export function protectionFlags(level: AssessmentLevel): Record<string, boolean> {
|
||||
return {
|
||||
FLAG_HIGH_PROTECTION: true,
|
||||
FLAG_VERY_HIGH_PROTECTION: level === "AL3",
|
||||
FLAG_ELEVATED_PROTECTION: true,
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// B1 — buildAssessment(db, tenantId, framework): der framework-parametrisierte
|
||||
// Einstieg in die Bewertung. Delegiert an die Strategie (Feindesign §3). Die
|
||||
// Belegauflösung (loadEvidenceResolver, Schicht 1) ist GETEILT und kennt kein Framework.
|
||||
//
|
||||
// TISAX bleibt bewusst über buildControlRows (unveränderte, snapshot-gesicherte Ausgabe,
|
||||
// §7a Regressionsschutz) — buildAssessment("TISAX") adaptiert sie in AssessmentRow.
|
||||
// ISO rechnet über die SoA-Anwendbarkeit + den geteilten Belegstatus.
|
||||
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows, loadEvidenceResolver } from "@/server/soa-context";
|
||||
import { controlSpecIso, ISO_SPEC_CONTROLS } from "@/lib/control-specs-iso";
|
||||
import { interpretationBand, averageReifegrad, type ControlAssessment } from "@/lib/readiness";
|
||||
import {
|
||||
isoSuggestStatus, isoGaps, isoBand,
|
||||
type FrameworkKey, type ImplStatus, type AssessmentRow, type ReadinessSummary,
|
||||
} from "@/lib/assessment";
|
||||
|
||||
export interface Assessment {
|
||||
framework: FrameworkKey;
|
||||
rows: AssessmentRow[];
|
||||
summary: ReadinessSummary;
|
||||
}
|
||||
|
||||
const nf1 = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
|
||||
/** TISAX-Bewertung: verhaltensgleich zu buildControlRows, in AssessmentRow gegossen. */
|
||||
async function buildTisaxAssessment(db: TenantDb, tenantId: string): Promise<Assessment> {
|
||||
const { rows } = await buildControlRows(db, tenantId);
|
||||
const assessmentRows: AssessmentRow[] = rows.map((r) => ({
|
||||
control: r.control,
|
||||
title: r.spec.title,
|
||||
spec: r.spec,
|
||||
evidence: r.evidence,
|
||||
verdict: { kind: "maturity", suggestion: r.suggestion, confirmed: r.confirmed, target: r.target },
|
||||
gaps: r.gaps,
|
||||
}));
|
||||
|
||||
// Ø-Reifegrad wie in der Readiness-Sicht (unbestätigt zählt mit 0, C9 §1).
|
||||
const ca: ControlAssessment[] = rows.map((r) => ({
|
||||
control: r.control,
|
||||
chapter: r.control.split(".")[0],
|
||||
reifegrad: r.confirmed ?? 0,
|
||||
bestaetigt: r.confirmed != null,
|
||||
}));
|
||||
const avg = averageReifegrad(ca);
|
||||
const band = interpretationBand(avg ?? 0);
|
||||
const fulfilled = rows.filter((r) => r.confirmed != null && r.confirmed >= r.target).length;
|
||||
|
||||
return {
|
||||
framework: "TISAX",
|
||||
rows: assessmentRows,
|
||||
summary: {
|
||||
framework: "TISAX",
|
||||
band: band.band,
|
||||
tone: band.tone,
|
||||
text: band.text,
|
||||
metricLabel: "Ø-Reifegrad",
|
||||
metricValue: avg == null ? "—" : nf1(avg),
|
||||
total: rows.length,
|
||||
fulfilled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isAnnex = (control: string) => control.startsWith("A.");
|
||||
|
||||
/** ISO-Bewertung: Scope aus der SoA-Anwendbarkeit + Klauseln 4–10 (immer), Status statt Reifegrad. */
|
||||
async function buildIsoAssessment(db: TenantDb): Promise<Assessment> {
|
||||
const soa = await db.soaEntry.findMany({
|
||||
where: { framework: "ISO_27001" },
|
||||
select: { control: true, applicable: true, justification: true, implementationStatus: true },
|
||||
});
|
||||
const soaByControl = new Map(soa.map((s) => [s.control, s]));
|
||||
// Belegauflösung ist geteilt und framework-blind; ISO nutzt keinen Hinweis-Nachweis
|
||||
// (operationalProof) → leeres completeControls.
|
||||
const resolve = await loadEvidenceResolver(db, new Set());
|
||||
|
||||
const inScope = ISO_SPEC_CONTROLS.filter((c) => !isAnnex(c) || (soaByControl.get(c)?.applicable ?? false));
|
||||
const rows: AssessmentRow[] = inScope.map((control) => {
|
||||
const spec = controlSpecIso(control);
|
||||
const ev = resolve(spec);
|
||||
const entry = soaByControl.get(control);
|
||||
const applicable = isAnnex(control) ? (entry?.applicable ?? false) : true;
|
||||
const confirmed = (entry?.implementationStatus as ImplStatus | undefined) ?? null;
|
||||
const suggested = isoSuggestStatus(spec, ev);
|
||||
// Klauseln 4–10 sind nicht Gegenstand der SoA-Anwendbarkeit → keine Begründungslücke.
|
||||
const justification = isAnnex(control) ? (entry?.justification ?? "") : "(Klausel — immer anwendbar)";
|
||||
return {
|
||||
control,
|
||||
title: spec.title,
|
||||
spec,
|
||||
evidence: ev,
|
||||
verdict: { kind: "status", applicable, suggested, confirmed },
|
||||
gaps: isoGaps(spec, ev, applicable, justification, confirmed),
|
||||
};
|
||||
});
|
||||
|
||||
// Leere SoA → keine Anwendbarkeitsaussage (DoD: nicht „0 %", sondern Hinweis).
|
||||
if (soa.length === 0) {
|
||||
return {
|
||||
framework: "ISO_27001",
|
||||
rows,
|
||||
summary: {
|
||||
framework: "ISO_27001", band: "Anwendbarkeit offen", tone: "mut",
|
||||
text: "Die Anwendbarkeit der Controls ist noch nicht erklärt. Bitte zuerst die SoA ausfüllen (/soa) — danach rechnet die Readiness.",
|
||||
metricLabel: "Umsetzungsgrad", metricValue: "—", total: rows.length, fulfilled: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Effektiver Status: bestätigter SoA-Status, sonst der evidenzbasierte Vorschlag
|
||||
// (Klauseln haben keine SoA-Bestätigung → Vorschlag).
|
||||
const effective = (r: AssessmentRow): ImplStatus =>
|
||||
r.verdict.kind === "status" ? (r.verdict.confirmed ?? r.verdict.suggested) : "geplant";
|
||||
const fulfilled = rows.filter((r) => effective(r) === "umgesetzt").length;
|
||||
const ratio = rows.length ? fulfilled / rows.length : 0;
|
||||
const band = isoBand(ratio);
|
||||
|
||||
return {
|
||||
framework: "ISO_27001",
|
||||
rows,
|
||||
summary: {
|
||||
framework: "ISO_27001",
|
||||
band: band.band, tone: band.tone, text: band.text,
|
||||
metricLabel: "Umsetzungsgrad",
|
||||
metricValue: `${Math.round(ratio * 100)} %`,
|
||||
total: rows.length,
|
||||
fulfilled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Framework-parametrisierte Bewertung. TISAX bleibt snapshot-identisch, ISO rechnet über die SoA. */
|
||||
export async function buildAssessment(db: TenantDb, tenantId: string, framework: FrameworkKey): Promise<Assessment> {
|
||||
return framework === "ISO_27001" ? buildIsoAssessment(db) : buildTisaxAssessment(db, tenantId);
|
||||
}
|
||||
@@ -42,58 +42,9 @@ export const TENANT_MODELS: readonly string[] = [
|
||||
"MailLog",
|
||||
"NotificationPreference",
|
||||
"AuthToken",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TaskParticipant",
|
||||
"Evidence",
|
||||
"Audit",
|
||||
"AuditEvidenceItem",
|
||||
"ControlDescription",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"OnboardingProgress",
|
||||
"ProjectFunctionAssignment",
|
||||
"WizardFact",
|
||||
"WizardScope",
|
||||
"PolicyPackageState",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"SupplierProfile",
|
||||
"ITServiceProfile",
|
||||
"SoftwareProfile",
|
||||
"ProjectProfile",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceControlResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"ControlAssessment",
|
||||
"ControlImplementation",
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
// Craftvia-Fachmodelle hier ergänzen (identisch zu src/server/db.ts).
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import type { ControlDescription } from "@prisma/client";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import type { DraftInput } from "@/server/ai/draft-control-description";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Server-Kontext für die Control-Beschreibungen (VDA-ISA-Spalte 4).
|
||||
* Bündelt je Control die Kontrollfrage, die Anforderungs-Bullets aus
|
||||
* `PolicyRequirement`, die verknüpften Richtlinien/Verfahren (`PolicyDocument`)
|
||||
* sowie den vorhandenen Nachweisstand (`Evidence`) und die bereits erfassten
|
||||
* `ControlDescription`-Zeilen. Wird von der Controls-Seite, der ABGABE-Vorschau
|
||||
* und den KI-/Speicher-Actions geteilt (eine Aufbereitungsstelle).
|
||||
*/
|
||||
|
||||
export type DescriptionStatus = "open" | "draft" | "confirmed";
|
||||
|
||||
export interface RequirementBullet {
|
||||
reqId: string;
|
||||
obligation: string; // MUSS | SOLL
|
||||
requirement: string;
|
||||
implementation: string;
|
||||
/** Verknüpfte Dokumente (policyCode + vaCodes → PolicyDocument). */
|
||||
documents: { code: string; title: string; version: string; status: string }[];
|
||||
/** Bereits erfasste Beschreibung (Entwurf/übernommen) oder null. */
|
||||
description: {
|
||||
draftText: string | null;
|
||||
sourceRef: string | null;
|
||||
confidence: string | null;
|
||||
status: DescriptionStatus;
|
||||
openAnswer: string | null;
|
||||
} | null;
|
||||
/** True, wenn zum Control mindestens ein Nachweis existiert. */
|
||||
hasEvidence: boolean;
|
||||
}
|
||||
|
||||
/** Status-Ampel je Control: grün (alle übernommen) / orange (teils) / rot (keine). */
|
||||
export type ControlLamp = "green" | "amber" | "red";
|
||||
|
||||
export interface ControlGroup {
|
||||
control: string;
|
||||
frage: string;
|
||||
bullets: RequirementBullet[];
|
||||
/** Nachweistitel des Controls (Evidence). */
|
||||
evidence: string[];
|
||||
lamp: ControlLamp;
|
||||
}
|
||||
|
||||
function descStatus(row: ControlDescription | undefined): DescriptionStatus {
|
||||
const s = row?.status;
|
||||
return s === "confirmed" || s === "draft" ? s : "open";
|
||||
}
|
||||
|
||||
/** Ampel aus den Beschreibungs-Status der Bullets. */
|
||||
function lampOf(bullets: RequirementBullet[]): ControlLamp {
|
||||
if (bullets.length === 0) return "red";
|
||||
const confirmed = bullets.filter((b) => b.description?.status === "confirmed").length;
|
||||
if (confirmed === bullets.length) return "green";
|
||||
const started = bullets.filter((b) => b.description && b.description.status !== "open").length;
|
||||
return started > 0 ? "amber" : "red";
|
||||
}
|
||||
|
||||
/** Lädt alle Controls (mit Anforderungs-Bullets, Dokumenten, Nachweisen, Beschreibungen). */
|
||||
export async function buildControlGroups(db: TenantDb): Promise<ControlGroup[]> {
|
||||
const [reqs, docs, evidence, descriptions] = await Promise.all([
|
||||
db.policyRequirement.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { reqId: true, control: true, obligation: true, requirement: true, implementation: true, policyCode: true, vaCodes: true },
|
||||
}),
|
||||
db.policyDocument.findMany({ where: { archivedAt: null }, select: { code: true, title: true, version: true, status: true } }),
|
||||
db.evidence.findMany({ where: { control: { not: null } }, select: { control: true, title: true } }),
|
||||
db.controlDescription.findMany(),
|
||||
]);
|
||||
|
||||
const docByCode = new Map(docs.map((d) => [d.code, d]));
|
||||
const descByReq = new Map(descriptions.map((d) => [d.reqId, d]));
|
||||
const evidenceByControl = new Map<string, string[]>();
|
||||
for (const e of evidence) {
|
||||
if (!e.control) continue;
|
||||
const list = evidenceByControl.get(e.control) ?? [];
|
||||
list.push(e.title);
|
||||
evidenceByControl.set(e.control, list);
|
||||
}
|
||||
|
||||
const byControl = new Map<string, RequirementBullet[]>();
|
||||
for (const r of reqs) {
|
||||
const codes = [r.policyCode, ...r.vaCodes].filter(Boolean);
|
||||
const documents = [...new Set(codes)]
|
||||
.map((c) => docByCode.get(c))
|
||||
.filter((d): d is NonNullable<typeof d> => Boolean(d))
|
||||
.map((d) => ({ code: d.code, title: d.title, version: d.version, status: d.status }));
|
||||
const row = descByReq.get(r.reqId);
|
||||
const bullet: RequirementBullet = {
|
||||
reqId: r.reqId,
|
||||
obligation: r.obligation,
|
||||
requirement: r.requirement,
|
||||
implementation: r.implementation,
|
||||
documents,
|
||||
description: row
|
||||
? { draftText: row.draftText, sourceRef: row.sourceRef, confidence: row.confidence, status: descStatus(row), openAnswer: row.openAnswer }
|
||||
: null,
|
||||
hasEvidence: (evidenceByControl.get(r.control)?.length ?? 0) > 0,
|
||||
};
|
||||
const list = byControl.get(r.control) ?? [];
|
||||
list.push(bullet);
|
||||
byControl.set(r.control, list);
|
||||
}
|
||||
|
||||
return [...byControl.entries()]
|
||||
.sort((a, b) => compareControl(a[0], b[0]))
|
||||
.map(([control, bullets]) => ({
|
||||
control,
|
||||
frage: controlTitle(control),
|
||||
bullets: bullets.sort((a, b) => a.reqId.localeCompare(b.reqId, undefined, { numeric: true })),
|
||||
evidence: evidenceByControl.get(control) ?? [],
|
||||
lamp: lampOf(bullets),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den KI-Eingabekontext für eine einzelne Anforderung (reqId). Liefert
|
||||
* `null`, wenn die Anforderung nicht existiert. Wird von der Draft-Action genutzt.
|
||||
*/
|
||||
export async function buildDraftInput(db: TenantDb, reqId: string): Promise<DraftInput | null> {
|
||||
const r = await db.policyRequirement.findFirst({
|
||||
where: { reqId, archivedAt: null },
|
||||
select: { reqId: true, control: true, obligation: true, requirement: true, implementation: true, policyCode: true, vaCodes: true },
|
||||
});
|
||||
if (!r) return null;
|
||||
|
||||
const codes = [...new Set([r.policyCode, ...r.vaCodes].filter(Boolean))];
|
||||
const [docs, evidence] = await Promise.all([
|
||||
db.policyDocument.findMany({ where: { code: { in: codes }, archivedAt: null }, select: { code: true, title: true, version: true, status: true } }),
|
||||
db.evidence.findMany({ where: { control: r.control }, select: { title: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
control: r.control,
|
||||
reqId: r.reqId,
|
||||
obligation: r.obligation,
|
||||
requirement: r.requirement,
|
||||
implementation: r.implementation,
|
||||
documents: docs.map((d) => ({ code: d.code, title: d.title, version: d.version, status: d.status })),
|
||||
evidence: evidence.map((e) => e.title),
|
||||
};
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// Cockpit (M3, 1.4): serverseitige Auflösung Control → Bereich + Default-RACI.
|
||||
//
|
||||
// Reihenfolge: Tenant-Override (`ControlDomainMap.tenantId = <tenant>`) vor globalem
|
||||
// Default (`tenantId = null`); je Ebene exakter Control-Match vor Kapitel-Präfix.
|
||||
// Fällt auf die statischen Defaults (`CONTROL_DOMAIN_DEFAULTS`) zurück, falls die
|
||||
// Tabelle (noch) nicht geseedet ist.
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Domain, RaciKind } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import {
|
||||
CONTROL_DOMAIN_DEFAULTS,
|
||||
matchControlRules,
|
||||
type ControlDomainRule,
|
||||
} from "@/lib/control-domain";
|
||||
|
||||
export interface ResolvedControlDomain {
|
||||
/** Primärer Bereich (RESPONSIBLE) — Default für Task.domain. */
|
||||
domain: Domain | null;
|
||||
/** Alle Regeln (RESPONSIBLE + Mitwirkende) für die beste Genauigkeitsstufe. */
|
||||
rules: ControlDomainRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereich + Default-RACI für ein Control ermitteln. Für die Aufgaben-Erzeugung
|
||||
* (soa/gap/trigger) — setzt `Task.domain` und liefert die Default-Mitwirkenden.
|
||||
*/
|
||||
export async function resolveControlDomain(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
): Promise<ResolvedControlDomain> {
|
||||
if (!control) return { domain: null, rules: [] };
|
||||
|
||||
// Tenant-Override zuerst, sonst globaler Default. Nur die potenziell relevanten
|
||||
// Zeilen laden (exaktes Control ODER Kapitel-Präfix).
|
||||
const chapter = String(control).trim().split(".")[0] ?? "";
|
||||
const rows = await db.controlDomainMap.findMany({
|
||||
where: {
|
||||
control: { in: [String(control).trim(), chapter] },
|
||||
OR: [{ tenantId }, { tenantId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
// Tenant-Override gewinnt: gibt es für die beste Genauigkeitsstufe Tenant-Zeilen,
|
||||
// nur diese verwenden.
|
||||
const asRules = (list: typeof rows): ControlDomainRule[] =>
|
||||
list.map((r) => ({ control: r.control, domain: r.domain, raci: r.raci, functionKey: r.functionKey ?? undefined }));
|
||||
|
||||
const tenantRules = matchControlRules(control, asRules(rows.filter((r) => r.tenantId === tenantId)));
|
||||
const globalRules = matchControlRules(control, asRules(rows.filter((r) => r.tenantId === null)));
|
||||
|
||||
let rules = tenantRules.length ? tenantRules : globalRules;
|
||||
// DB nicht geseedet → statischer Fallback.
|
||||
if (!rules.length) rules = matchControlRules(control, CONTROL_DOMAIN_DEFAULTS);
|
||||
|
||||
const primary = rules.find((r) => r.raci === "RESPONSIBLE") ?? rules[0];
|
||||
return { domain: primary?.domain ?? null, rules };
|
||||
}
|
||||
|
||||
/** Nur die Default-Domain (RESPONSIBLE) — Kurzform für Task-Erzeugung. */
|
||||
export async function domainForControl(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
): Promise<Domain | null> {
|
||||
const { domain } = await resolveControlDomain(db, tenantId, control);
|
||||
return domain;
|
||||
}
|
||||
|
||||
/** Nachrangige Mitwirkende (nicht RESPONSIBLE) mit Funktions-Default. */
|
||||
export function consultedFunctions(rules: ControlDomainRule[]): { functionKey: string; raci: RaciKind }[] {
|
||||
return rules
|
||||
.filter((r) => r.raci !== "RESPONSIBLE" && r.functionKey)
|
||||
.map((r) => ({ functionKey: r.functionKey as string, raci: r.raci }));
|
||||
}
|
||||
|
||||
// ── Cockpit (M3, 2.3): RACI-Auto-Befüllung (TaskParticipant) ──────────────────
|
||||
//
|
||||
// Auflösung Control/Domain → zuständige Funktionen (RESPONSIBLE + CONSULTED) →
|
||||
// über ProjectFunctionAssignment (functionKey→userId, je Tenant) zu Usern →
|
||||
// TaskParticipant-Zeilen. Zentral aus der Task-Erzeugung aufgerufen; sobald die
|
||||
// Funktion→User-Zuordnung (Ebene 1) gepflegt ist, bekommen neue Aufgaben
|
||||
// automatisch Mitwirkende.
|
||||
|
||||
/** RACI-Rang: RESPONSIBLE gewinnt vor CONSULTED bei Funktions-Dedup. */
|
||||
const RACI_RANK: Record<RaciKind, number> = { RESPONSIBLE: 0, ACCOUNTABLE: 1, CONSULTED: 2, INFORMED: 3 };
|
||||
|
||||
/** Nur die für die Auto-Befüllung relevanten Rollen (Sichtbarkeit + Verantwortung). */
|
||||
const RACI_ASSIGNABLE: RaciKind[] = ["RESPONSIBLE", "CONSULTED"];
|
||||
|
||||
/** Für eine Domain die zuständigen Funktionen aus ControlDomainMap (Tenant-Override vor global, sonst statischer Fallback). */
|
||||
async function domainFunctionRules(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
domain: Domain,
|
||||
): Promise<ControlDomainRule[]> {
|
||||
// ControlDomainMap ist ein globales Modell (kein Auto-Tenant-Filter) → explizit
|
||||
// Tenant-Override ODER globalen Default laden.
|
||||
const rows = await db.controlDomainMap.findMany({
|
||||
where: { domain, OR: [{ tenantId }, { tenantId: null }] },
|
||||
});
|
||||
const tenantRows = rows.filter((r) => r.tenantId === tenantId);
|
||||
const chosen = tenantRows.length ? tenantRows : rows.filter((r) => r.tenantId === null);
|
||||
if (chosen.length) {
|
||||
return chosen.map((r) => ({ control: r.control, domain: r.domain, raci: r.raci, functionKey: r.functionKey ?? undefined }));
|
||||
}
|
||||
// DB (noch) nicht geseedet → statischer Fallback.
|
||||
return CONTROL_DOMAIN_DEFAULTS.filter((r) => r.domain === domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuständige Funktionen (RESPONSIBLE + CONSULTED) für Control und/oder Domain.
|
||||
* Dedupliziert je functionKey (RESPONSIBLE gewinnt vor CONSULTED).
|
||||
*/
|
||||
async function responsibleFunctions(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
domain: Domain | null | undefined,
|
||||
): Promise<{ functionKey: string; raci: RaciKind }[]> {
|
||||
const best = new Map<string, RaciKind>();
|
||||
const add = (functionKey: string | undefined, raci: RaciKind) => {
|
||||
if (!functionKey || !RACI_ASSIGNABLE.includes(raci)) return;
|
||||
const cur = best.get(functionKey);
|
||||
if (cur == null || RACI_RANK[raci] < RACI_RANK[cur]) best.set(functionKey, raci);
|
||||
};
|
||||
|
||||
if (control) {
|
||||
const { rules } = await resolveControlDomain(db, tenantId, control);
|
||||
for (const r of rules) add(r.functionKey, r.raci);
|
||||
}
|
||||
if (domain) {
|
||||
for (const r of await domainFunctionRules(db, tenantId, domain)) add(r.functionKey, r.raci);
|
||||
}
|
||||
return [...best].map(([functionKey, raci]) => ({ functionKey, raci }));
|
||||
}
|
||||
|
||||
export interface AssignTaskParticipantsInput {
|
||||
taskId: string;
|
||||
control?: string | null;
|
||||
domain?: Domain | null;
|
||||
/** Primär-Verantwortliche(r) (Task.assigneeId) — nicht doppelt als RESPONSIBLE-Participant. */
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RACI-Auto-Befüllung: aus ControlDomainMap (Control und/oder Domain) die zuständigen
|
||||
* Funktionen (RESPONSIBLE + CONSULTED, Tenant-Override vor global) auflösen, über
|
||||
* ProjectFunctionAssignment (functionKey→userId je Tenant) zu Usern mappen und
|
||||
* TaskParticipant-Zeilen anlegen. Unbesetzte Funktionen (userId null) werden
|
||||
* übersprungen. Idempotent (Unique tenantId+taskId+userId+raci; P2002 wird
|
||||
* abgefangen). Der assigneeId wird NICHT doppelt als RESPONSIBLE-Participant gesetzt.
|
||||
* Gibt die Anzahl neu angelegter Mitwirkenden-Zeilen zurück.
|
||||
*/
|
||||
export async function assignTaskParticipants(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
input: AssignTaskParticipantsInput,
|
||||
): Promise<number> {
|
||||
const functions = await responsibleFunctions(db, tenantId, input.control, input.domain);
|
||||
if (!functions.length) return 0;
|
||||
|
||||
const keys = [...new Set(functions.map((f) => f.functionKey))];
|
||||
// db ist mandantengebunden → ProjectFunctionAssignment wird automatisch tenant-gefiltert.
|
||||
const assignments = await db.projectFunctionAssignment.findMany({
|
||||
where: { functionKey: { in: keys } },
|
||||
select: { functionKey: true, userId: true },
|
||||
});
|
||||
const usersByFunction = new Map<string, string[]>();
|
||||
for (const a of assignments) {
|
||||
if (!a.userId) continue; // unbesetzte Funktion überspringen
|
||||
const list = usersByFunction.get(a.functionKey) ?? [];
|
||||
if (!list.includes(a.userId)) list.push(a.userId);
|
||||
usersByFunction.set(a.functionKey, list);
|
||||
}
|
||||
|
||||
// (userId, raci) deduplizieren; assignee nicht doppelt als RESPONSIBLE.
|
||||
const wanted = new Map<string, { userId: string; raci: RaciKind }>();
|
||||
for (const fn of functions) {
|
||||
for (const userId of usersByFunction.get(fn.functionKey) ?? []) {
|
||||
if (fn.raci === "RESPONSIBLE" && userId === input.assigneeId) continue;
|
||||
wanted.set(`${userId}::${fn.raci}`, { userId, raci: fn.raci });
|
||||
}
|
||||
}
|
||||
if (!wanted.size) return 0;
|
||||
|
||||
let created = 0;
|
||||
for (const { userId, raci } of wanted.values()) {
|
||||
try {
|
||||
// tenantId zusätzlich explizit (Prisma-Typ verlangt es; der Tenant-Guard setzt ihn ohnehin).
|
||||
await db.taskParticipant.create({ data: { tenantId, taskId: input.taskId, userId, raci } });
|
||||
created++;
|
||||
} catch (e) {
|
||||
// Idempotenz: Wettlauf/Doppelanlage über den Unique-Constraint abfangen.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
+13
-88
@@ -2,9 +2,9 @@ import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
/**
|
||||
* Central database access for the ISMS tool.
|
||||
* Central database access for Craftvia.
|
||||
*
|
||||
* Multi-tenant rule (see docs/SPEC.md §2): no query on tenant-scoped models
|
||||
* Multi-tenant rule (see docs/craftvia/SPEC-CRAFTVIA.md §5): no query on tenant-scoped models
|
||||
* without a tenant context. Application code MUST use `dbForTenant(tenantId)`
|
||||
* for all tenant-scoped models — the raw client is only for global data
|
||||
* (permissions, control catalogs) and platform administration.
|
||||
@@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
||||
* ohne FORCE nicht, die alleinige Isolation ist der Tenant-Guard unten.
|
||||
*
|
||||
* Ist `RLS_ENFORCED=true`, verbindet sich die App zusätzlich als eingeschränkte
|
||||
* Rolle `isms_app` über `RLS_DATABASE_URL` (NOBYPASSRLS). Für diese Rolle sind
|
||||
* Rolle `craftvia_app` über `RLS_DATABASE_URL` (NOBYPASSRLS). Für diese Rolle sind
|
||||
* die Policies unter `FORCE ROW LEVEL SECURITY` scharf. `dbForTenant` setzt dann
|
||||
* pro Operation den Mandantenkontext (`app.tenant_id`) TRANSAKTIONSLOKAL und
|
||||
* führt die Operation auf DERSELBEN Transaktions-Connection aus — nur so greift
|
||||
@@ -51,7 +51,7 @@ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
export const RLS_ENFORCED = process.env.RLS_ENFORCED === "true";
|
||||
|
||||
/**
|
||||
* Basis-Client der eingeschränkten App-Rolle `isms_app` (nur bei aktivem Flag).
|
||||
* Basis-Client der eingeschränkten App-Rolle `craftvia_app` (nur bei aktivem Flag).
|
||||
* Uneextendiert — wird in `dbForTenant` gezielt für die transaktionslokale
|
||||
* Kontextsetzung genutzt, damit die Guard-Extension nicht rekursiv greift.
|
||||
*
|
||||
@@ -64,8 +64,8 @@ if (RLS_ENFORCED) {
|
||||
if (!rlsUrl) {
|
||||
throw new Error(
|
||||
"RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt. Setze eine Verbindung als " +
|
||||
"Rolle isms_app (LOGIN, NOBYPASSRLS), z. B. " +
|
||||
"postgresql://isms_app:<pw>@<host>:5432/isms?schema=public — sonst würde die " +
|
||||
"Rolle craftvia_app (LOGIN, NOBYPASSRLS), z. B. " +
|
||||
"postgresql://craftvia_app:<pw>@<host>:5432/craftvia?schema=public — sonst würde die " +
|
||||
"App als Owner ohne scharfe RLS laufen (fail secure).",
|
||||
);
|
||||
}
|
||||
@@ -92,87 +92,12 @@ const TENANT_MODELS = new Set<string>([
|
||||
// davor, dass ein Token versehentlich ohne Mandantenfilter über dbForTenant
|
||||
// gelesen wird.
|
||||
"AuthToken",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TaskParticipant",
|
||||
"Evidence",
|
||||
"Audit",
|
||||
"AuditEvidenceItem",
|
||||
"ControlDescription",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"OnboardingProgress",
|
||||
"ProjectFunctionAssignment",
|
||||
"WizardFact",
|
||||
"WizardScope",
|
||||
"PolicyPackageState",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"ProcessDependency",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"SupplierProfile",
|
||||
"ITServiceProfile",
|
||||
"SoftwareProfile",
|
||||
"ProjectProfile",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceControlResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"ControlAssessment",
|
||||
"ControlImplementation",
|
||||
// Incident-Management (IM-A)
|
||||
"Incident",
|
||||
"IncidentComment",
|
||||
"IncidentAsset",
|
||||
"IncidentProcess",
|
||||
"IncidentRisk",
|
||||
"IncidentControl",
|
||||
"IncidentMeasure",
|
||||
"IncidentEvidence",
|
||||
"IncidentAttachment",
|
||||
// IM-D — mandantengebundene Intake-Konfiguration (RLS). Die Review-Queue
|
||||
// (IncidentInboundReview) ist bewusst plattformweit → NICHT hier.
|
||||
"IncidentIntakeConfig",
|
||||
// WS4b: WebAuthnCredential ist jetzt identitäts-global (kein tenant_id) → NICHT hier.
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
// AP1: Framework-Zugehörigkeit je Mandant (RLS-Policy in der Migration).
|
||||
"TenantFramework",
|
||||
// AP3: Anwendbarkeitserklärung (SoA) je Mandant/Framework (RLS-Policy in der Migration).
|
||||
"SoaEntry",
|
||||
// AP4: Managementklauseln (Kennzahlen 9.1, Managementbewertung 9.3, CAPA 10.2).
|
||||
"Kpi",
|
||||
"KpiValue",
|
||||
"ManagementReview",
|
||||
"ManagementReviewDecision",
|
||||
"Nonconformity",
|
||||
"CorrectiveAction",
|
||||
// AP5: Dokumentenlenkung (Lesebestätigung + Versionshistorie).
|
||||
"PolicyAcknowledgement",
|
||||
"PolicyDocumentVersion",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||
// (docs/craftvia/MIGRATIONS.md).
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -196,7 +121,7 @@ const TENANT_MODELS = new Set<string>([
|
||||
* Isolations-Exception wirft der Aufrufer UNABHÄNGIG davon weiter.
|
||||
*
|
||||
* Achtung Kontext: Die Funktion läuft mitten in der laufenden DB-Operation
|
||||
* (im RLS-Pfad ggf. innerhalb der F-04-Transaktion des `isms_app`-Basisclients).
|
||||
* (im RLS-Pfad ggf. innerhalb der F-04-Transaktion des `craftvia_app`-Basisclients).
|
||||
* Der Audit-Insert läuft über den separaten Owner-`prisma` auf EIGENER Connection
|
||||
* und ist damit von der Tenant-Transaktion entkoppelt: Er verklemmt sie nicht und
|
||||
* bleibt bestehen, auch wenn die Tenant-Operation durch den anschließenden Throw
|
||||
@@ -243,7 +168,7 @@ type DelegateSource = Record<string, GuardDelegate>;
|
||||
|
||||
/**
|
||||
* F-02-Tenant-Guard, herausgelöst, damit er über zwei Ausführungspfade
|
||||
* (Owner ohne RLS / isms_app mit RLS-Transaktion) identisch wiederverwendet wird.
|
||||
* (Owner ohne RLS / craftvia_app mit RLS-Transaktion) identisch wiederverwendet wird.
|
||||
*
|
||||
* `source` liefert die uneextendierten Modell-Delegates für die Direktaufrufe
|
||||
* (findUnique-Hybrid, Ownership-Vorprüfung) — bei RLS-off der Owner-`prisma`,
|
||||
@@ -371,7 +296,7 @@ async function applyTenantGuard(
|
||||
* reads are filtered by tenantId, creates get the tenantId injected.
|
||||
*
|
||||
* RLS-off (Default): Owner-`prisma` + Guard-Extension, `query(args)` als Ausführung.
|
||||
* RLS-on (`RLS_ENFORCED=true`): `isms_app`-Basisclient; jede Operation eines
|
||||
* RLS-on (`RLS_ENFORCED=true`): `craftvia_app`-Basisclient; jede Operation eines
|
||||
* Tenant-Modells läuft in einer eigenen Transaktion des Basisclients, in der
|
||||
* `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback
|
||||
* → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch.
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import type { TenantDb } from "./db";
|
||||
|
||||
/**
|
||||
* Ableitung des Abhängigkeitsgraphen (SPEC §4.11) aus Process,
|
||||
* ProcessAsset(role), Asset und AssetRelation. Keine redundante
|
||||
* Datenhaltung — der Graph wird bei jedem Aufruf frisch berechnet.
|
||||
*
|
||||
* Kritikalität je Knoten:
|
||||
* - Prozess: BIA-Kritikalität (1–4), sonst 1
|
||||
* - Asset: max(C, I, A)
|
||||
* Kritische Kante: beide Endknoten-Kritikalität ≥ Schwellwert.
|
||||
* Kritischer Pfad: von kritischen Prozessen entlang kritischer Kanten (DFS).
|
||||
* SPOF: Asset, von dem ≥ spofMin kritische Prozesse (transitiv) abhängen.
|
||||
*/
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "process"
|
||||
| "INFORMATION"
|
||||
| "SYSTEM"
|
||||
| "APPLICATION"
|
||||
| "LOCATION"
|
||||
| "SUPPLIER"
|
||||
| "IT_SERVICE"
|
||||
| "SOFTWARE"
|
||||
| "PROJECT"
|
||||
| "PERSON"
|
||||
| "DATA";
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
entity: "process" | "asset";
|
||||
kind: GraphNodeKind;
|
||||
name: string;
|
||||
sub: string | null;
|
||||
criticality: number;
|
||||
critical: boolean;
|
||||
spof: boolean;
|
||||
dependentCriticalProcesses: number;
|
||||
};
|
||||
|
||||
export type GraphEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
critical: boolean;
|
||||
};
|
||||
|
||||
export type DependencyGraph = {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
threshold: number;
|
||||
analysis: {
|
||||
spofs: { id: string; name: string; count: number }[];
|
||||
criticalProcessCount: number;
|
||||
criticalEdgeCount: number;
|
||||
longestCriticalPath: string[]; // Knoten-Namen
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildDependencyGraph(
|
||||
db: TenantDb,
|
||||
opts: { threshold?: number; spofMin?: number } = {}
|
||||
): Promise<DependencyGraph> {
|
||||
const threshold = opts.threshold ?? 3;
|
||||
const spofMin = opts.spofMin ?? 2;
|
||||
|
||||
const [processes, assets, relations, procDeps] = await Promise.all([
|
||||
db.process.findMany({
|
||||
include: {
|
||||
bia: { select: { criticality: true } },
|
||||
processAssets: { select: { assetId: true, role: true } },
|
||||
},
|
||||
}),
|
||||
db.asset.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
}),
|
||||
db.assetRelation.findMany({ select: { assetId: true, relatedAssetId: true, type: true } }),
|
||||
db.processDependency.findMany({
|
||||
select: { sourceProcessId: true, targetProcessId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const pid = (id: string) => `p:${id}`;
|
||||
const aid = (id: string) => `a:${id}`;
|
||||
|
||||
const assetCrit = new Map<string, number>();
|
||||
for (const a of assets) {
|
||||
assetCrit.set(a.id, Math.max(a.confidentiality, a.integrity, a.availability));
|
||||
}
|
||||
|
||||
const nodes: GraphNode[] = [];
|
||||
const critOf = new Map<string, number>();
|
||||
|
||||
for (const p of processes) {
|
||||
const c = p.bia?.criticality ?? 1;
|
||||
critOf.set(pid(p.id), c);
|
||||
nodes.push({
|
||||
id: pid(p.id),
|
||||
entity: "process",
|
||||
kind: "process",
|
||||
name: p.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
for (const a of assets) {
|
||||
const c = assetCrit.get(a.id) ?? 1;
|
||||
critOf.set(aid(a.id), c);
|
||||
nodes.push({
|
||||
id: aid(a.id),
|
||||
entity: "asset",
|
||||
kind: a.type,
|
||||
name: a.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Zwei Sichten:
|
||||
// - adjacency (Analyse): Abhängigkeitsrichtung (Abhängiger → Abhängigkeit),
|
||||
// Basis für kritischen Pfad, SPOF, transitive Abhängigkeiten.
|
||||
// - edges (Anzeige): links = unterstützende Abhängigkeit, Mitte = Prozess,
|
||||
// rechts = erzeugtes primäres Asset. Pfeile fließen sauber von links nach
|
||||
// rechts, daher zeigen unterstützende Kanten VON der Abhängigkeit zum
|
||||
// Nutzer/Prozess.
|
||||
const edges: GraphEdge[] = [];
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const addAdj = (s: string, t: string) => {
|
||||
if (!adjacency.has(s)) adjacency.set(s, []);
|
||||
adjacency.get(s)!.push(t);
|
||||
};
|
||||
const isCritEdge = (s: string, t: string) =>
|
||||
(critOf.get(s) ?? 1) >= threshold && (critOf.get(t) ?? 1) >= threshold;
|
||||
|
||||
for (const p of processes) {
|
||||
for (const pa of p.processAssets) {
|
||||
const proc = pid(p.id);
|
||||
const asset = aid(pa.assetId);
|
||||
// Analyse: Prozess ist auf alle zugeordneten Assets angewiesen (SPOF/Pfad)
|
||||
addAdj(proc, asset);
|
||||
if (pa.role === "PRIMARY") {
|
||||
// erzeugtes Asset rechts vom Prozess
|
||||
edges.push({
|
||||
id: `${proc}->${asset}`,
|
||||
source: proc,
|
||||
target: asset,
|
||||
label: "erzeugt",
|
||||
critical: isCritEdge(proc, asset),
|
||||
});
|
||||
} else {
|
||||
// unterstützendes Asset links vom Prozess (Kante zeigt zum Prozess)
|
||||
edges.push({
|
||||
id: `${asset}->${proc}`,
|
||||
source: asset,
|
||||
target: proc,
|
||||
label: "unterstützt",
|
||||
critical: isCritEdge(asset, proc),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const assetIds = new Set(assets.map((a) => a.id));
|
||||
for (const r of relations) {
|
||||
if (!assetIds.has(r.assetId) || !assetIds.has(r.relatedAssetId)) continue;
|
||||
// Analyse: asset hängt von relatedAsset ab
|
||||
addAdj(aid(r.assetId), aid(r.relatedAssetId));
|
||||
// Anzeige: Abhängigkeit (relatedAsset) links, Nutzer (asset) rechts
|
||||
const s = aid(r.relatedAssetId);
|
||||
const t = aid(r.assetId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: "unterstützt",
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
}
|
||||
|
||||
// Strukturierte Prozess-zu-Prozess-Abhängigkeiten (ProcessDependency): `source`
|
||||
// benötigt `target`. Analyse: source hängt von target ab. Anzeige: Abhängigkeit
|
||||
// (target) links → Nutzer (source) rechts, konsistent zur Flussrichtung der
|
||||
// übrigen Kanten (Pfeil zeigt zum Nutzer, „wird benötigt von").
|
||||
const procNodeIds = new Set(processes.map((p) => p.id));
|
||||
for (const d of procDeps) {
|
||||
if (!procNodeIds.has(d.sourceProcessId) || !procNodeIds.has(d.targetProcessId)) continue;
|
||||
addAdj(pid(d.sourceProcessId), pid(d.targetProcessId));
|
||||
const s = pid(d.targetProcessId);
|
||||
const t = pid(d.sourceProcessId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: "benötigt von",
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
}
|
||||
|
||||
// Transitive Abhängigkeiten je kritischem Prozess → SPOF-Zählung je Asset
|
||||
const criticalProcesses = nodes.filter((n) => n.entity === "process" && n.critical);
|
||||
const dependents = new Map<string, Set<string>>(); // assetNodeId → Set kritischer Prozess-IDs
|
||||
for (const cp of criticalProcesses) {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...(adjacency.get(cp.id) ?? [])];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
if (!dependents.has(cur)) dependents.set(cur, new Set());
|
||||
dependents.get(cur)!.add(cp.id);
|
||||
for (const next of adjacency.get(cur) ?? []) stack.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
// SPOF-Zählung über Assets UND (seit den strukturierten Prozess-Abhängigkeiten)
|
||||
// Prozesse: ein gemeinsam benötigter Prozess wie „IT-Betrieb", von dem mehrere
|
||||
// kritische Prozesse abhängen, ist ebenfalls ein Single Point of Failure. Ein
|
||||
// Prozess zählt nicht sich selbst (adjacency startet bei den direkten Abhängigkeiten).
|
||||
const spofs: { id: string; name: string; count: number }[] = [];
|
||||
for (const n of nodes) {
|
||||
const count = dependents.get(n.id)?.size ?? 0;
|
||||
n.dependentCriticalProcesses = count;
|
||||
if (count >= spofMin) {
|
||||
n.spof = true;
|
||||
spofs.push({ id: n.id, name: n.name, count });
|
||||
}
|
||||
}
|
||||
spofs.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Längster kritischer Pfad (Abhängigkeitskette): DFS über die Analyse-Adjazenz,
|
||||
// beschränkt auf kritische Knoten, ab kritischen Prozessen.
|
||||
const critAdj = new Map<string, string[]>();
|
||||
for (const [s, targets] of adjacency) {
|
||||
if ((critOf.get(s) ?? 1) < threshold) continue;
|
||||
const critTargets = targets.filter((t) => (critOf.get(t) ?? 1) >= threshold);
|
||||
if (critTargets.length) critAdj.set(s, critTargets);
|
||||
}
|
||||
const nameOf = new Map(nodes.map((n) => [n.id, n.name]));
|
||||
let longest: string[] = [];
|
||||
const dfs = (node: string, path: string[], visited: Set<string>) => {
|
||||
const next = critAdj.get(node) ?? [];
|
||||
if (next.length === 0) {
|
||||
if (path.length > longest.length) longest = [...path];
|
||||
return;
|
||||
}
|
||||
let extended = false;
|
||||
for (const nx of next) {
|
||||
if (visited.has(nx)) continue;
|
||||
extended = true;
|
||||
visited.add(nx);
|
||||
dfs(nx, [...path, nx], visited);
|
||||
visited.delete(nx);
|
||||
}
|
||||
if (!extended && path.length > longest.length) longest = [...path];
|
||||
};
|
||||
for (const cp of criticalProcesses) {
|
||||
dfs(cp.id, [cp.id], new Set([cp.id]));
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
threshold,
|
||||
analysis: {
|
||||
spofs,
|
||||
criticalProcessCount: criticalProcesses.length,
|
||||
criticalEdgeCount: edges.filter((e) => e.critical).length,
|
||||
longestCriticalPath: longest.map((id) => nameOf.get(id) ?? id),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,29 +14,10 @@ export interface PiiReference {
|
||||
}
|
||||
|
||||
/** Felder, die eine Person als „Eigentümer/Ersteller/Bearbeiter/Akteur" referenzieren. */
|
||||
//
|
||||
// Neue Craftvia-Fachmodelle mit Personen-Referenzen (z. B. Auftrag.assigneeId,
|
||||
// Bericht.approvedById) MÜSSEN hier ergänzt werden.
|
||||
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||
{ model: "Asset", field: "ownerId" },
|
||||
{ model: "Asset", field: "createdBy" },
|
||||
{ model: "Process", field: "ownerId" },
|
||||
{ model: "Process", field: "createdBy" },
|
||||
{ model: "Process", field: "deputyOwnerId" },
|
||||
{ model: "Risk", field: "ownerId" },
|
||||
{ model: "Risk", field: "createdBy" },
|
||||
{ model: "Measure", field: "ownerId" },
|
||||
{ model: "Measure", field: "createdBy" },
|
||||
{ model: "SupplierProfile", field: "createdBy" },
|
||||
{ model: "ITServiceProfile", field: "createdBy" },
|
||||
{ model: "SoftwareProfile", field: "createdBy" },
|
||||
{ model: "ProjectProfile", field: "createdBy" },
|
||||
{ model: "ManagementDecision", field: "decidedBy" },
|
||||
{ model: "AuditLog", field: "actorId" },
|
||||
{ model: "Task", field: "assigneeId" },
|
||||
{ model: "Task", field: "createdById" },
|
||||
{ model: "TaskComment", field: "authorId" },
|
||||
{ model: "TaskParticipant", field: "userId" },
|
||||
{ model: "Audit", field: "auditorUserId" },
|
||||
{ model: "AuditEvidenceItem", field: "assignedUserId" },
|
||||
{ model: "OnboardingProgress", field: "reviewerId" },
|
||||
{ model: "ProjectFunctionAssignment", field: "userId" },
|
||||
{ model: "NotificationPreference", field: "userId" },
|
||||
];
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// Aufbereitung der Export-Zeilen (Story B7-2) aus A7 (Control-Assessment) und A8
|
||||
// (Gap-Liste). Wird vom CSV-Route-Handler und der Management-Zusammenfassung geteilt.
|
||||
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows } from "@/server/soa-context";
|
||||
import { buildGapItems } from "@/server/gap-context";
|
||||
import { buildControlGroups } from "@/server/control-descriptions-context";
|
||||
import { pruefzielOfControl, composeAbgabeText, type ExportControl, type AbgabeControl } from "@/lib/export/vda-isa";
|
||||
|
||||
export async function buildExportRows(db: TenantDb, tenantId: string): Promise<ExportControl[]> {
|
||||
const [{ rows }, { items }] = await Promise.all([
|
||||
buildControlRows(db, tenantId),
|
||||
buildGapItems(db, tenantId),
|
||||
]);
|
||||
|
||||
const gapByControl = new Map<string, number>();
|
||||
for (const g of items) for (const c of g.controls) gapByControl.set(c, (gapByControl.get(c) ?? 0) + 1);
|
||||
|
||||
return rows.map<ExportControl>((r) => {
|
||||
const ev = r.evidence;
|
||||
const belege = [
|
||||
`Richtlinie ${r.spec.policy.join("/") || "—"} (${ev.policy})`,
|
||||
`Verfahren ${r.spec.verfahren.join("/") || "—"} (${ev.verfahren})`,
|
||||
ev.assetLinked ? "Asset verknüpft" : null,
|
||||
ev.riskLinked ? "Risiko verknüpft" : null,
|
||||
].filter(Boolean).join("; ");
|
||||
return {
|
||||
control: r.control,
|
||||
frage: r.spec.title,
|
||||
reifegrad: r.confirmed,
|
||||
bestaetigt: r.confirmed !== null,
|
||||
umsetzung: r.suggestion.reason,
|
||||
belege,
|
||||
offenePunkte: gapByControl.get(r.control) ?? r.gaps.length,
|
||||
pruefziel: pruefzielOfControl(r.control),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ABGABE-Zeilen (VDA-ISA-Prüfungsdokumentation): je Control der bestätigte
|
||||
* Reifegrad (ControlAssessment), die zusammengesetzte Umsetzungsbeschreibung
|
||||
* (übernommene ControlDescription je Anforderung) und die Referenz-Dokumentation
|
||||
* (Dokumentverweise der übernommenen Beschreibungen + vorhandene Nachweise).
|
||||
*/
|
||||
export async function buildAbgabeRows(db: TenantDb, tenantId: string): Promise<AbgabeControl[]> {
|
||||
const [groups, assessments] = await Promise.all([
|
||||
buildControlGroups(db),
|
||||
db.controlAssessment.findMany({ where: { tenantId }, select: { control: true, confirmedValue: true, target: true } }),
|
||||
]);
|
||||
const asmByControl = new Map(assessments.map((a) => [a.control, a]));
|
||||
|
||||
return groups.map<AbgabeControl>((g) => {
|
||||
const confirmed = g.bullets.filter((b) => b.description?.status === "confirmed" && b.description.draftText);
|
||||
const umsetzung = composeAbgabeText(confirmed.map((b) => ({ requirement: b.requirement, text: b.description!.draftText })));
|
||||
const docRefs = [...new Set(confirmed.map((b) => b.description!.sourceRef).filter((s): s is string => Boolean(s)))];
|
||||
const referenzen = [...docRefs, ...g.evidence].join("; ");
|
||||
const asm = asmByControl.get(g.control);
|
||||
return {
|
||||
control: g.control,
|
||||
frage: g.frage,
|
||||
pruefziel: pruefzielOfControl(g.control),
|
||||
reifegrad: asm?.confirmedValue ?? null,
|
||||
zielReifegrad: asm?.target ?? null,
|
||||
umsetzung,
|
||||
referenzen,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Vorfallregister-Export als echte xlsx (IM-C, §9/§72). Serverseitig, weil
|
||||
// `exceljs` Node-APIs nutzt und nicht client-safe ist. Spalten/Zeilen entstammen
|
||||
// dem client-safen Builder in src/lib/incident-export.ts (gleiche Reihenfolge wie
|
||||
// die CSV-Variante), damit CSV und XLSX deckungsgleich bleiben.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import {
|
||||
INCIDENT_REGISTER_COLUMNS,
|
||||
incidentRegisterRow,
|
||||
type IncidentRegisterInput,
|
||||
} from "@/lib/incident-export";
|
||||
|
||||
// Sinnvolle Spaltenbreiten je Spalte (parallel zu INCIDENT_REGISTER_COLUMNS).
|
||||
const WIDTHS = [14, 40, 24, 12, 10, 16, 16, 18, 18, 18, 20, 20, 12, 13, 12, 20, 22, 20, 22, 11, 9, 8, 9, 18];
|
||||
|
||||
/** Erzeugt das Vorfallregister als xlsx-Workbook-Buffer. */
|
||||
export async function buildIncidentRegisterXlsx(rows: IncidentRegisterInput[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Certvia";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("Vorfallregister", { views: [{ state: "frozen", ySplit: 1 }] });
|
||||
ws.columns = INCIDENT_REGISTER_COLUMNS.map((header, i) => ({ header, width: WIDTHS[i] ?? 16 }));
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle", wrapText: true };
|
||||
headerRow.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF4F2FA" } };
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ws.addRow(incidentRegisterRow(r));
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
}
|
||||
|
||||
const out = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(out as ArrayBuffer);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// SoA-Export als echte xlsx (AP3). Serverseitig (exceljs nutzt Node-APIs). Spalten/
|
||||
// Zeilen kommen aus dem client-safen Builder in src/lib/soa.ts, damit CSV und XLSX
|
||||
// deckungsgleich bleiben.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import { SOA_EXPORT_COLUMNS, soaExportRow, type SoaExportInput } from "@/lib/soa";
|
||||
|
||||
const WIDTHS = [12, 40, 10, 50, 24, 16, 12, 22, 22, 11];
|
||||
|
||||
/** Erzeugt die Anwendbarkeitserklärung als xlsx-Workbook-Buffer. */
|
||||
export async function buildSoaXlsx(rows: SoaExportInput[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Certvia";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("SoA", { views: [{ state: "frozen", ySplit: 1 }] });
|
||||
ws.columns = SOA_EXPORT_COLUMNS.map((header, i) => ({ header, width: WIDTHS[i] ?? 16 }));
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle" };
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ws.addRow(soaExportRow(r));
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
}
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// VDA-ISA-ABGABE-Export als echte xlsx. Serverseitig, weil `exceljs` Node-APIs
|
||||
// (Buffer/Streams) nutzt und nicht client-safe ist — daher unter src/server/.
|
||||
// Aufbau je Control: Kontrollnummer, Reifegrad (Ist/Ziel), zusammengesetzte
|
||||
// „Beschreibung der Umsetzung", Referenz Dokumentation, Prüfziel. Sortierung wie
|
||||
// die CSV-Variante (IS → Prototyp → Datenschutz, dann Control numerisch).
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import { PRUEFZIEL_LABEL, sortAbgabeRows, type AbgabeControl } from "@/lib/export/vda-isa";
|
||||
|
||||
/**
|
||||
* Erzeugt die ABGABE-Sicht als xlsx-Workbook-Buffer im VDA-ISA-Layout.
|
||||
* Kopfzeile fett + graue Füllung, sinnvolle Spaltenbreiten, mehrzeilige
|
||||
* Text-Zellen (Umsetzung/Referenzen) mit Zeilenumbruch und oberer Ausrichtung.
|
||||
*/
|
||||
export async function buildAbgabeXlsx(rows: AbgabeControl[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "ISMS-Tool";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("VDA-ISA ABGABE", {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
});
|
||||
|
||||
ws.columns = [
|
||||
{ header: "Kontrollnummer", key: "control", width: 16 },
|
||||
{ header: "Kontrollfrage/Ziel", key: "frage", width: 40 },
|
||||
{ header: "Reifegrad (Ist)", key: "reifegrad", width: 14 },
|
||||
{ header: "Reifegrad (Ziel)", key: "zielReifegrad", width: 14 },
|
||||
{ header: "Beschreibung der Umsetzung", key: "umsetzung", width: 70 },
|
||||
{ header: "Referenz Dokumentation", key: "referenzen", width: 40 },
|
||||
{ header: "Prüfziel", key: "pruefziel", width: 22 },
|
||||
];
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle", wrapText: true };
|
||||
headerRow.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE7E7E7" } };
|
||||
|
||||
for (const r of sortAbgabeRows(rows)) {
|
||||
const row = ws.addRow({
|
||||
control: r.control,
|
||||
frage: r.frage,
|
||||
reifegrad: r.reifegrad ?? "na",
|
||||
zielReifegrad: r.zielReifegrad ?? "na",
|
||||
umsetzung: r.umsetzung,
|
||||
referenzen: r.referenzen,
|
||||
pruefziel: PRUEFZIEL_LABEL[r.pruefziel],
|
||||
});
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
row.getCell("reifegrad").alignment = { vertical: "top", horizontal: "center" };
|
||||
row.getCell("zielReifegrad").alignment = { vertical: "top", horizontal: "center" };
|
||||
}
|
||||
|
||||
// exceljs liefert ArrayBuffer/Buffer je nach Umgebung — als Node-Buffer normalisieren.
|
||||
const out = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(out as ArrayBuffer);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows, loadScopeInput, type ControlRow } from "@/server/soa-context";
|
||||
import { activeRequirements, type C1Row } from "@/lib/scope-filter";
|
||||
import { riskRef } from "@/lib/risk";
|
||||
import { consolidate, summarize, type RawGap, type GapItem, type GapEffort, type GapSummary } from "@/lib/gap-consolidation";
|
||||
import type { OpenPoint } from "@/lib/maturity";
|
||||
|
||||
/**
|
||||
* Gap-Kontext (Story A8). Aggregiert die offenen Punkte aus Schritt 7 (Control-Assessment,
|
||||
* A7) und Schritt 6 (Risiko-Register, A6: Risiken oberhalb der Akzeptanzlinie), reichert
|
||||
* sie um Anforderungstyp/Aufwand/Reifegrad-Wirkung an, gleicht sie mit bestehenden Aufgaben
|
||||
* ab und übergibt sie an die reine Konsolidierungs-Engine (src/lib/gap-consolidation.ts).
|
||||
*/
|
||||
|
||||
/** Risikowert > 9 (hoch/sehr hoch) = oberhalb der Akzeptanzlinie (analog VA-09/A6). */
|
||||
const ACCEPT_THRESHOLD = 9;
|
||||
|
||||
const TYPE_RANK: Record<C1Row["type"], number> = { "SEHR HOCH": 4, HOCH: 3, MUSS: 2, SOLL: 1 };
|
||||
|
||||
/** Höchster in-Scope-Anforderungstyp je Control (bestimmt die Priorität nach C8 §1). */
|
||||
async function requirementTypeByControl(db: TenantDb, tenantId: string): Promise<Map<string, C1Row["type"]>> {
|
||||
const { input } = await loadScopeInput(db, tenantId);
|
||||
const out = new Map<string, C1Row["type"]>();
|
||||
for (const r of activeRequirements(input)) {
|
||||
const cur = out.get(r.control);
|
||||
if (!cur || TYPE_RANK[r.type] > TYPE_RANK[cur]) out.set(r.control, r.type);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Aufwand + Reifegrad-Wirkung eines Control-Gaps aus Art und Belegstatus (für Quick-Wins). */
|
||||
function controlGapEffort(gap: OpenPoint, row: ControlRow): { effort: GapEffort; maturityImpact: number } {
|
||||
const impact = Math.max(1, row.target - row.suggestion.value);
|
||||
switch (gap.kind) {
|
||||
case "nachweis":
|
||||
// Review/Audit/Test durchführen — organisatorisch, kein Tool/Budget.
|
||||
return { effort: "gering", maturityImpact: impact };
|
||||
case "verknuepfung":
|
||||
// Asset-/Risiko-Verknüpfung herstellen — organisatorisch.
|
||||
return { effort: "gering", maturityImpact: impact };
|
||||
case "richtlinie":
|
||||
// Dokument bereits verknüpft (unvalidiert) → Freigabe = gering; fehlt → Erstellung = hoch.
|
||||
return { effort: row.evidence.policy === "verknuepft" ? "gering" : "hoch", maturityImpact: impact };
|
||||
case "verfahren":
|
||||
return { effort: row.evidence.verfahren === "verknuepft" ? "gering" : "hoch", maturityImpact: impact };
|
||||
case "widerspruch":
|
||||
return { effort: "hoch", maturityImpact: impact };
|
||||
default:
|
||||
return { effort: "mittel", maturityImpact: impact };
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut die konsolidierte, priorisierte Gap-Liste inkl. Summary. */
|
||||
export async function buildGapItems(db: TenantDb, tenantId: string): Promise<{ items: GapItem[]; summary: GapSummary }> {
|
||||
const [{ rows }, reqTypes, openTasks, risks, catalog] = await Promise.all([
|
||||
buildControlRows(db, tenantId),
|
||||
requirementTypeByControl(db, tenantId),
|
||||
db.task.findMany({ where: { status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true, origin: true } }),
|
||||
db.risk.findMany({
|
||||
where: { status: { in: ["OPEN", "IN_TREATMENT"] }, treatment: { not: "ACCEPT" } },
|
||||
select: { id: true, refNo: true, title: true, score: true, residualScore: true, catalogCode: true },
|
||||
}),
|
||||
db.riskCatalogEntry.findMany({ select: { code: true, controls: true } }),
|
||||
]);
|
||||
|
||||
const taskByOrigin = new Map<string, string>();
|
||||
for (const t of openTasks) if (t.origin) taskByOrigin.set(t.origin, t.id);
|
||||
const controlsByCatalog = new Map(catalog.map((c) => [c.code, c.controls]));
|
||||
|
||||
const raws: RawGap[] = [];
|
||||
|
||||
// Quelle Schritt 7: Control-Gaps (A7 openPoints).
|
||||
for (const row of rows) {
|
||||
const reqType = reqTypes.get(row.control);
|
||||
for (const gap of row.gaps) {
|
||||
const { effort, maturityImpact } = controlGapEffort(gap, row);
|
||||
const taskOrigin = `wizard:control-gap:${row.control}:${gap.kind}`;
|
||||
raws.push({
|
||||
id: `control:${row.control}:${gap.kind}`,
|
||||
source: "control",
|
||||
title: `${row.control} — ${gap.missing}`,
|
||||
action: gap.action,
|
||||
controls: [row.control],
|
||||
requirementType: reqType,
|
||||
kind: gap.kind,
|
||||
maturity: row.confirmed ?? row.suggestion.value,
|
||||
target: row.target,
|
||||
effort,
|
||||
maturityImpact,
|
||||
hasDependency: false,
|
||||
taskOrigin,
|
||||
taskId: taskByOrigin.get(taskOrigin) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Quelle Schritt 6: Risiken oberhalb der Akzeptanzlinie (A6).
|
||||
for (const risk of risks) {
|
||||
const eff = risk.residualScore ?? risk.score;
|
||||
if (eff <= ACCEPT_THRESHOLD) continue;
|
||||
const controls = risk.catalogCode ? controlsByCatalog.get(risk.catalogCode) ?? [] : [];
|
||||
const taskOrigin = `risk-measure:${risk.id}`;
|
||||
raws.push({
|
||||
id: `risk:${risk.id}`,
|
||||
source: "risk",
|
||||
title: `${riskRef(risk.refNo)} — ${risk.title}`,
|
||||
action: `Behandlungsmaßnahme für Risiko ${riskRef(risk.refNo)} umsetzen`,
|
||||
controls,
|
||||
riskScore: eff,
|
||||
aboveAcceptance: true,
|
||||
// Risikobehandlung erfordert i. d. R. Umsetzung/Tool → kein Quick-Win-Default.
|
||||
effort: "mittel",
|
||||
maturityImpact: 1,
|
||||
hasDependency: false,
|
||||
taskOrigin,
|
||||
taskId: taskByOrigin.get(taskOrigin) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const items = consolidate(raws);
|
||||
return { items, summary: summarize(items) };
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Aufbereitung der Vorfall-Exportdaten (IM-C, §9/§72): Register-Zeilen,
|
||||
// Einzel-Berichtsdaten und Meldevorlagen-Eingaben. Fasst die DB-Zugriffe für den
|
||||
// Export-Route-Handler zusammen; die eigentliche Formatierung liegt in den
|
||||
// client-safen Buildern (src/lib/incident-export.ts, incident-report-html.ts).
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { prisma } from "@/server/db";
|
||||
import { measureRef } from "@/lib/measure";
|
||||
import { riskRef } from "@/lib/risk";
|
||||
import { deadlineItems } from "@/lib/incident-deadlines";
|
||||
import type { IncidentRegisterInput, IncidentTemplateInput } from "@/lib/incident-export";
|
||||
import type { IncidentReportData } from "@/lib/incident-report-html";
|
||||
|
||||
const DEADLINE_LABEL_DE: Record<string, string> = {
|
||||
erstmeldung: "NIS2 Erstmeldung (24 h)",
|
||||
folgemeldung: "NIS2 Folgemeldung (72 h)",
|
||||
abschluss: "NIS2 Abschlussbericht (1 Monat)",
|
||||
dsgvo: "DSGVO-Meldung (Art. 33, 72 h)",
|
||||
reaction: "Interne Reaktionsfrist (SLA)",
|
||||
resolution: "Interne Behebungsfrist (SLA)",
|
||||
};
|
||||
|
||||
async function organisationName(tenantId: string): Promise<string | null> {
|
||||
const t = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
|
||||
return t?.name ?? null;
|
||||
}
|
||||
|
||||
/** Registerzeilen (Liste) für CSV/XLSX — respektiert einen optionalen Where-Filter. */
|
||||
export async function buildIncidentRegisterInputs(
|
||||
db: TenantDb,
|
||||
where: Prisma.IncidentWhereInput = {},
|
||||
): Promise<IncidentRegisterInput[]> {
|
||||
const incidents = await db.incident.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
assignee: { select: { name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
incidentMeasures: true,
|
||||
incidentRisks: true,
|
||||
incidentAssets: true,
|
||||
incidentControls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return incidents.map((i) => ({
|
||||
refNo: i.refNo,
|
||||
title: i.title,
|
||||
category: i.category,
|
||||
severity: i.severity,
|
||||
priority: i.priority,
|
||||
status: i.status,
|
||||
source: i.source,
|
||||
occurredAt: i.occurredAt,
|
||||
detectedAt: i.detectedAt,
|
||||
reportedAt: i.reportedAt,
|
||||
ownerName: i.owner?.name ?? null,
|
||||
assigneeName: i.assignee?.name ?? null,
|
||||
nis2Relevant: i.nis2Relevant,
|
||||
dsgvoRelevant: i.dsgvoRelevant,
|
||||
personalData: i.personalData,
|
||||
prototypeData: i.prototypeData,
|
||||
reportStatus: i.reportStatus,
|
||||
erstmeldungDueAt: i.erstmeldungDueAt,
|
||||
dsgvoDueAt: i.dsgvoDueAt,
|
||||
abschlussDueAt: i.abschlussDueAt,
|
||||
measureCount: i._count.incidentMeasures,
|
||||
riskCount: i._count.incidentRisks,
|
||||
assetCount: i._count.incidentAssets,
|
||||
controlCount: i._count.incidentControls,
|
||||
createdAt: i.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const REPORT_INCLUDE = {
|
||||
owner: { select: { name: true } },
|
||||
assignee: { select: { name: true } },
|
||||
incidentAssets: { include: { asset: { select: { name: true } } } },
|
||||
incidentProcesses: { include: { process: { select: { name: true } } } },
|
||||
incidentRisks: { include: { risk: { select: { refNo: true, title: true } } } },
|
||||
incidentControls: true,
|
||||
incidentMeasures: {
|
||||
include: { measure: { select: { refNo: true, title: true, status: true, dueDate: true, owner: { select: { name: true } } } } },
|
||||
},
|
||||
incidentEvidence: { include: { evidence: { select: { title: true } } } },
|
||||
} satisfies Prisma.IncidentInclude;
|
||||
|
||||
/** Vollständige Daten für den druckbaren Einzel-Vorfallbericht. */
|
||||
export async function buildIncidentReportData(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
where: Prisma.IncidentWhereInput,
|
||||
): Promise<IncidentReportData | null> {
|
||||
const inc = await db.incident.findFirst({ where, include: REPORT_INCLUDE });
|
||||
if (!inc) return null;
|
||||
|
||||
const audit = await db.auditLog.findMany({
|
||||
where: { entity: "incident", entityId: inc.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 300,
|
||||
});
|
||||
const actorIds = [...new Set(audit.map((a) => a.actorId).filter((x): x is string => Boolean(x)))];
|
||||
const actors = actorIds.length
|
||||
? await db.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const actorNames = Object.fromEntries(actors.map((a) => [a.id, a.name]));
|
||||
|
||||
const deadlines = deadlineItems(inc).map((d) => ({
|
||||
label: DEADLINE_LABEL_DE[d.kind] ?? d.kind,
|
||||
dueAt: d.dueAt,
|
||||
done: d.done,
|
||||
overdue: d.overdue,
|
||||
}));
|
||||
|
||||
return {
|
||||
refNo: inc.refNo,
|
||||
title: inc.title,
|
||||
organisation: await organisationName(tenantId),
|
||||
description: inc.description,
|
||||
category: inc.category,
|
||||
source: inc.source,
|
||||
status: inc.status,
|
||||
severity: inc.severity,
|
||||
priority: inc.priority,
|
||||
occurredAt: inc.occurredAt,
|
||||
detectedAt: inc.detectedAt,
|
||||
reportedAt: inc.reportedAt,
|
||||
ownerName: inc.owner?.name ?? null,
|
||||
assigneeName: inc.assignee?.name ?? null,
|
||||
reporterName: inc.reporterName,
|
||||
reporterContact: inc.reporterContact,
|
||||
impactC: inc.impactC,
|
||||
impactI: inc.impactI,
|
||||
impactA: inc.impactA,
|
||||
urgency: inc.urgency,
|
||||
affectedDataCategories: inc.affectedDataCategories,
|
||||
personalData: inc.personalData,
|
||||
prototypeData: inc.prototypeData,
|
||||
nis2Relevant: inc.nis2Relevant,
|
||||
dsgvoRelevant: inc.dsgvoRelevant,
|
||||
reportStatus: inc.reportStatus,
|
||||
rootCause: inc.rootCause,
|
||||
resolution: inc.resolution,
|
||||
closingNote: inc.closingNote,
|
||||
lessonsLearned: inc.lessonsLearned,
|
||||
measuresEffectiveness: inc.measuresEffectiveness,
|
||||
postIncidentReview: inc.postIncidentReview,
|
||||
deadlines,
|
||||
measures: inc.incidentMeasures.map((m) => ({
|
||||
ref: measureRef(m.measure.refNo),
|
||||
title: m.measure.title,
|
||||
status: m.measure.status,
|
||||
owner: m.measure.owner?.name ?? null,
|
||||
dueDate: m.measure.dueDate,
|
||||
})),
|
||||
risks: inc.incidentRisks.map((r) => ({ ref: riskRef(r.risk.refNo), title: r.risk.title })),
|
||||
controls: inc.incidentControls.map((c) => c.controlRef),
|
||||
assets: inc.incidentAssets.map((a) => a.asset.name),
|
||||
processes: inc.incidentProcesses.map((p) => p.process.name),
|
||||
evidence: inc.incidentEvidence.map((e) => ({ title: e.evidence.title, note: e.note })),
|
||||
timeline: audit.map((a) => ({
|
||||
at: a.createdAt,
|
||||
actor: a.actorId ? actorNames[a.actorId] ?? "System" : "System",
|
||||
action: a.action,
|
||||
})),
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Eingabedaten für die NIS2-/DSGVO-Meldevorlagen. */
|
||||
export async function buildIncidentTemplateInput(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
where: Prisma.IncidentWhereInput,
|
||||
): Promise<IncidentTemplateInput | null> {
|
||||
const inc = await db.incident.findFirst({ where });
|
||||
if (!inc) return null;
|
||||
return {
|
||||
refNo: inc.refNo,
|
||||
title: inc.title,
|
||||
description: inc.description,
|
||||
category: inc.category,
|
||||
severity: inc.severity,
|
||||
organisation: await organisationName(tenantId),
|
||||
occurredAt: inc.occurredAt,
|
||||
detectedAt: inc.detectedAt,
|
||||
reportedAt: inc.reportedAt,
|
||||
impactC: inc.impactC,
|
||||
impactI: inc.impactI,
|
||||
impactA: inc.impactA,
|
||||
affectedDataCategories: inc.affectedDataCategories,
|
||||
personalData: inc.personalData,
|
||||
nis2Relevant: inc.nis2Relevant,
|
||||
reporterName: inc.reporterName,
|
||||
reporterContact: inc.reporterContact,
|
||||
erstmeldungDueAt: inc.erstmeldungDueAt,
|
||||
folgemeldungDueAt: inc.folgemeldungDueAt,
|
||||
abschlussDueAt: inc.abschlussDueAt,
|
||||
dsgvoDueAt: inc.dsgvoDueAt,
|
||||
};
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* IM-D — Inbound-Parsing (REIN, ohne Netz/DB).
|
||||
*
|
||||
* Trennung von IMAP-I/O (KONZEPT §14.3): dieser Baustein bekommt bereits
|
||||
* geparste Header + Body (im Betrieb aus mailparser, im Test aus Fixtures) und
|
||||
* extrahiert die entscheidungsrelevanten Felder:
|
||||
* - Token aus dem EMPFÄNGER-Header (`Delivered-To`/`X-Envelope-To` — NICHT `To`,
|
||||
* da dort bei Weiterleitung die Kundenadresse steht),
|
||||
* - Absender/Betreff/Text,
|
||||
* - Auto-Reply/Bounce-Erkennung (`Auto-Submitted`/`Precedence`),
|
||||
* - DKIM/SPF-Signal aus `Authentication-Results`.
|
||||
*
|
||||
* Bewusst keine Seiteneffekte, keine Prisma-, keine IMAP-Importe → in
|
||||
* scripts/test-incident-inbound.ts mit Fixtures prüfbar.
|
||||
*/
|
||||
|
||||
export type AuthResult = "pass" | "fail" | "none";
|
||||
|
||||
/** Header-Bag: Schlüssel case-insensitiv, Werte einzeln oder mehrfach. */
|
||||
export type RawHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
export interface InboundInput {
|
||||
headers: RawHeaders;
|
||||
/** Absenderzeile, ggf. „Name <a@b>". Fällt auf den `from`-Header zurück. */
|
||||
from?: string;
|
||||
subject?: string;
|
||||
/** Klartext-Body (mailparser: `.text`). */
|
||||
text?: string;
|
||||
/** Message-ID aus dem Parser; sonst aus dem Header gezogen. */
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface ParsedInbound {
|
||||
messageId: string | null;
|
||||
sender: string | null;
|
||||
senderName: string | null;
|
||||
senderDomain: string | null;
|
||||
subject: string;
|
||||
text: string;
|
||||
/** Nur aus Delivered-To/X-Envelope-To (nicht To/Cc). */
|
||||
recipients: string[];
|
||||
/** Erster Token, der auf die Intake-Domain passt. */
|
||||
token: string | null;
|
||||
recipientForToken: string | null;
|
||||
autoSubmitted: boolean;
|
||||
dkim: AuthResult;
|
||||
spf: AuthResult;
|
||||
}
|
||||
|
||||
/** Intake-Domain (Env INCIDENT_INTAKE_DOMAIN, Default in.certvia.de). */
|
||||
export function intakeDomain(): string {
|
||||
const v = process.env.INCIDENT_INTAKE_DOMAIN?.trim();
|
||||
return (v && v.length > 0 ? v : "in.certvia.de").toLowerCase();
|
||||
}
|
||||
|
||||
/** Ableitung der Intake-Adresse aus dem Token: vorfall-<token>@<domain>. */
|
||||
export function intakeAddress(token: string, domain: string = intakeDomain()): string {
|
||||
return `vorfall-${token}@${domain}`;
|
||||
}
|
||||
|
||||
/** Token-Format: URL-sichere Kleinbuchstaben/Ziffern, keine leicht verwechselbaren Zeichen. */
|
||||
const TOKEN_RE = /^[a-z0-9]{8,64}$/;
|
||||
|
||||
/** Alle Werte eines Headers (case-insensitiv) als flache Liste. */
|
||||
function headerValues(headers: RawHeaders, name: string): string[] {
|
||||
const target = name.toLowerCase();
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (k.toLowerCase() !== target || v == null) continue;
|
||||
if (Array.isArray(v)) out.push(...v.map((x) => String(x)));
|
||||
else out.push(String(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function headerFirst(headers: RawHeaders, name: string): string | null {
|
||||
const vs = headerValues(headers, name);
|
||||
return vs.length ? vs[0] : null;
|
||||
}
|
||||
|
||||
/** Bare-E-Mail aus „Name <a@b>" oder „a@b"; lowercased. */
|
||||
export function extractEmail(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const angle = input.match(/<([^<>@\s]+@[^<>@\s]+)>/);
|
||||
const candidate = angle ? angle[1] : input.trim();
|
||||
const m = candidate.match(/([^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)/);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
function extractName(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const angle = input.match(/^\s*"?([^"<]+?)"?\s*</);
|
||||
if (angle) {
|
||||
const name = angle[1].trim();
|
||||
return name.length ? name : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Token aus einer einzelnen Adresse ziehen, sofern sie auf die Intake-Domain passt. */
|
||||
export function tokenFromAddress(address: string, domain: string = intakeDomain()): string | null {
|
||||
const email = extractEmail(address);
|
||||
if (!email) return null;
|
||||
const at = email.lastIndexOf("@");
|
||||
if (at < 0) return null;
|
||||
const local = email.slice(0, at);
|
||||
const dom = email.slice(at + 1);
|
||||
if (dom !== domain.toLowerCase()) return null;
|
||||
if (!local.startsWith("vorfall-")) return null;
|
||||
const token = local.slice("vorfall-".length);
|
||||
return TOKEN_RE.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** Empfänger-Adressen aus den zustellrelevanten Headern (NICHT To/Cc). */
|
||||
export function deliveryRecipients(headers: RawHeaders): string[] {
|
||||
const raw = [
|
||||
...headerValues(headers, "Delivered-To"),
|
||||
...headerValues(headers, "X-Envelope-To"),
|
||||
...headerValues(headers, "X-Original-To"),
|
||||
];
|
||||
const emails: string[] = [];
|
||||
for (const line of raw) {
|
||||
// Ein Header kann mehrere Adressen tragen (Komma-getrennt).
|
||||
for (const part of line.split(",")) {
|
||||
const e = extractEmail(part);
|
||||
if (e && !emails.includes(e)) emails.push(e);
|
||||
}
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
/** Auto-Reply/Bounce/Mailingliste erkennen (Schleifenschutz, KONZEPT §2). */
|
||||
export function isAutoSubmitted(headers: RawHeaders): boolean {
|
||||
const autoSub = headerFirst(headers, "Auto-Submitted");
|
||||
if (autoSub && autoSub.trim().toLowerCase() !== "no") return true;
|
||||
|
||||
const precedence = headerFirst(headers, "Precedence");
|
||||
if (precedence) {
|
||||
const p = precedence.trim().toLowerCase();
|
||||
if (p === "bulk" || p === "auto_reply" || p === "junk" || p === "list") return true;
|
||||
}
|
||||
|
||||
// Leerer Envelope-From (<>) ist der klassische Bounce.
|
||||
const returnPath = headerFirst(headers, "Return-Path");
|
||||
if (returnPath && returnPath.trim() === "<>") return true;
|
||||
|
||||
if (headerFirst(headers, "X-Autoreply")) return true;
|
||||
if (headerFirst(headers, "X-Autorespond")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** DKIM/SPF-Ergebnis aus allen Authentication-Results-Headern ableiten. */
|
||||
export function authResults(headers: RawHeaders): { dkim: AuthResult; spf: AuthResult } {
|
||||
const joined = headerValues(headers, "Authentication-Results").join("; ").toLowerCase();
|
||||
const read = (mech: string): AuthResult => {
|
||||
const m = joined.match(new RegExp(`${mech}=(pass|fail|softfail|neutral|none|permerror|temperror)`));
|
||||
if (!m) return "none";
|
||||
if (m[1] === "pass") return "pass";
|
||||
if (m[1] === "fail" || m[1] === "softfail" || m[1] === "permerror") return "fail";
|
||||
return "none";
|
||||
};
|
||||
return { dkim: read("dkim"), spf: read("spf") };
|
||||
}
|
||||
|
||||
/** Message-ID normalisieren (mit/ohne spitze Klammern). */
|
||||
function normalizeMessageId(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const m = input.match(/<([^<>]+)>/);
|
||||
const id = (m ? m[1] : input).trim();
|
||||
return id.length ? id : null;
|
||||
}
|
||||
|
||||
/** Vollständiges Parsing einer eingehenden Mail (rein). */
|
||||
export function parseInbound(input: InboundInput, domain: string = intakeDomain()): ParsedInbound {
|
||||
const headers = input.headers ?? {};
|
||||
const fromRaw = input.from ?? headerFirst(headers, "From") ?? null;
|
||||
const sender = extractEmail(fromRaw);
|
||||
const senderName = extractName(fromRaw);
|
||||
const senderDomain = sender ? sender.slice(sender.lastIndexOf("@") + 1) : null;
|
||||
|
||||
const recipients = deliveryRecipients(headers);
|
||||
let token: string | null = null;
|
||||
let recipientForToken: string | null = null;
|
||||
for (const r of recipients) {
|
||||
const t = tokenFromAddress(r, domain);
|
||||
if (t) {
|
||||
token = t;
|
||||
recipientForToken = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { dkim, spf } = authResults(headers);
|
||||
|
||||
return {
|
||||
messageId: normalizeMessageId(input.messageId ?? headerFirst(headers, "Message-ID")),
|
||||
sender,
|
||||
senderName,
|
||||
senderDomain,
|
||||
subject: (input.subject ?? headerFirst(headers, "Subject") ?? "").trim(),
|
||||
text: (input.text ?? "").trim(),
|
||||
recipients,
|
||||
token,
|
||||
recipientForToken,
|
||||
autoSubmitted: isAutoSubmitted(headers),
|
||||
dkim,
|
||||
spf,
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* IM-D — Inbound-Verarbeitung mit DB (vom Worker benutzt; NICHT rein).
|
||||
*
|
||||
* Bindet die reine Entscheidung (route.ts) an die Persistenz:
|
||||
* - Token → Mandant via IncidentIntakeConfig (Owner-Client, mandantenübergreifend),
|
||||
* - Dedupe per Message-ID (Incident.inboundMessageId ODER Review.messageId),
|
||||
* - Anlage des Vorfalls (dbForTenant → RLS/Guard) bzw. eines IncidentInboundReview.
|
||||
*
|
||||
* Der IMAP-I/O bleibt im Worker (scripts/incident-inbound-worker.ts); hier gibt es
|
||||
* keine Netz-/IMAP-Abhängigkeit, nur Prisma.
|
||||
*/
|
||||
import { prisma, dbForTenant } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { nextIncidentRefNo } from "@/server/incident-refno";
|
||||
import { incidentManagerIds, notifyIncidentEvent } from "@/server/mail/incident-notifications";
|
||||
import type { ParsedInbound } from "./parse";
|
||||
import { decideRoute, type IntakeConfigView, type RouteDecision } from "./route";
|
||||
|
||||
/** Konfiguration zum Token laden (mandantenübergreifend, Owner-Client). */
|
||||
export async function resolveIntakeConfig(token: string): Promise<IntakeConfigView | null> {
|
||||
const cfg = await prisma.incidentIntakeConfig.findUnique({
|
||||
where: { token },
|
||||
select: { tenantId: true, token: true, allowlistDomains: true, sourceAddress: true },
|
||||
});
|
||||
return cfg ?? null;
|
||||
}
|
||||
|
||||
/** Wurde diese Message-ID bereits zu einem Vorfall ODER einem Review verarbeitet? */
|
||||
export async function messageAlreadySeen(messageId: string | null): Promise<boolean> {
|
||||
if (!messageId) return false;
|
||||
const [inc, rev] = await Promise.all([
|
||||
prisma.incident.count({ where: { inboundMessageId: messageId } }),
|
||||
prisma.incidentInboundReview.count({ where: { messageId } }),
|
||||
]);
|
||||
return inc > 0 || rev > 0;
|
||||
}
|
||||
|
||||
export interface ProcessResult {
|
||||
decision: RouteDecision;
|
||||
incidentId?: string;
|
||||
refNo?: string;
|
||||
reviewId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine geparste Mail verarbeiten: Konfiguration auflösen, Dedupe prüfen, entscheiden
|
||||
* und das Ergebnis persistieren. Gibt die Entscheidung + ggf. angelegte IDs zurück.
|
||||
*/
|
||||
export async function processInbound(msg: ParsedInbound): Promise<ProcessResult> {
|
||||
const config = msg.token ? await resolveIntakeConfig(msg.token) : null;
|
||||
const alreadySeen = await messageAlreadySeen(msg.messageId);
|
||||
const decision = decideRoute(msg, { config, alreadySeen });
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
return { decision };
|
||||
}
|
||||
|
||||
if (decision.action === "review") {
|
||||
const row = await prisma.incidentInboundReview.create({
|
||||
data: {
|
||||
messageId: decision.review.messageId,
|
||||
sender: decision.review.sender,
|
||||
subject: decision.review.subject,
|
||||
recipient: decision.review.recipient,
|
||||
token: decision.review.token,
|
||||
reason: decision.review.reason,
|
||||
tenantId: decision.review.tenantId,
|
||||
receivedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
// Plattform-Audit (tenant-los): Betreiber muss die Review nachvollziehen können.
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: decision.review.tenantId,
|
||||
scope: "platform",
|
||||
action: "create",
|
||||
entity: "incident_inbound_review",
|
||||
entityId: row.id,
|
||||
after: { reason: decision.review.reason, sender: decision.review.sender },
|
||||
},
|
||||
});
|
||||
return { decision, reviewId: row.id };
|
||||
}
|
||||
|
||||
// action === "incident"
|
||||
const draft = decision.incident;
|
||||
const refNo = await nextIncidentRefNo(prisma, draft.tenantId);
|
||||
const db = dbForTenant(draft.tenantId);
|
||||
const incident = await db.incident.create({
|
||||
data: {
|
||||
tenantId: draft.tenantId,
|
||||
refNo,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
source: "email",
|
||||
status: "neu",
|
||||
reporterName: draft.reporterName,
|
||||
reporterContact: draft.reporterContact,
|
||||
inboundMessageId: draft.inboundMessageId,
|
||||
},
|
||||
select: { id: true, refNo: true, title: true },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: draft.tenantId,
|
||||
action: "create",
|
||||
entity: "incident",
|
||||
entityId: incident.id,
|
||||
after: { refNo: incident.refNo, source: "email", inbound: true, spfWarning: decision.spfWarning },
|
||||
});
|
||||
|
||||
// Provisionierung (KONZEPT §12a): Die erste erfolgreich zugestellte Inbound-Mail
|
||||
// belegt, dass die Weiterleitung des Kunden funktioniert → Status automatisch von
|
||||
// `weiterleitung_ausstehend` auf `verifiziert` heben (Owner-Client, updateMany ist
|
||||
// idempotent und trifft nur noch offene Konfigurationen). Der Betreiber kann den
|
||||
// Status im Portal zusätzlich manuell setzen.
|
||||
await prisma.incidentIntakeConfig.updateMany({
|
||||
where: { tenantId: draft.tenantId, status: "weiterleitung_ausstehend" },
|
||||
data: { status: "verifiziert", verifiedAt: new Date() },
|
||||
});
|
||||
|
||||
// §7 — neuer Vorfall → ISB/Incident-Manager (best effort; Mailfehler dürfen die
|
||||
// Verarbeitung nicht scheitern lassen — notifyIncidentEvent fängt intern ab).
|
||||
await notifyIncidentEvent({
|
||||
tenantId: draft.tenantId,
|
||||
event: "incident_created",
|
||||
incidentId: incident.id,
|
||||
refNo: incident.refNo,
|
||||
title: incident.title,
|
||||
recipientIds: await incidentManagerIds(draft.tenantId),
|
||||
});
|
||||
|
||||
return { decision, incidentId: incident.id, refNo: incident.refNo };
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* IM-D — Inbound-Routing (REIN, ohne Netz/DB).
|
||||
*
|
||||
* Entscheidet auf Basis der geparsten Mail (parse.ts) + der aufgelösten
|
||||
* Intake-Konfiguration, was mit einer Mail geschieht:
|
||||
* - `ignore` — Auto-Reply/Bounce oder bereits verarbeitet (Dedupe per Message-ID)
|
||||
* - `incident` — Vorfall im richtigen Mandanten anlegen (Status neu, source=email)
|
||||
* - `review` — Betreiber-Review (kein/unbekannter Token oder Allowlist/DKIM-Fehler)
|
||||
*
|
||||
* Vertrauensmodell (KONZEPT §2): Weiterleitung bricht i. d. R. SPF (DKIM bleibt
|
||||
* gültig) → SPF-Fail wird NICHT hart abgelehnt; Vertrauen entsteht aus
|
||||
* Absender-Allowlist + DKIM. SPF ist nur ein Signal (im Review-Grund dokumentiert,
|
||||
* aber nie allein ausschlaggebend).
|
||||
*
|
||||
* Die DB-/Netz-Anbindung (Token→Mandant, Dublettenprüfung, Persistenz) macht der
|
||||
* Worker; hier ist alles rein und mit Fixtures testbar.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { ParsedInbound } from "./parse";
|
||||
|
||||
/** Sicht auf die Intake-Konfiguration, die der Router braucht (vom Worker geladen). */
|
||||
export interface IntakeConfigView {
|
||||
tenantId: string;
|
||||
token: string;
|
||||
allowlistDomains: string[];
|
||||
sourceAddress: string | null;
|
||||
}
|
||||
|
||||
export interface RouteDeps {
|
||||
/** Vom Token aufgelöste Konfiguration (oder null, wenn Token unbekannt). */
|
||||
config: IntakeConfigView | null;
|
||||
/** Wurde diese Message-ID bereits verarbeitet? (Incident ODER Review vorhanden) */
|
||||
alreadySeen: boolean;
|
||||
}
|
||||
|
||||
export type ReviewReason = "no_token" | "unknown_token" | "allowlist_failed" | "dkim_failed";
|
||||
export type IgnoreReason = "auto_submitted" | "duplicate" | "no_message_id";
|
||||
|
||||
export interface IncidentDraft {
|
||||
tenantId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reporterName: string | null;
|
||||
reporterContact: string | null;
|
||||
inboundMessageId: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewDraft {
|
||||
messageId: string | null;
|
||||
sender: string;
|
||||
subject: string | null;
|
||||
recipient: string | null;
|
||||
token: string | null;
|
||||
reason: ReviewReason;
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
export type RouteDecision =
|
||||
| { action: "ignore"; reason: IgnoreReason }
|
||||
| { action: "incident"; incident: IncidentDraft; spfWarning: boolean }
|
||||
| { action: "review"; review: ReviewDraft };
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_DESC = 10000;
|
||||
|
||||
function domainOf(email: string | null): string | null {
|
||||
if (!email) return null;
|
||||
const at = email.lastIndexOf("@");
|
||||
return at >= 0 ? email.slice(at + 1).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Routing-Entscheidung. Reihenfolge ist bewusst:
|
||||
* 1. Auto-Reply/Bounce → ignorieren (Schleifenschutz).
|
||||
* 2. Dedupe per Message-ID → ignorieren (Idempotenz beim erneuten Abholen).
|
||||
* 3. Kein Token → Review (nicht droppen).
|
||||
* 4. Unbekannter Token → Review.
|
||||
* 5. Allowlist-Fehlschlag → Review (mit aufgelöstem Mandanten).
|
||||
* 6. DKIM=fail → Review (DKIM + Allowlist tragen das Vertrauen).
|
||||
* 7. sonst → Vorfall (SPF-Fail wird toleriert, nur als Warnung markiert).
|
||||
*/
|
||||
export function decideRoute(msg: ParsedInbound, deps: RouteDeps): RouteDecision {
|
||||
if (msg.autoSubmitted) return { action: "ignore", reason: "auto_submitted" };
|
||||
if (deps.alreadySeen) return { action: "ignore", reason: "duplicate" };
|
||||
|
||||
const sender = msg.sender ?? "";
|
||||
const baseReview = {
|
||||
messageId: msg.messageId,
|
||||
sender,
|
||||
subject: msg.subject || null,
|
||||
recipient: msg.recipientForToken ?? msg.recipients[0] ?? null,
|
||||
token: msg.token,
|
||||
};
|
||||
|
||||
if (!msg.token) {
|
||||
return { action: "review", review: { ...baseReview, reason: "no_token", tenantId: null } };
|
||||
}
|
||||
if (!deps.config) {
|
||||
return { action: "review", review: { ...baseReview, reason: "unknown_token", tenantId: null } };
|
||||
}
|
||||
|
||||
const cfg = deps.config;
|
||||
const senderDomain = msg.senderDomain ?? domainOf(msg.sender);
|
||||
const allow = cfg.allowlistDomains.map((d) => d.trim().toLowerCase()).filter(Boolean);
|
||||
// Allowlist ist obligatorisch: ohne gepflegte Domäne wird nichts automatisch zum
|
||||
// Ticket (fail-closed) — die Mail geht in die Betreiber-Review.
|
||||
const allowlisted = senderDomain != null && allow.includes(senderDomain);
|
||||
if (!allowlisted) {
|
||||
return {
|
||||
action: "review",
|
||||
review: { ...baseReview, reason: "allowlist_failed", tenantId: cfg.tenantId },
|
||||
};
|
||||
}
|
||||
if (msg.dkim === "fail") {
|
||||
return {
|
||||
action: "review",
|
||||
review: { ...baseReview, reason: "dkim_failed", tenantId: cfg.tenantId },
|
||||
};
|
||||
}
|
||||
|
||||
const title = (msg.subject || "(ohne Betreff)").slice(0, MAX_TITLE);
|
||||
const origin = `Eingang per E-Mail von ${sender || "unbekannt"}` +
|
||||
(baseReview.recipient ? ` an ${baseReview.recipient}` : "");
|
||||
const description = `[${origin}]\n\n${msg.text}`.slice(0, MAX_DESC);
|
||||
|
||||
return {
|
||||
action: "incident",
|
||||
spfWarning: msg.spf === "fail",
|
||||
incident: {
|
||||
tenantId: cfg.tenantId,
|
||||
title,
|
||||
description,
|
||||
reporterName: msg.senderName ?? (sender || null),
|
||||
reporterContact: sender || null,
|
||||
inboundMessageId: msg.messageId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Globaler, nicht erratbarer Intake-Token (Auto-Token, KONZEPT §12a). */
|
||||
export function generateIntakeToken(): string {
|
||||
// 16 Bytes → 32 Hex-Zeichen (a–f, 0–9) → passt auf TOKEN_RE ([a-z0-9]{8,64}).
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* refNo-Generator für Vorfälle: `INC-<JJJJ>-<lfd>` je Mandant/Jahr (Muster Risiko-refNo).
|
||||
*
|
||||
* Ausgelagert (kein "use server"), damit sowohl die Server-Action als auch der
|
||||
* Akzeptanztest (scripts/test-incidents.ts) die reine Logik verwenden können.
|
||||
* `client` ist ein Prisma-artiger Client mit einem `incident.findMany` — im
|
||||
* Betrieb der rohe Owner-`prisma`, im Test ebenso.
|
||||
*/
|
||||
type IncidentFinder = {
|
||||
incident: {
|
||||
findMany: (args: {
|
||||
where: { tenantId: string; refNo: { startsWith: string } };
|
||||
select: { refNo: true };
|
||||
}) => Promise<{ refNo: string }[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function nextIncidentRefNo(
|
||||
client: IncidentFinder,
|
||||
tenantId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const year = now.getFullYear();
|
||||
const prefix = `INC-${year}-`;
|
||||
const rows = await client.incident.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 (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
return `${prefix}${String(max + 1).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { isaControlForIso, ISO_TO_ISA } from "@/lib/iso-isa-crosswalk";
|
||||
|
||||
/**
|
||||
* Umsetzungshinweise (C6) für ISO-27001-Anforderungen (B2).
|
||||
*
|
||||
* Der Hinweiskatalog `ImplementationHint` ist nach VDA-ISA-Controls verschlüsselt und wird
|
||||
* NICHT dupliziert: Weil ISO- und VDA-ISA-Anforderungen in der gemeinsamen Bibliothek auf
|
||||
* denselben Abschnitt zeigen, gelten die dortigen Hinweise für beide Normen. Die Auflösung
|
||||
* läuft über den generierten Crosswalk (`src/lib/iso-isa-crosswalk.ts`).
|
||||
*
|
||||
* Für die 33 Anforderungen in ISO-eigenen Abschnitten (Managementsystem-Klauseln, einzelne
|
||||
* Anhang-A-Controls) gibt es kein ISA-Gegenstück und damit keine Hinweise — der Aufruf
|
||||
* liefert dort eine leere Liste, kein Fehler.
|
||||
*/
|
||||
|
||||
export type IsoHint = {
|
||||
reqId: string;
|
||||
control: string;
|
||||
stufe: string;
|
||||
requirement: string;
|
||||
organisational: string;
|
||||
technical: string;
|
||||
evidence: string;
|
||||
resources: string;
|
||||
};
|
||||
|
||||
/** Hinweise zu einer ISO-Anforderung („A.8.5", „9.1"). Leer, wenn kein ISA-Gegenstück existiert. */
|
||||
export async function hintsForIsoControl(db: TenantDb, isoRef: string): Promise<IsoHint[]> {
|
||||
const isa = isaControlForIso(isoRef);
|
||||
if (!isa) return [];
|
||||
return db.implementationHint.findMany({
|
||||
where: { control: isa },
|
||||
orderBy: { reqId: "asc" },
|
||||
select: {
|
||||
reqId: true, control: true, stufe: true, requirement: true,
|
||||
organisational: true, technical: true, evidence: true, resources: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweise für mehrere ISO-Anforderungen in einem Zug — für Listen-/Panelansichten.
|
||||
* Rückgabe: ISO-Referenz → Hinweise (nur Einträge mit mindestens einem Hinweis).
|
||||
*/
|
||||
export async function hintsForIsoControls(
|
||||
db: TenantDb,
|
||||
isoRefs: string[],
|
||||
): Promise<Map<string, IsoHint[]>> {
|
||||
const byIsa = new Map<string, string[]>();
|
||||
for (const ref of isoRefs) {
|
||||
const isa = isaControlForIso(ref);
|
||||
if (!isa) continue;
|
||||
byIsa.set(isa, [...(byIsa.get(isa) ?? []), ref]);
|
||||
}
|
||||
if (byIsa.size === 0) return new Map();
|
||||
|
||||
const hints = await db.implementationHint.findMany({
|
||||
where: { control: { in: [...byIsa.keys()] } },
|
||||
orderBy: { reqId: "asc" },
|
||||
select: {
|
||||
reqId: true, control: true, stufe: true, requirement: true,
|
||||
organisational: true, technical: true, evidence: true, resources: true,
|
||||
},
|
||||
});
|
||||
|
||||
const out = new Map<string, IsoHint[]>();
|
||||
for (const h of hints) {
|
||||
for (const isoRef of byIsa.get(h.control) ?? []) {
|
||||
out.set(isoRef, [...(out.get(isoRef) ?? []), h]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Abdeckungsgrad: wie viele ISO-Anforderungen über den Crosswalk Hinweise erreichen können. */
|
||||
export function isoHintCoverage(): { withIsa: number; isoOnly: number; total: number } {
|
||||
const withIsa = Object.keys(ISO_TO_ISA).length;
|
||||
return { withIsa, isoOnly: 120 - withIsa, total: 120 };
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "./config";
|
||||
import { enqueueMail } from "./service";
|
||||
import { normalizeLocale, type Locale } from "./templates";
|
||||
import {
|
||||
openReportDeadlines,
|
||||
type DeadlineKind,
|
||||
} from "@/lib/incident-deadlines";
|
||||
|
||||
/**
|
||||
* Vorfall-Benachrichtigungen (Fachkonzept §7, SEC1-Infrastruktur) — IM-B.
|
||||
*
|
||||
* Modelliert nach `notifications.ts` (Aufgaben), gleiche Zusagen:
|
||||
* - **Opt-in per Default** (fehlt eine `NotificationPreference`, wird versendet).
|
||||
* - **Mandantenisolation** — Empfänger immer innerhalb des auslösenden Mandanten.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand** — der Auslöser bekommt keine Mail über die eigene Aktion.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job hängt das Datum an.
|
||||
*
|
||||
* „Fire and forget": Fehler werden geloggt, nie an die Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type IncidentEvent =
|
||||
| "incident_created"
|
||||
| "incident_assigned"
|
||||
| "incident_status_changed"
|
||||
| "incident_report_due"
|
||||
| "incident_closed";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: IncidentEvent,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const [user, preference, settings] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: { id: userId, tenantId, status: "ACTIVE" },
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
if (preference && !preference.email) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale: normalizeLocale(preference?.locale ?? settings?.locale),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs der ISB/Incident-Manager eines Mandanten = aktive Nutzer mit dem Recht
|
||||
* `incident:manage` (§1/§7). Streng mandantengebunden.
|
||||
*/
|
||||
export async function incidentManagerIds(tenantId: string): Promise<string[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: "ACTIVE",
|
||||
userRoles: {
|
||||
some: { role: { rolePermissions: { some: { permission: { key: "incident:manage" } } } } },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return users.map((u) => u.id);
|
||||
}
|
||||
|
||||
type TextBuilder = (title: string, refNo: string, detail?: string) => { subject: string; body: string };
|
||||
|
||||
const TEXTS: Record<IncidentEvent, Record<Locale, TextBuilder>> = {
|
||||
incident_created: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Neuer Vorfall gemeldet",
|
||||
body: `Ein neuer Vorfall „${title}" (${refNo}) wurde gemeldet und wartet auf Triage.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "New incident reported",
|
||||
body: `A new incident "${title}" (${refNo}) has been reported and awaits triage.`,
|
||||
}),
|
||||
},
|
||||
incident_assigned: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall zugewiesen",
|
||||
body: `Ihnen wurde der Vorfall „${title}" (${refNo}) zur Bearbeitung zugewiesen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident assigned",
|
||||
body: `The incident "${title}" (${refNo}) has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
incident_status_changed: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Statusänderung am Vorfall",
|
||||
body: `Der Vorfall „${title}" (${refNo}) hat einen neuen Stand: ${detail ?? "aktualisiert"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Incident status changed",
|
||||
body: `The incident "${title}" (${refNo}) has a new state: ${detail ?? "updated"}.`,
|
||||
}),
|
||||
},
|
||||
incident_report_due: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Meldefrist für Vorfall",
|
||||
body: `Für den Vorfall „${title}" (${refNo}) ${detail ?? "steht eine Meldefrist an"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Reporting deadline for incident",
|
||||
body: `For the incident "${title}" (${refNo}) ${detail ?? "a reporting deadline is due"}.`,
|
||||
}),
|
||||
},
|
||||
incident_closed: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall abgeschlossen",
|
||||
body: `Der Vorfall „${title}" (${refNo}) wurde abgeschlossen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident closed",
|
||||
body: `The incident "${title}" (${refNo}) has been closed.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Vorfall-Benachrichtigung an eine Menge von Empfängern. Der
|
||||
* Auslöser (`actorId`) wird ausgenommen, Empfänger werden dedupliziert und je
|
||||
* Sprache/Präferenz aufgelöst.
|
||||
*/
|
||||
export async function notifyIncidentEvent(input: {
|
||||
tenantId: string;
|
||||
event: IncidentEvent;
|
||||
incidentId: string;
|
||||
refNo: string;
|
||||
title: string;
|
||||
recipientIds: (string | null | undefined)[];
|
||||
actorId?: string | null;
|
||||
detail?: string;
|
||||
/** Ergänzt den Idempotenzschlüssel (der Fristen-Job hängt Frist-Art + Datum an). */
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const ids = [...new Set(input.recipientIds.filter((x): x is string => !!x))].filter(
|
||||
(id) => id !== input.actorId,
|
||||
);
|
||||
|
||||
for (const id of ids) {
|
||||
const recipient = await resolveRecipient(input.tenantId, id, input.event);
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.title, input.refNo, input.detail);
|
||||
await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.incidentId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${input.incidentId}`),
|
||||
refNo: input.refNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[mail] Vorfall-Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const DEADLINE_LABEL: Record<DeadlineKind, Record<Locale, string>> = {
|
||||
erstmeldung: { de: "Die NIS2-Erstmeldung (24 h)", en: "The NIS2 initial report (24 h)" },
|
||||
folgemeldung: { de: "Die NIS2-Folgemeldung (72 h)", en: "The NIS2 follow-up report (72 h)" },
|
||||
abschluss: { de: "Der NIS2-Abschlussbericht (1 Monat)", en: "The NIS2 final report (1 month)" },
|
||||
dsgvo: { de: "Die DSGVO-Meldung (Art. 33, 72 h)", en: "The GDPR notification (Art. 33, 72 h)" },
|
||||
reaction: { de: "Die interne Reaktionsfrist", en: "The internal reaction SLA" },
|
||||
resolution: { de: "Die interne Behebungsfrist", en: "The internal resolution SLA" },
|
||||
};
|
||||
|
||||
function deadlineDetail(kind: DeadlineKind, overdue: boolean, remainingMs: number, locale: Locale): string {
|
||||
const label = DEADLINE_LABEL[kind][locale];
|
||||
if (overdue) {
|
||||
const days = Math.max(0, Math.floor(-remainingMs / 86_400_000));
|
||||
return locale === "en"
|
||||
? `${label} is overdue${days > 0 ? ` by ${days} day(s)` : ""}`
|
||||
: `${label} ist überfällig${days > 0 ? ` (seit ${days} Tag(en))` : ""}`;
|
||||
}
|
||||
const hours = Math.max(0, Math.ceil(remainingMs / 3_600_000));
|
||||
return locale === "en"
|
||||
? `${label} is due within ${hours} h`
|
||||
: `${label} läuft in ${hours} h ab`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung/Eskalation für Meldepflichten (§6/§7). Läuft täglich im
|
||||
* Reminder-Worker (mandantenübergreifend über den rohen Client; die Zuordnung
|
||||
* kommt aus dem Vorfall). Pro Vorfall, Frist-Art und Tag höchstens eine Mail.
|
||||
*
|
||||
* Empfänger: drohend → owner/assignee (bzw. Manager, falls unbesetzt);
|
||||
* überfällig → zusätzlich alle Incident-Manager (Eskalation).
|
||||
*/
|
||||
export async function sendIncidentDeadlineReminders(now: Date = new Date()): Promise<number> {
|
||||
const incidents = await prisma.incident.findMany({
|
||||
where: {
|
||||
status: { not: "abgeschlossen" },
|
||||
reportStatus: { not: "abschluss" },
|
||||
OR: [
|
||||
{ erstmeldungDueAt: { not: null } },
|
||||
{ folgemeldungDueAt: { not: null } },
|
||||
{ abschlussDueAt: { not: null } },
|
||||
{ dsgvoDueAt: { not: null } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
refNo: true,
|
||||
title: true,
|
||||
severity: true,
|
||||
reportStatus: true,
|
||||
ownerId: true,
|
||||
assigneeId: true,
|
||||
createdBy: true,
|
||||
detectedAt: true,
|
||||
reportedAt: true,
|
||||
occurredAt: true,
|
||||
createdAt: true,
|
||||
erstmeldungDueAt: true,
|
||||
folgemeldungDueAt: true,
|
||||
abschlussDueAt: true,
|
||||
dsgvoDueAt: true,
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
let sent = 0;
|
||||
const managerCache = new Map<string, string[]>();
|
||||
|
||||
for (const inc of incidents) {
|
||||
const due = openReportDeadlines(inc, now).filter((d) => d.overdue || d.dueSoon);
|
||||
if (due.length === 0) continue;
|
||||
|
||||
for (const item of due) {
|
||||
// Basis-Empfänger: Bearbeiter/Owner; unbesetzt oder überfällig → Manager.
|
||||
const recipients = new Set<string>();
|
||||
if (inc.ownerId) recipients.add(inc.ownerId);
|
||||
if (inc.assigneeId) recipients.add(inc.assigneeId);
|
||||
if (item.overdue || recipients.size === 0) {
|
||||
if (!managerCache.has(inc.tenantId)) {
|
||||
managerCache.set(inc.tenantId, await incidentManagerIds(inc.tenantId));
|
||||
}
|
||||
managerCache.get(inc.tenantId)!.forEach((id) => recipients.add(id));
|
||||
}
|
||||
if (recipients.size === 0) continue;
|
||||
|
||||
// Sprache je Empfänger wird in notifyIncidentEvent aufgelöst; der Detailtext
|
||||
// wird pro Empfänger benötigt → hier einmal in beiden Sprachen vorbereiten.
|
||||
for (const id of recipients) {
|
||||
const recipient = await resolveRecipient(inc.tenantId, id, "incident_report_due");
|
||||
if (!recipient) continue;
|
||||
const detail = deadlineDetail(item.kind, item.overdue, item.remainingMs, recipient.locale);
|
||||
const text = TEXTS.incident_report_due[recipient.locale](inc.title, inc.refNo, detail);
|
||||
const result = await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: inc.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `incident_report_due:${inc.id}:${recipient.id}:${item.kind}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${inc.id}`),
|
||||
refNo: inc.refNo,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -31,4 +31,4 @@ export const MAIL_DLQ = "mail-dead-letter";
|
||||
*/
|
||||
export const SCHEDULER_QUEUE = "mail-scheduler";
|
||||
/** Wiederkehrender Job: fällige/überfällige Aufgaben erinnern. */
|
||||
export const DUE_REMINDER_JOB = "task-due-reminder";
|
||||
export const DUE_REMINDER_JOB = "daily-reminders";
|
||||
|
||||
@@ -4,40 +4,33 @@ import { enqueueMail } from "./service";
|
||||
import { formatWhen, normalizeLocale, type Locale } from "./templates";
|
||||
|
||||
/**
|
||||
* SEC1 §6 — Benachrichtigungen aus Aufgaben-Ereignissen.
|
||||
* SEC1 §6 — Benachrichtigungen aus Fach-Ereignissen (Andockpunkt für die Craftvia-Module,
|
||||
* z. B. „Auftrag zugewiesen", „Bericht zur Freigabe", „Notdienst gemeldet").
|
||||
*
|
||||
* Regeln:
|
||||
* - **Opt-in per Default.** Fehlt eine `NotificationPreference`-Zeile, wird
|
||||
* versendet. Erst ein bewusstes `email = false` unterdrückt.
|
||||
* - **Mandantenisolation.** Empfänger wird immer innerhalb des auslösenden
|
||||
* Mandanten aufgelöst; ein Task verweist nie über die Mandantengrenze.
|
||||
* Mandanten aufgelöst.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand.** Wer die Aktion auslöst, bekommt keine Mail über
|
||||
* die eigene Handlung.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job nutzt zusätzlich das
|
||||
* Datum, damit pro Aufgabe und Tag höchstens eine Erinnerung rausgeht.
|
||||
* - **Idempotenz** über `dedupeKey`.
|
||||
*
|
||||
* Alle Funktionen sind „fire and forget": Fehler werden geloggt, aber nie an die
|
||||
* auslösende Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type NotificationEvent =
|
||||
| "task_assigned"
|
||||
| "task_approval_requested"
|
||||
| "task_decided"
|
||||
| "task_due";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
/**
|
||||
* Löst den Empfänger inklusive Sprache auf und berücksichtigt seine Präferenz.
|
||||
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv,
|
||||
* abbestellt).
|
||||
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv, abbestellt).
|
||||
*/
|
||||
async function resolveRecipient(
|
||||
export async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: NotificationEvent,
|
||||
eventType: string,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
@@ -47,7 +40,7 @@ async function resolveRecipient(
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
where: { userId_eventType: { userId, eventType } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
@@ -65,156 +58,46 @@ async function resolveRecipient(
|
||||
};
|
||||
}
|
||||
|
||||
const TEXTS: Record<
|
||||
NotificationEvent,
|
||||
Record<Locale, (title: string, extra?: string) => { subject: string; body: string }>
|
||||
> = {
|
||||
task_assigned: {
|
||||
de: (title) => ({
|
||||
subject: "Neue Aufgabe für Sie",
|
||||
body: `Ihnen wurde die Aufgabe „${title}" zugewiesen.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "A new task for you",
|
||||
body: `The task "${title}" has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
task_approval_requested: {
|
||||
de: (title) => ({
|
||||
subject: "Freigabe angefragt",
|
||||
body: `Sie wurden um die Freigabe von „${title}" gebeten.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "Approval requested",
|
||||
body: `You have been asked to approve "${title}".`,
|
||||
}),
|
||||
},
|
||||
task_decided: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Entscheidung zu Ihrer Freigabe-Anfrage",
|
||||
body: `Zu „${title}" liegt eine Entscheidung vor: ${extra ?? "bearbeitet"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Decision on your approval request",
|
||||
body: `A decision has been made on "${title}": ${extra ?? "processed"}.`,
|
||||
}),
|
||||
},
|
||||
task_due: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Aufgabe fällig",
|
||||
body: `Die Aufgabe „${title}" ist ${extra ?? "fällig"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Task due",
|
||||
body: `The task "${title}" is ${extra ?? "due"}.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Aufgaben-Benachrichtigung. Wird aus den Task-Actions heraus
|
||||
* aufgerufen und darf diese nie scheitern lassen.
|
||||
* Versendet eine Fach-Benachrichtigung an eine Mitgliedschaft. Texte liefert der Aufrufer
|
||||
* je Sprache (die Module halten ihre Texte selbst). Darf die Fachaktion nie scheitern lassen.
|
||||
*/
|
||||
export async function notifyTaskEvent(input: {
|
||||
export async function notifyUser(input: {
|
||||
tenantId: string;
|
||||
event: NotificationEvent;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskType: string;
|
||||
eventType: string;
|
||||
/** Objekt, auf das sich die Benachrichtigung bezieht (für Idempotenz). */
|
||||
entityId: string;
|
||||
recipientId: string | null | undefined;
|
||||
/** Auslöser — bekommt keine Mail über die eigene Handlung. */
|
||||
actorId?: string | null;
|
||||
/** Zusatz, z. B. „freigegeben" / „abgelehnt" oder „seit 3 Tagen überfällig". */
|
||||
detail?: string;
|
||||
/** Überschreibt den Idempotenzschlüssel (Fristen-Job hängt das Datum an). */
|
||||
texts: Record<Locale, { subject: string; body: string }>;
|
||||
/** Relativer Pfad in der App, z. B. `/work-orders/<id>`. */
|
||||
path?: string;
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (input.recipientId && input.actorId && input.recipientId === input.actorId) return;
|
||||
|
||||
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.event);
|
||||
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.eventType);
|
||||
if (!recipient) return;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.taskTitle, input.detail);
|
||||
|
||||
const text = input.texts[recipient.locale];
|
||||
await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.taskId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
dedupeKey: `${input.eventType}:${input.entityId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${input.taskId}`),
|
||||
taskType: input.taskType,
|
||||
actionUrl: input.path ? absoluteUrl(input.path) : undefined,
|
||||
eventType: input.eventType,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Eine misslungene Benachrichtigung darf die Fachaktion nicht kippen.
|
||||
console.error("[mail] Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung (SEC1 §6): einmal täglich für offene Aufgaben, die heute
|
||||
* fällig sind oder es bereits waren.
|
||||
*
|
||||
* Doppelversand ist über den `dedupeKey` inklusive Datum ausgeschlossen: pro
|
||||
* Aufgabe, Empfänger und Tag entsteht höchstens eine Mail — auch wenn der Job
|
||||
* (z. B. nach einem Neustart) mehrfach läuft.
|
||||
*
|
||||
* Läuft mandantenübergreifend über den rohen Client, weil es keinen
|
||||
* Request-/Session-Kontext gibt; die Mandantenzuordnung kommt aus der Aufgabe
|
||||
* selbst und wird an jede Mail durchgereicht.
|
||||
*/
|
||||
export async function sendDueReminders(now: Date = new Date()): Promise<number> {
|
||||
const endOfDay = new Date(now);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { status: "OPEN", dueDate: { not: null, lte: endOfDay }, assigneeId: { not: null } },
|
||||
select: { id: true, tenantId: true, title: true, type: true, assigneeId: true, dueDate: true },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const task of tasks) {
|
||||
const overdueDays = task.dueDate
|
||||
? Math.floor((now.getTime() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
const detailDe = overdueDays > 0 ? `seit ${overdueDays} Tag(en) überfällig` : "heute fällig";
|
||||
const detailEn = overdueDays > 0 ? `overdue by ${overdueDays} day(s)` : "due today";
|
||||
|
||||
const recipient = await resolveRecipient(task.tenantId, task.assigneeId, "task_due");
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS.task_due[recipient.locale](
|
||||
task.title,
|
||||
recipient.locale === "en" ? detailEn : detailDe,
|
||||
);
|
||||
|
||||
const result = await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: task.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `task_due:${task.id}:${recipient.id}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${task.id}`),
|
||||
taskType: task.type,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Zeitstempel für Transaktionsmails (Passwort/MFA/E-Mail-Änderung). */
|
||||
export function nowFor(locale: Locale): string {
|
||||
return formatWhen(new Date(), locale);
|
||||
|
||||
@@ -29,7 +29,7 @@ export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
|
||||
tenantId: string | null;
|
||||
locale?: string | null;
|
||||
/**
|
||||
* Idempotenzschlüssel, z. B. `task_assigned:<taskId>:<userId>`. Ohne Schlüssel
|
||||
* Idempotenzschlüssel, z. B. `work_order_assigned:<orderId>:<userId>`. Ohne Schlüssel
|
||||
* ist Mehrfachversand möglich — für Transaktionsmails gewollt (jede Anfrage
|
||||
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BRAND } from "@/lib/brand";
|
||||
* und worker-tauglich; die UI-Kataloge in `messages/*.json` bleiben unberührt.
|
||||
*
|
||||
* Layout, Farben und die Dachmarken-Fußzeile kommen aus `src/lib/email-brand.ts`
|
||||
* (Certvia-CD, Inline-Styles, Tabellenlayout für Outlook).
|
||||
* (Craftvia-CD, Inline-Styles, Tabellenlayout für Outlook).
|
||||
*
|
||||
* WICHTIG: Templates erhalten fertige `actionUrl`s. Tokens werden von SEC2/SEC3/
|
||||
* SEC4 erzeugt und tauchen weder im MailLog noch in Logs auf.
|
||||
@@ -37,14 +37,7 @@ export type TemplateVars = {
|
||||
subject: string;
|
||||
body: string;
|
||||
actionUrl?: string;
|
||||
taskType: string;
|
||||
};
|
||||
incident_notification: {
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
actionUrl?: string;
|
||||
refNo: string;
|
||||
eventType: string;
|
||||
};
|
||||
test: { name: string; when: string };
|
||||
};
|
||||
@@ -59,20 +52,13 @@ export const TEMPLATE_KEYS = [
|
||||
"email_changed_notice",
|
||||
"mfa_changed",
|
||||
"notification",
|
||||
"incident_notification",
|
||||
"test",
|
||||
] as const satisfies readonly TemplateKey[];
|
||||
|
||||
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
||||
const FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung, weil Ihnen eine Aufgabe zugewiesen ist. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because a task is assigned to you. You can change this in your profile.",
|
||||
};
|
||||
|
||||
/** Fußnote für Vorfall-Benachrichtigungen (§7). */
|
||||
const INCIDENT_FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung, weil Sie am Vorfallmanagement beteiligt sind. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because you are involved in incident management. You can change this in your profile.",
|
||||
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because of your role in your company. You can change this in your profile.",
|
||||
};
|
||||
|
||||
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
|
||||
@@ -141,16 +127,9 @@ const de: { [K in TemplateKey]: Builder<K> } = {
|
||||
subject: `${BRAND.name}: ${v.subject}`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hallo ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "In Certvia öffnen", url: v.actionUrl } : undefined,
|
||||
action: v.actionUrl ? { label: `In ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
|
||||
footerNote: FOOTER_NOTE.de,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hallo ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Vorfall öffnen", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_FOOTER_NOTE.de,
|
||||
}),
|
||||
test: (v) => ({
|
||||
subject: `${BRAND.name}: Test-Mail`,
|
||||
heading: "Test-Mail",
|
||||
@@ -229,13 +208,6 @@ const en: { [K in TemplateKey]: Builder<K> } = {
|
||||
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
|
||||
footerNote: FOOTER_NOTE.en,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hello ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Open incident", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_FOOTER_NOTE.en,
|
||||
}),
|
||||
test: (v) => ({
|
||||
subject: `${BRAND.name}: test message`,
|
||||
heading: "Test message",
|
||||
|
||||
@@ -2,8 +2,6 @@ import { UnrecoverableError, Worker, type Job } from "bullmq";
|
||||
import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver";
|
||||
import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
|
||||
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||
import { sendDueReminders } from "./notifications";
|
||||
import { sendIncidentDeadlineReminders } from "./incident-notifications";
|
||||
import { closeMailProvider } from "./provider-smtp";
|
||||
|
||||
/**
|
||||
@@ -103,10 +101,9 @@ export function startReminderWorker(): Worker | null {
|
||||
SCHEDULER_QUEUE,
|
||||
async (job: Job) => {
|
||||
if (job.name !== DUE_REMINDER_JOB) return;
|
||||
const count = await sendDueReminders();
|
||||
console.info(`[mail] Aufgaben-Fristen-Erinnerungen eingestellt: ${count}`);
|
||||
const incidentCount = await sendIncidentDeadlineReminders();
|
||||
console.info(`[mail] Vorfall-Meldefristen-Erinnerungen/Eskalationen eingestellt: ${incidentCount}`);
|
||||
// Andockpunkt: tägliche Erinnerungen der Fachmodule (z. B. überfällige Berichte,
|
||||
// offene Einsätze) hier registrieren. Aktuell sind keine Erinnerungen definiert.
|
||||
console.info("[mail] Täglicher Erinnerungslauf: keine Erinnerungen registriert.");
|
||||
},
|
||||
{ connection, concurrency: 1 },
|
||||
);
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { dbForTenant } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Server-Bausteine für das generische Objekt-Review (Story A3-1). Review läuft über
|
||||
* Aufgaben (Task, Typ `validation`): ein Objekt wird zur Validierung eingereicht
|
||||
* (Vier-Augen), ein Validator (`validate_objects`, z. B. Rolle `external_validator`)
|
||||
* bestätigt oder weist zurück. Der Review-Status wird aus der jüngsten Aufgabe
|
||||
* abgeleitet (src/lib/object-review.ts).
|
||||
*/
|
||||
type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
|
||||
/** Jüngste Review-Aufgabe eines Objekts (oder null). */
|
||||
export async function latestReviewTask(db: TenantDb, entityType: string, entityId: string) {
|
||||
return db.task.findFirst({
|
||||
where: { entityType, entityId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { comments: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Aktive Nutzer mit Validierungsrecht (`validate_objects`), optional ohne eine Person. */
|
||||
export async function eligibleValidators(db: TenantDb, excludeUserId?: string) {
|
||||
const users = await db.user.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
...(excludeUserId ? { id: { not: excludeUserId } } : {}),
|
||||
userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "validate_objects" } } } } } },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
interface CreateReviewInput {
|
||||
tenantId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityRef?: string | null;
|
||||
title: string;
|
||||
approverId: string;
|
||||
submitterId: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Objekt zur Validierung einreichen: offene Vorgänger schließen und eine neue
|
||||
* `validation`-Aufgabe an einen berechtigten Validator (≠ Einreicher) erzeugen.
|
||||
*/
|
||||
export async function createObjectReviewTask(db: TenantDb, input: CreateReviewInput) {
|
||||
if (!input.approverId) throw new Error("Bitte einen Validator auswählen.");
|
||||
if (input.approverId === input.submitterId) throw new Error("Vier-Augen-Prinzip: Der Validator muss eine andere Person sein.");
|
||||
|
||||
const approver = await db.user.findFirst({
|
||||
where: {
|
||||
id: input.approverId,
|
||||
status: "ACTIVE",
|
||||
userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "validate_objects" } } } } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!approver) throw new Error("Ungültiger Validator (kein aktiver Nutzer mit Validierungsrecht).");
|
||||
|
||||
// Vorherige offene Review-Aufgaben dieses Objekts abschließen (Resubmit).
|
||||
await db.task.updateMany({
|
||||
where: { entityType: input.entityType, entityId: input.entityId, status: "OPEN" },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
|
||||
return db.task.create({
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
type: "validation",
|
||||
title: input.title,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
entityRef: input.entityRef ?? null,
|
||||
assigneeId: approver.id,
|
||||
createdById: input.submitterId,
|
||||
status: "OPEN",
|
||||
comments: input.note
|
||||
? { create: { tenantId: input.tenantId, authorId: input.submitterId, kind: "submit", body: input.note } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Objekt-Route für Revalidierung nach einer Review-Entscheidung. */
|
||||
export function pathForEntity(entityType: string | null): string | null {
|
||||
switch (entityType) {
|
||||
case "risk":
|
||||
return "/risks";
|
||||
case "policy_document":
|
||||
return "/policies";
|
||||
case "asset":
|
||||
return "/assets";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { PolicyRequirement } from "@prisma/client";
|
||||
import { compareControl } from "@/lib/control-titles";
|
||||
import { domainForControl } from "@/server/control-domain";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Primäres Control eines Richtliniendokuments = niedrigstes `PolicyRequirement.control`
|
||||
* (Richtlinie über `policyCode == code`, Verfahren über `vaCodes.includes(code)`).
|
||||
*/
|
||||
function primaryControlForDoc(code: string, requirements: PolicyRequirement[]): string | null {
|
||||
const own = requirements.filter((r) => r.policyCode === code);
|
||||
const list = own.length ? own : requirements.filter((r) => r.vaCodes.includes(code));
|
||||
if (!list.length) return null;
|
||||
return [...list].map((r) => r.control).sort(compareControl)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kern der Fachbereichs-Ableitung (ohne Guard, damit auch der Paket-Import sie
|
||||
* wiederverwenden kann): setzt `PolicyDocument.domain` dort, wo er leer ist, aus dem
|
||||
* primären Control → `domainForControl`. Idempotent — bereits gesetzte Fachbereiche
|
||||
* bleiben unangetastet. Gibt die Zahl der befüllten Dokumente zurück.
|
||||
*
|
||||
* Erwartet eine tenant-scoped `TenantDb` (RLS/Guard) — nie das ungescopte `prisma`.
|
||||
*/
|
||||
export async function deriveDomainsCore(db: TenantDb, tenantId: string): Promise<number> {
|
||||
const [docs, requirements] = await Promise.all([
|
||||
db.policyDocument.findMany({ where: { archivedAt: null, domain: null }, select: { id: true, code: true } }),
|
||||
db.policyRequirement.findMany({ where: { archivedAt: null }, select: { control: true, policyCode: true, vaCodes: true } }),
|
||||
]);
|
||||
let filled = 0;
|
||||
for (const doc of docs) {
|
||||
const control = primaryControlForDoc(doc.code, requirements as PolicyRequirement[]);
|
||||
if (!control) continue;
|
||||
const domain = await domainForControl(db, tenantId, control);
|
||||
if (!domain) continue;
|
||||
await db.policyDocument.update({ where: { id: doc.id }, data: { domain } });
|
||||
filled++;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
+15
-103
@@ -1,38 +1,7 @@
|
||||
import type { PrismaClient, Framework } from "@prisma/client";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { hashPassword } from "@/server/password";
|
||||
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { reconcilePackage, stampPackageVersion } from "../../prisma/import-policies";
|
||||
import { resolvePackageForTenant } from "../../prisma/template-store";
|
||||
import { importManaged } from "../../prisma/import-managed";
|
||||
|
||||
/**
|
||||
* Mappt die Mandanten-Stammdaten (TenantSettings) auf die ISMS-Template-Variablen
|
||||
* des Richtlinienmoduls (§4.1) — eine Pflegestelle, keine Doppeleingabe.
|
||||
*/
|
||||
export async function syncPolicyVariablesFromSettings(
|
||||
prisma: PrismaClient,
|
||||
tenantId: string,
|
||||
s: {
|
||||
orgName: string; orgShort?: string | null; ismsScope?: string | null; ismsScopeDescription?: string | null;
|
||||
roleManagement?: string | null; roleIsb?: string | null; roleItLead?: string | null; roleDpo?: string | null;
|
||||
}
|
||||
) {
|
||||
const map: Record<string, string | null | undefined> = {
|
||||
ORG_NAME: s.orgName,
|
||||
ORG_SHORT: s.orgShort,
|
||||
ISMS_SCOPE: s.ismsScope,
|
||||
ISMS_SCOPE_DESCRIPTION: s.ismsScopeDescription,
|
||||
ROLE_MANAGEMENT: s.roleManagement,
|
||||
ROLE_ISB: s.roleIsb,
|
||||
ROLE_IT_LEAD: s.roleItLead,
|
||||
ROLE_DPO: s.roleDpo,
|
||||
};
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (value == null || value === "") continue;
|
||||
await prisma.policyVariable.updateMany({ where: { tenantId, key }, data: { value } });
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProvisionOpts {
|
||||
name: string;
|
||||
@@ -40,25 +9,18 @@ export interface ProvisionOpts {
|
||||
short?: string;
|
||||
sector?: string;
|
||||
admin: { email: string; name: string; password: string };
|
||||
tisaxLevel?: "AL2" | "AL3";
|
||||
seedPoliciesDir?: string;
|
||||
actorId?: string | null;
|
||||
/**
|
||||
* AP2: Rahmenwerke, die der Mandant führt (Reihenfolge = Primär zuerst). Default
|
||||
* `["TISAX"]` (rückwärtskompatibel). Steuert die TenantFramework-Zeilen, welche
|
||||
* Mappings importiert und welche Sichtbarkeits-Flags (FLAG_FW_*) gesetzt werden.
|
||||
*/
|
||||
frameworks?: Framework[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Mandanten idempotent an bzw. aktualisiert ihn: Standard-Rollen +
|
||||
* Permissions, erster Admin-User, Mandanten-Einstellungen, alle Module aktiv,
|
||||
* optional Richtlinienpaket-Seed inkl. TISAX-Default (§3.7 Auto-Provisioning).
|
||||
* Legt einen Mandanten idempotent an bzw. aktualisiert ihn: globaler Permission-Katalog,
|
||||
* Standard-Rollen (ROLE_DEFS) inkl. Rechte, erster Mandantenadministrator (Identity +
|
||||
* Mitgliedschaft), Mandanten-Einstellungen, alle Module aktiv, Audit-Eintrag.
|
||||
*
|
||||
* Andockpunkt Fachmodule: mandantenspezifische Stammdaten-Defaults (z. B. Auftragsarten,
|
||||
* Checklisten-Vorlagen) werden künftig hier nach Schritt 6 angelegt.
|
||||
*/
|
||||
export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts) {
|
||||
const tisaxLevel = opts.tisaxLevel ?? "AL2";
|
||||
|
||||
// 1. Globaler Permission-Katalog
|
||||
for (const key of PERMISSIONS) {
|
||||
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
|
||||
@@ -88,11 +50,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Erster Admin-User (Mandanten-Admin + ISB)
|
||||
// Option C: globale Identity (Anmeldung) + Mitgliedschaft (User) im Mandanten.
|
||||
// Expand/Contract: passwordHash bleibt vorerst auch auf der Membership (Legacy),
|
||||
// bis WS1 den Login gegen Identity umstellt. Idempotent: bestehende Identity/
|
||||
// Passwort wird NICHT überschrieben (analog Bootstrap-Semantik).
|
||||
// 4. Erster Mandantenadministrator: globale Identity (Anmeldung) + Mitgliedschaft.
|
||||
// Idempotent: eine bestehende Identity/ihr Passwort wird NICHT überschrieben.
|
||||
const passwordHash = await hashPassword(opts.admin.password);
|
||||
const identity = await prisma.identity.upsert({
|
||||
where: { email: opts.admin.email },
|
||||
@@ -104,16 +63,16 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
update: { name: opts.admin.name, identityId: identity.id },
|
||||
create: { tenantId: tenant.id, identityId: identity.id, email: opts.admin.email, name: opts.admin.name },
|
||||
});
|
||||
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
|
||||
for (const r of adminRoles) {
|
||||
const adminRole = await prisma.role.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "tenant-admin" } } });
|
||||
if (adminRole) {
|
||||
await prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId: admin.id, roleId: r.id } },
|
||||
where: { userId_roleId: { userId: admin.id, roleId: adminRole.id } },
|
||||
update: {},
|
||||
create: { userId: admin.id, roleId: r.id },
|
||||
create: { userId: admin.id, roleId: adminRole.id },
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Mandanten-Einstellungen (Quelle der ISMS-Variablen)
|
||||
// 5. Mandanten-Einstellungen (Unternehmensdaten)
|
||||
await prisma.tenantSettings.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
update: {},
|
||||
@@ -122,11 +81,6 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
orgName: opts.name,
|
||||
orgShort: opts.short ?? null,
|
||||
sector: opts.sector ?? null,
|
||||
roleManagement: "Geschäftsführung",
|
||||
roleIsb: "Informationssicherheitsbeauftragte(r) (ISB)",
|
||||
roleItLead: "IT-Leitung",
|
||||
roleDpo: "Datenschutzbeauftragte(r) (DSB)",
|
||||
tisaxLevel,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,50 +93,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
|
||||
});
|
||||
}
|
||||
|
||||
// 6b. Framework-Zugehörigkeit (AP2): der Mandant führt die in `frameworks` genannten
|
||||
// Rahmenwerke (Default TISAX). Erstes = Primär. Idempotent je (tenant, framework).
|
||||
const frameworks: Framework[] = opts.frameworks?.length ? [...new Set(opts.frameworks)] : ["TISAX"];
|
||||
const runsTisax = frameworks.includes("TISAX");
|
||||
for (const [i, framework] of frameworks.entries()) {
|
||||
await prisma.tenantFramework.upsert({
|
||||
where: { tenantId_framework: { tenantId: tenant.id, framework } },
|
||||
update: { isPrimary: i === 0 },
|
||||
create: { tenantId: tenant.id, framework, isPrimary: i === 0 },
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Richtlinienpaket JE FRAMEWORK importieren (falls Seed-Verzeichnis übergeben)
|
||||
if (opts.seedPoliciesDir) {
|
||||
// Neue Mandanten erhalten die neueste veröffentlichte DB-Vorlage (Datei-Fallback).
|
||||
// Anforderungen sind framework-scoped, die geteilten Inhalte werden nur beim ersten
|
||||
// Framework abgeglichen (reconcileShared, Falle 1.1).
|
||||
for (const [i, framework] of frameworks.entries()) {
|
||||
const { pkg } = await resolvePackageForTenant(prisma, tenant.id, opts.seedPoliciesDir, framework);
|
||||
await reconcilePackage(prisma, tenant.id, pkg, { framework, reconcileShared: i === 0 });
|
||||
await stampPackageVersion(prisma, tenant.id, pkg.version, framework);
|
||||
}
|
||||
await importManaged(prisma, tenant.id);
|
||||
|
||||
// AL-/Schutzbedarf-Flags sind TISAX-Konzepte → nur bei TISAX-Mandanten setzen
|
||||
// (reiner ISO-Mandant: keine AL-Flags, Übergabe D2/§1.3).
|
||||
if (runsTisax) {
|
||||
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
|
||||
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } });
|
||||
}
|
||||
|
||||
// WICHTIG (Reihenfolge, Übergabe AP2): die Framework-Sichtbarkeits-Flags NACH dem
|
||||
// Import setzen. reconcilePackage legt sie beim ersten Import mit dem Schema-Default
|
||||
// an (TISAX=true, ISO=false) und überschreibt bestehende Werte NIE — würden wir vorher
|
||||
// setzen, bliebe der Default stehen und ein ISO-Mandant sähe die VDA-ISA-Sicht.
|
||||
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_FW_TISAX" }, data: { value: runsTisax ? "true" : "false" } });
|
||||
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_FW_ISO27001" }, data: { value: frameworks.includes("ISO_27001") ? "true" : "false" } });
|
||||
|
||||
const settings = await prisma.tenantSettings.findUnique({ where: { tenantId: tenant.id } });
|
||||
if (settings) await syncPolicyVariablesFromSettings(prisma, tenant.id, settings);
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name, tisaxLevel, frameworks } },
|
||||
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name } },
|
||||
});
|
||||
|
||||
return tenant;
|
||||
|
||||
+93
-129
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* RBAC catalog: granular permissions bundled into per-tenant roles.
|
||||
* See docs/SPEC.md §3. Permissions are global rows (no tenantId),
|
||||
* roles are created per tenant from ROLE_DEFS at tenant provisioning.
|
||||
* RBAC-Katalog (Craftvia, docs/craftvia/SPEC-CRAFTVIA.md §4): granulare Permissions,
|
||||
* gebündelt in mandanteneigene Rollen. Permissions sind globale Zeilen (kein tenantId),
|
||||
* Rollen werden bei der Mandanten-Provisionierung aus ROLE_DEFS angelegt.
|
||||
*
|
||||
* Neue Permission ⇒ hier ergänzen, der Rollenzuordnung zuweisen und bestehende Mandanten
|
||||
* per `npx tsx scripts/sync-role-permissions.ts` nachziehen.
|
||||
*/
|
||||
|
||||
export const PERMISSIONS = [
|
||||
@@ -10,150 +13,103 @@ export const PERMISSIONS = [
|
||||
"user:read",
|
||||
"user:manage",
|
||||
"role:manage",
|
||||
// Assets & BIA (one module)
|
||||
"asset:read",
|
||||
"asset:write",
|
||||
"bia:read",
|
||||
"bia:write",
|
||||
// Risk
|
||||
"risk:read",
|
||||
"risk:write",
|
||||
"risk:accept",
|
||||
// Compliance
|
||||
"soa:read",
|
||||
"soa:write",
|
||||
// Measures / tasks
|
||||
"measure:read",
|
||||
"measure:write",
|
||||
// Aufgaben: anlegen/bearbeiten/übernehmen (F-10). Vorher ungeprüft — jeder
|
||||
// authentifizierte Nutzer konnte Aufgaben anlegen/übernehmen/bearbeiten.
|
||||
"task:write",
|
||||
// Policies
|
||||
"policy:read",
|
||||
"policy:write",
|
||||
"policy:approve",
|
||||
// Incidents (Modul „Vorfälle")
|
||||
"incident:read",
|
||||
"incident:report",
|
||||
"incident:manage",
|
||||
"incident:close",
|
||||
// Audits
|
||||
"audit:read",
|
||||
"audit:conduct",
|
||||
// Suppliers
|
||||
"supplier:read",
|
||||
"supplier:write",
|
||||
// Evidence / documents
|
||||
"evidence:read",
|
||||
"evidence:write",
|
||||
// Reporting / management review
|
||||
// Kunden
|
||||
"customer:read",
|
||||
"customer:write",
|
||||
"customer:merge",
|
||||
// Objekte
|
||||
"site:read",
|
||||
"site:write",
|
||||
// Teams
|
||||
"team:read",
|
||||
"team:manage",
|
||||
// Aufträge
|
||||
"work_order:read_all", // alle Aufträge des Mandanten
|
||||
"work_order:read_team", // nur eigene / Team-Aufträge
|
||||
"work_order:write",
|
||||
"work_order:assign",
|
||||
"work_order:release_billing",
|
||||
"work_order:cancel",
|
||||
// Auftragsimport
|
||||
"import:write",
|
||||
// Einsatz (Mobile): Einsatz starten, Zeiten, Material, Fotos, Notizen
|
||||
"field:execute",
|
||||
"field:correct_time",
|
||||
// Berichte
|
||||
"report:read",
|
||||
"review:manage",
|
||||
// Onboarding-Wizard
|
||||
"onboarding:use",
|
||||
// Objekt-Validierung (Story F2): Vier-Augen-Bestätigung von Review-Objekten
|
||||
"validate_objects",
|
||||
// Aufgaben-Oversight: alle Mandanten-Aufgaben sehen (nicht nur eigene) — für PM/Leitung
|
||||
"task:read_all",
|
||||
// Chat
|
||||
"chat:use",
|
||||
"report:write",
|
||||
"report:approve_team", // Teamleiter-Freigabe
|
||||
"report:approve", // Backoffice-Freigabe
|
||||
// Notdienst
|
||||
"emergency:create",
|
||||
"emergency:review",
|
||||
// Dokumente
|
||||
"document:read",
|
||||
"document:write",
|
||||
"document:read_internal",
|
||||
// Benachrichtigungen
|
||||
"notification:read",
|
||||
// Vorlagen: Auftragsarten, Checklisten, Berichtsvorlagen
|
||||
"settings:templates",
|
||||
// Lotse (KI-Assistent)
|
||||
"lotse:use",
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[number];
|
||||
|
||||
const ADMIN_ONLY: readonly Permission[] = ["tenant:manage", "user:manage", "role:manage"];
|
||||
|
||||
/** Rollen-Keys der Standardrollen (Spec §4). */
|
||||
export const ROLE_KEYS = ["tenant-admin", "backoffice", "team-lead", "technician"] as const;
|
||||
export type RoleKey = (typeof ROLE_KEYS)[number];
|
||||
|
||||
/** Role blueprints instantiated for every tenant (key → name + permissions). */
|
||||
export const ROLE_DEFS: Record<
|
||||
string,
|
||||
{ name: string; permissions: readonly Permission[] }
|
||||
> = {
|
||||
export const ROLE_DEFS: Record<RoleKey, { name: string; permissions: readonly Permission[] }> = {
|
||||
"tenant-admin": {
|
||||
name: "Mandanten-Admin",
|
||||
permissions: ["tenant:manage", "user:read", "user:manage", "role:manage", "report:read", "onboarding:use", "validate_objects", "task:read_all", "task:write", "incident:read", "incident:report", "incident:manage", "incident:close", "chat:use"],
|
||||
name: "Mandantenadministrator",
|
||||
permissions: PERMISSIONS,
|
||||
},
|
||||
pm: {
|
||||
name: "Projekt-/Aufgabensteuerung (PM)",
|
||||
permissions: ["task:read_all", "task:write", "measure:read", "report:read", "chat:use"],
|
||||
backoffice: {
|
||||
name: "Backoffice",
|
||||
permissions: PERMISSIONS.filter(
|
||||
(p) => !ADMIN_ONLY.includes(p) && !p.startsWith("field:") && p !== "emergency:create",
|
||||
),
|
||||
},
|
||||
isb: {
|
||||
name: "ISB / CISO",
|
||||
"team-lead": {
|
||||
name: "Teamleiter",
|
||||
permissions: [
|
||||
"user:read",
|
||||
"asset:read",
|
||||
"asset:write",
|
||||
"bia:read",
|
||||
"bia:write",
|
||||
"risk:read",
|
||||
"risk:write",
|
||||
"risk:accept",
|
||||
"soa:read",
|
||||
"soa:write",
|
||||
"measure:read",
|
||||
"measure:write",
|
||||
"policy:read",
|
||||
"policy:write",
|
||||
"policy:approve",
|
||||
"incident:read",
|
||||
"incident:report",
|
||||
"incident:manage",
|
||||
"incident:close",
|
||||
"audit:read",
|
||||
"supplier:read",
|
||||
"supplier:write",
|
||||
"evidence:read",
|
||||
"evidence:write",
|
||||
"customer:read",
|
||||
"site:read",
|
||||
"team:read",
|
||||
"work_order:read_team",
|
||||
"field:execute",
|
||||
"field:correct_time",
|
||||
"report:read",
|
||||
"review:manage",
|
||||
"onboarding:use",
|
||||
"validate_objects",
|
||||
"task:read_all",
|
||||
"task:write",
|
||||
"chat:use",
|
||||
"report:write",
|
||||
"report:approve_team",
|
||||
"emergency:create",
|
||||
"document:read",
|
||||
"notification:read",
|
||||
"lotse:use",
|
||||
],
|
||||
},
|
||||
auditor: {
|
||||
name: "Auditor",
|
||||
technician: {
|
||||
name: "Monteur",
|
||||
permissions: [
|
||||
"asset:read",
|
||||
"bia:read",
|
||||
"risk:read",
|
||||
"soa:read",
|
||||
"measure:read",
|
||||
"policy:read",
|
||||
"incident:read",
|
||||
"audit:read",
|
||||
"audit:conduct",
|
||||
"supplier:read",
|
||||
"evidence:read",
|
||||
"customer:read",
|
||||
"site:read",
|
||||
"team:read",
|
||||
"work_order:read_team",
|
||||
"field:execute",
|
||||
"report:read",
|
||||
"report:write",
|
||||
"emergency:create",
|
||||
"document:read",
|
||||
"notification:read",
|
||||
"lotse:use",
|
||||
],
|
||||
},
|
||||
owner: {
|
||||
name: "Asset-/Risk-Owner",
|
||||
permissions: [
|
||||
"asset:read",
|
||||
"asset:write",
|
||||
"bia:read",
|
||||
"risk:read",
|
||||
"risk:write",
|
||||
"measure:read",
|
||||
"measure:write",
|
||||
"policy:read",
|
||||
"incident:read",
|
||||
"incident:report",
|
||||
"evidence:read",
|
||||
"evidence:write",
|
||||
"task:write",
|
||||
"chat:use",
|
||||
],
|
||||
},
|
||||
user: {
|
||||
name: "Mitarbeiter",
|
||||
permissions: ["policy:read", "incident:read", "incident:report", "measure:read", "chat:use"],
|
||||
},
|
||||
external_validator: {
|
||||
name: "Externer Validator",
|
||||
permissions: ["onboarding:use", "validate_objects", "policy:read"],
|
||||
},
|
||||
};
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
@@ -179,3 +135,11 @@ export function hasPermission(
|
||||
): boolean {
|
||||
return session?.user?.permissions?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
/** true, wenn die Session mindestens eine der Permissions hat. */
|
||||
export function hasAnyPermission(
|
||||
session: { user?: { permissions?: string[] } } | null,
|
||||
permissions: readonly Permission[]
|
||||
): boolean {
|
||||
return permissions.some((p) => hasPermission(session, p));
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { TenantDb } from "./db";
|
||||
|
||||
/**
|
||||
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
|
||||
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
|
||||
* Verknüpfung trägt eine erwartete Minderung (0,00–4,00) je Dimension —
|
||||
* bewusst dezimal: oft senkt erst die Summe mehrerer Maßnahmen eine
|
||||
* Dimension um einen vollen Punkt. Die Minderungen werden summiert und
|
||||
* von der Brutto-Bewertung abgezogen (Untergrenze 1). Ohne Maßnahmen
|
||||
* gibt es kein Rest-Risiko (null).
|
||||
*/
|
||||
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
|
||||
const risk = await db.risk.findUnique({
|
||||
where: { id: riskId },
|
||||
include: { riskMeasures: { select: { reductionLikelihood: true, reductionImpact: true } } },
|
||||
});
|
||||
if (!risk) return;
|
||||
|
||||
if (risk.riskMeasures.length === 0) {
|
||||
await db.risk.update({
|
||||
where: { id: riskId },
|
||||
data: { residualLikelihood: null, residualImpact: null, residualScore: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
|
||||
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
|
||||
const residualLikelihood = round2(Math.max(1, risk.likelihood - reductionL));
|
||||
const residualImpact = round2(Math.max(1, risk.impact - reductionI));
|
||||
|
||||
await db.risk.update({
|
||||
where: { id: riskId },
|
||||
data: {
|
||||
residualLikelihood,
|
||||
residualImpact,
|
||||
residualScore: round2(residualLikelihood * residualImpact),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { dbForTenant } from "@/server/db";
|
||||
import type { FtRoleInput } from "@/lib/ft-rules";
|
||||
|
||||
/**
|
||||
* Rollen-Kontext (Story A4). Liest die zentralen Rollen-Variablen (`ROLE_*`, Gruppe
|
||||
* „Rollen") und die relevanten Fragebogen-Fakten und baut daraus den Prüf-Kontext für
|
||||
* die Funktionstrennung sowie die Werte für die ISB-Bestellung. Read-only: die `ROLE_*`
|
||||
* werden ausschließlich in `/settings` gepflegt (A2-1-Governance).
|
||||
*/
|
||||
type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
|
||||
export const ROLE_KEYS = ["ROLE_MANAGEMENT", "ROLE_ISB", "ROLE_IT_LEAD", "ROLE_HR_LEAD", "ROLE_DPO"] as const;
|
||||
|
||||
const isEmpty = (v?: string | null) => {
|
||||
const s = (v ?? "").trim().toLowerCase();
|
||||
return s === "" || s === "n.n." || s === "—" || s === "-" || s === "tbd";
|
||||
};
|
||||
|
||||
export interface RoleContext extends FtRoleInput {
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
export async function loadRoleContext(db: TenantDb): Promise<RoleContext> {
|
||||
const vars = await db.policyVariable.findMany({
|
||||
where: { key: { in: [...ROLE_KEYS, "ORG_NAME"] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const vmap = new Map(vars.map((v) => [v.key, v.value]));
|
||||
|
||||
const facts = await db.wizardFact.findMany({
|
||||
where: { key: { in: ["Q-ROLE-02_ISB_BENANNT", "Q-ROLE_ISB_IST_IT", "Q-ROLE-ISB-MODUS", "Q-ORG-05"] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const fmap = new Map(facts.map((f) => [f.key, f.value as unknown]));
|
||||
|
||||
const roles = {
|
||||
ISB: vmap.get("ROLE_ISB") ?? null,
|
||||
MANAGEMENT: vmap.get("ROLE_MANAGEMENT") ?? null,
|
||||
IT_LEAD: vmap.get("ROLE_IT_LEAD") ?? null,
|
||||
HR_LEAD: vmap.get("ROLE_HR_LEAD") ?? null,
|
||||
DPO: vmap.get("ROLE_DPO") ?? null,
|
||||
};
|
||||
|
||||
const isbNamedFact = fmap.get("Q-ROLE-02_ISB_BENANNT");
|
||||
const isbNamed = isbNamedFact === undefined ? !isEmpty(roles.ISB) : isbNamedFact === true;
|
||||
|
||||
return {
|
||||
roles,
|
||||
isbNamed,
|
||||
isbIsIt: fmap.get("Q-ROLE_ISB_IST_IT") === true,
|
||||
isbInternal: fmap.get("Q-ROLE-ISB-MODUS") === "intern",
|
||||
personalData: fmap.get("Q-ORG-05") === true,
|
||||
orgName: vmap.get("ORG_NAME") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Team / Funktionszuordnung (Ebene 1 „Fundament", M1). Lädt die echten
|
||||
* Funktion→User-Zuweisungen (`ProjectFunctionAssignment`) und reichert sie mit
|
||||
* Name/E-Mail des zugewiesenen Accounts an. Anders als `loadRoleContext` (read-only
|
||||
* Soll-Modell) ist dies die im Wizard **pflegbare** Ist-Besetzung.
|
||||
*/
|
||||
export interface FunctionAssignmentView {
|
||||
id: string;
|
||||
functionKey: string;
|
||||
userId: string | null;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
invitedEmail: string | null;
|
||||
domain: string | null;
|
||||
}
|
||||
|
||||
export async function loadFunctionAssignments(db: TenantDb): Promise<FunctionAssignmentView[]> {
|
||||
const rows = await db.projectFunctionAssignment.findMany({ orderBy: { createdAt: "asc" } });
|
||||
const userIds = [...new Set(rows.map((r) => r.userId).filter((v): v is string => Boolean(v)))];
|
||||
const users = userIds.length
|
||||
? await db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } })
|
||||
: [];
|
||||
const umap = new Map(users.map((u) => [u.id, u]));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
functionKey: r.functionKey,
|
||||
userId: r.userId,
|
||||
userName: r.userId ? (umap.get(r.userId)?.name ?? null) : null,
|
||||
userEmail: r.userId ? (umap.get(r.userId)?.email ?? null) : null,
|
||||
invitedEmail: r.invitedEmail,
|
||||
domain: r.domain,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Aktive Auswahlliste der Mandanten-Accounts für die Funktionszuweisung. */
|
||||
export async function listAssignableUsers(db: TenantDb): Promise<{ id: string; name: string; email: string }[]> {
|
||||
return db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true, email: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
|
||||
import { activeRequirements, type Pruefziel, type ScopeInput } from "@/lib/scope-filter";
|
||||
import {
|
||||
specForControl,
|
||||
suggestMaturity,
|
||||
targetMaturity,
|
||||
openPoints,
|
||||
type ControlEvidence,
|
||||
type EvidenceStatus,
|
||||
type MaturitySuggestion,
|
||||
type OpenPoint,
|
||||
type C5ControlSpec,
|
||||
type ControlSpec,
|
||||
} from "@/lib/maturity";
|
||||
import { compareControl } from "@/lib/control-titles";
|
||||
import { applyProtection, buildContext } from "@/lib/policy-render";
|
||||
import type { ImplementationHint } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Server-Kontext für den Control-Assessment-Schritt (Story A7). Leitet den
|
||||
* Assessment-Scope (A2: Level + Flags + Prüfziele → aktive Anforderungen/Controls) sowie
|
||||
* den Belegstand je Control (aus Richtlinien-/Verfahrensdokumenten, Inventar, Risikoregister)
|
||||
* ab und wendet die reine Reifegrad-Engine (src/lib/maturity.ts) an.
|
||||
*/
|
||||
|
||||
/** Assessment-Scope wie im Scoping-Schritt (A2-1/A2-2) hergeleitet. */
|
||||
export async function loadScopeInput(db: TenantDb, tenantId: string): Promise<{ level: "AL2" | "AL3"; input: ScopeInput }> {
|
||||
const level = await getAssessmentLevel(db);
|
||||
const scope = await db.wizardScope.findFirst();
|
||||
const pruefziele = (scope?.pruefziele as Pruefziel[]) ?? ["informationssicherheit"];
|
||||
const should = await db.wizardFact.findUnique({ where: { tenantId_key: { tenantId, key: "Q-GOV-05" } } });
|
||||
const includeShould = should ? should.value === true : true;
|
||||
return { level, input: { pruefziele, flags: { ...protectionFlags(level), FLAG_INCLUDE_SHOULD: includeShould } } };
|
||||
}
|
||||
|
||||
/** Im Scope liegende Controls (distinct, sortiert). */
|
||||
export function controlsInScope(input: ScopeInput): string[] {
|
||||
return [...new Set(activeRequirements(input).map((r) => r.control))].sort(compareControl);
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<EvidenceStatus, number> = { fehlt: 0, verknuepft: 1, validiert: 2 };
|
||||
|
||||
/**
|
||||
* Belegstatus-Auflöser: liest einmalig alle Richtlinien-/Verfahrensdokumente (Code→Status)
|
||||
* sowie die Inventar-/Risikozahlen und liefert je Control-Spec den abgeleiteten Ist-Belegstand.
|
||||
* Der operative Wirksamkeitsnachweis (N) gilt als erbracht, wenn die Umsetzungshinweise des
|
||||
* Controls dokumentiert erledigt sind (`completeControls`, #10) — dann kann Grad 3 erreicht
|
||||
* werden; sonst false (Deckelung auf Grad 2, Gap bei Zielgrad 3).
|
||||
*/
|
||||
export async function loadEvidenceResolver(db: TenantDb, completeControls: Set<string> = new Set()): Promise<(spec: ControlSpec) => ControlEvidence> {
|
||||
const [docs, assetCount, riskCount] = await Promise.all([
|
||||
db.policyDocument.findMany({ select: { code: true, status: true } }),
|
||||
db.asset.count(),
|
||||
db.risk.count(),
|
||||
]);
|
||||
const statusByCode = new Map<string, EvidenceStatus>();
|
||||
for (const d of docs) {
|
||||
// FREIGEGEBEN = validiert; ENTWURF/IN_FREIGABE = verknüpft (unvalidiert); ARCHIVIERT = fehlt.
|
||||
const s: EvidenceStatus = d.status === "FREIGEGEBEN" ? "validiert" : d.status === "ARCHIVIERT" ? "fehlt" : "verknuepft";
|
||||
statusByCode.set(d.code, s);
|
||||
}
|
||||
|
||||
const aggregate = (codes: string[]): EvidenceStatus => {
|
||||
if (codes.length === 0) return "fehlt";
|
||||
// „schwächster" Beleg gewinnt (alle geforderten Belege müssen vorliegen).
|
||||
return codes.reduce<EvidenceStatus>((acc, c) => {
|
||||
const cur = statusByCode.get(c) ?? "fehlt";
|
||||
return STATUS_RANK[cur] < STATUS_RANK[acc] ? cur : acc;
|
||||
}, "validiert");
|
||||
};
|
||||
|
||||
// Belegauflösung ist framework-neutral (Feindesign §1: „eine Belegbasis, zwei
|
||||
// Bewertungen") — sie kennt bewusst nur `ControlSpec`, kein Framework.
|
||||
return (spec: ControlSpec): ControlEvidence => {
|
||||
const policy = aggregate(spec.policy);
|
||||
const verfahren = aggregate(spec.verfahren);
|
||||
return {
|
||||
policy,
|
||||
verfahren,
|
||||
// Controls ohne dediziertes Verfahren: die (validierte) Richtlinie trägt die Umsetzungsregelung.
|
||||
implementationRule: spec.verfahren.length === 0 ? policy === "validiert" : undefined,
|
||||
assetLinked: assetCount > 0,
|
||||
riskLinked: riskCount > 0,
|
||||
operationalProof: completeControls.has(spec.control),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Ein Umsetzungshinweis (global) mit dem mandantenspezifischen Bearbeitungsstatus (#10). */
|
||||
export interface HintWithStatus {
|
||||
hint: ImplementationHint;
|
||||
status: string; // offen | in_umsetzung | erledigt
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface ImplementationContext {
|
||||
/** Relevante Umsetzungshinweise je In-Scope-Control (aktiv + passender AL-Filter). */
|
||||
hintsByControl: Map<string, HintWithStatus[]>;
|
||||
/** Controls, deren relevante Hinweise alle „erledigt" sind → operativer Nachweis erbracht. */
|
||||
completeControls: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Umsetzungshinweise (ImplementationHint, global) im Scope des Mandanten
|
||||
* (aktive Anforderungen wie im Coverage-Filter + passender AL-Filter) und den
|
||||
* mandantenspezifischen Bearbeitungsstatus (ControlImplementation). Grundlage für die
|
||||
* Anzeige im Control-Schritt und für die Reifegrad-Kopplung (operativer Nachweis).
|
||||
*/
|
||||
export async function loadImplementationContext(db: TenantDb, level: "AL2" | "AL3"): Promise<ImplementationContext> {
|
||||
const [hints, reqs, variables, impls] = await Promise.all([
|
||||
db.implementationHint.findMany({ orderBy: { reqId: "asc" } }),
|
||||
db.policyRequirement.findMany({ where: { archivedAt: null }, select: { reqId: true, condition: true } }),
|
||||
db.policyVariable.findMany(),
|
||||
db.controlImplementation.findMany({ select: { reqId: true, status: true, note: true } }),
|
||||
]);
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const activeReqIds = new Set(reqs.filter((r) => !r.condition || ctx[r.condition] === true).map((r) => r.reqId));
|
||||
const statusByReq = new Map(impls.map((i) => [i.reqId, i]));
|
||||
|
||||
const hintsByControl = new Map<string, HintWithStatus[]>();
|
||||
for (const h of hints) {
|
||||
if (!activeReqIds.has(h.reqId) || !h.alFilter.includes(level)) continue;
|
||||
const st = statusByReq.get(h.reqId);
|
||||
const list = hintsByControl.get(h.control) ?? [];
|
||||
list.push({ hint: h, status: st?.status ?? "offen", note: st?.note ?? null });
|
||||
hintsByControl.set(h.control, list);
|
||||
}
|
||||
|
||||
const completeControls = new Set<string>();
|
||||
for (const [control, list] of hintsByControl) {
|
||||
if (list.length > 0 && list.every((h) => h.status === "erledigt")) completeControls.add(control);
|
||||
}
|
||||
return { hintsByControl, completeControls };
|
||||
}
|
||||
|
||||
export interface ControlRow {
|
||||
control: string;
|
||||
spec: C5ControlSpec;
|
||||
evidence: ControlEvidence;
|
||||
suggestion: MaturitySuggestion;
|
||||
target: 2 | 3;
|
||||
gaps: OpenPoint[];
|
||||
/** Vom Bearbeiter bestätigter/überschriebener Reifegrad (falls vorhanden). */
|
||||
confirmed: number | null;
|
||||
}
|
||||
|
||||
/** Baut die vollständige Bewertungsliste je In-Scope-Control für Anzeige und Aufgaben. */
|
||||
export async function buildControlRows(db: TenantDb, tenantId: string): Promise<{ rows: ControlRow[]; level: "AL2" | "AL3"; implementation: ImplementationContext }> {
|
||||
const { level, input } = await loadScopeInput(db, tenantId);
|
||||
const implementation = await loadImplementationContext(db, level);
|
||||
const [resolve, confirmations] = await Promise.all([
|
||||
loadEvidenceResolver(db, implementation.completeControls),
|
||||
db.controlAssessment.findMany({ select: { control: true, confirmedValue: true } }),
|
||||
]);
|
||||
const confirmedBy = new Map(confirmations.map((c) => [c.control, c.confirmedValue]));
|
||||
|
||||
const rows = controlsInScope(input).map<ControlRow>((control) => {
|
||||
const spec = specForControl(control);
|
||||
const evidence = resolve(spec);
|
||||
const suggestion = suggestMaturity(spec, evidence);
|
||||
const target = targetMaturity({ level, flags: input.flags });
|
||||
return {
|
||||
control,
|
||||
spec,
|
||||
evidence,
|
||||
suggestion,
|
||||
target,
|
||||
gaps: openPoints(spec, evidence, suggestion, target),
|
||||
confirmed: confirmedBy.get(control) ?? null,
|
||||
};
|
||||
});
|
||||
return { rows, level, implementation };
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// AP3 — SoA-Serverkontext (Statement of Applicability): ISO-Controls aus dem
|
||||
// Vorlagenpaket laden, die SoA je Mandant idempotent vorbefüllen und die Export-Zeilen
|
||||
// aufbereiten. Server-only (Datei-/DB-Zugriff). Getrennt von soa-context.ts (das ist
|
||||
// das VDA-ISA-Reifegrad-Assessment, ein anderes Thema).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import type { SoaExportInput } from "@/lib/soa";
|
||||
import { compareSoaControls } from "@/lib/soa";
|
||||
|
||||
export interface IsoSoaControl {
|
||||
control: string;
|
||||
title: string;
|
||||
/** Default-Anwendbarkeit aus dem Mapping (vor Bedingungsprüfung). */
|
||||
applicableDefault: boolean;
|
||||
/** Optionaler FLAG_*-Schalter, der die Default-Anwendbarkeit steuert. */
|
||||
condition: string | null;
|
||||
policyCode: string | null;
|
||||
}
|
||||
|
||||
let cached: IsoSoaControl[] | null = null;
|
||||
|
||||
/** Die 93 Annex-A-Controls (soa_relevant) aus mapping-iso.json (memoisiert). */
|
||||
export function loadIsoSoaControls(): IsoSoaControl[] {
|
||||
if (cached) return cached;
|
||||
const path = join(process.cwd(), "seed", "isms-vorlagenpaket-v2", "mapping-iso.json");
|
||||
const mapping = JSON.parse(readFileSync(path, "utf-8")) as {
|
||||
anforderungen: Array<{
|
||||
control: string; title?: string; policy?: string;
|
||||
soa_relevant?: boolean; applicable?: boolean; condition?: string | null;
|
||||
}>;
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const out: IsoSoaControl[] = [];
|
||||
for (const a of mapping.anforderungen) {
|
||||
if (!a.soa_relevant || seen.has(a.control)) continue;
|
||||
seen.add(a.control);
|
||||
out.push({
|
||||
control: a.control,
|
||||
title: a.title ?? a.control,
|
||||
applicableDefault: a.applicable ?? true,
|
||||
condition: a.condition ?? null,
|
||||
policyCode: a.policy ?? null,
|
||||
});
|
||||
}
|
||||
out.sort((x, y) => compareSoaControls(x.control, y.control));
|
||||
cached = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Aktuelle Werte der bedingungssteuernden Flags des Mandanten (Default: nicht gesetzt = false). */
|
||||
async function tenantFlagValues(db: TenantDb, keys: string[]): Promise<Map<string, boolean>> {
|
||||
if (keys.length === 0) return new Map();
|
||||
const rows = await db.policyVariable.findMany({ where: { key: { in: keys } }, select: { key: true, value: true } });
|
||||
return new Map(rows.map((r) => [r.key, r.value === "true"]));
|
||||
}
|
||||
|
||||
/**
|
||||
* SoA für einen Mandanten idempotent sicherstellen (Framework ISO_27001): fehlende
|
||||
* Controls anlegen, vorhandene NIE überschreiben (Nutzerpflege bleibt). Die
|
||||
* Default-Anwendbarkeit ergibt sich aus dem Mapping UND — falls eine `condition`
|
||||
* gesetzt ist — dem entsprechenden Flag des Mandanten (z. B. FLAG_DEV_INHOUSE aus →
|
||||
* Control standardmäßig nicht anwendbar). Gibt die Zahl neu angelegter Zeilen zurück.
|
||||
*/
|
||||
export async function ensureSoaEntries(db: TenantDb, tenantId: string): Promise<number> {
|
||||
const controls = loadIsoSoaControls();
|
||||
const existing = new Set(
|
||||
(await db.soaEntry.findMany({ where: { tenantId, framework: "ISO_27001" }, select: { control: true } })).map((e) => e.control),
|
||||
);
|
||||
const missing = controls.filter((c) => !existing.has(c.control));
|
||||
if (missing.length === 0) return 0;
|
||||
|
||||
const conditionKeys = [...new Set(missing.map((c) => c.condition).filter((x): x is string => !!x))];
|
||||
const flags = await tenantFlagValues(db, conditionKeys);
|
||||
|
||||
await db.soaEntry.createMany({
|
||||
data: missing.map((c) => ({
|
||||
tenantId,
|
||||
framework: "ISO_27001" as const,
|
||||
control: c.control,
|
||||
title: c.title,
|
||||
applicable: c.applicableDefault && (c.condition ? flags.get(c.condition) === true : true),
|
||||
justification: "",
|
||||
implementationStatus: "geplant",
|
||||
policyCode: c.policyCode,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
return missing.length;
|
||||
}
|
||||
|
||||
/** Anzeige-/Export-Zeilen der SoA (IDs zu Namen aufgelöst, sortiert). */
|
||||
export async function buildSoaExportInputs(db: TenantDb, tenantId: string): Promise<SoaExportInput[]> {
|
||||
const entries = await db.soaEntry.findMany({ where: { tenantId, framework: "ISO_27001" } });
|
||||
const ownerIds = [...new Set(entries.map((e) => e.ownerId).filter((x): x is string => !!x))];
|
||||
const owners = ownerIds.length
|
||||
? new Map((await db.user.findMany({ where: { id: { in: ownerIds } }, select: { id: true, name: true } })).map((u) => [u.id, u.name]))
|
||||
: new Map<string, string>();
|
||||
|
||||
return entries
|
||||
.map((e) => ({
|
||||
control: e.control,
|
||||
title: e.title,
|
||||
applicable: e.applicable,
|
||||
justification: e.justification,
|
||||
source: e.source,
|
||||
implementationStatus: e.implementationStatus,
|
||||
policyCode: e.policyCode,
|
||||
ownerName: e.ownerId ? owners.get(e.ownerId) ?? null : null,
|
||||
evidenceLabel: e.evidenceId,
|
||||
}))
|
||||
.sort((a, b) => compareSoaControls(a.control, b.control));
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
// Cockpit (M3, 2.2): Auto-Completion-Engine — GEDECKELT + Wiedervorlage.
|
||||
//
|
||||
// Fachprüfungs-Grundsatz „Existenz ≠ Wirksamkeit":
|
||||
// Ein Statuswechsel eines Objekts (Policy/Risk/Control/Asset) schließt die
|
||||
// verknüpften Aufgaben AUTOMATISCH nur bis DONE (= dokumentiert/umgesetzt).
|
||||
// Der Reifegrad/„audit-ready" (Reifegrad 3) wird NICHT automatisch gesetzt — er
|
||||
// verlangt einen verknüpften `Evidence`-Nachweis PLUS Vier-Augen-Freigabe und
|
||||
// bleibt ein separater, manueller Schritt.
|
||||
//
|
||||
// Zusätzlich: Wiedervorlage — eine Aufgabe mit `recurrence` erzeugt beim Erreichen
|
||||
// von DONE automatisch eine Folge-Aufgabe mit neuem dueDate/effectiveUntil.
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
/** Aufgaben, die durch Auto-Completion geschlossen werden (offen/aktiv). */
|
||||
const OPEN_STATES = ["PROPOSED", "OPEN", "IN_PROGRESS"];
|
||||
|
||||
type SyncEntity = "policy_document" | "risk" | "control" | "asset";
|
||||
|
||||
interface SyncCtx {
|
||||
db: TenantDb;
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Statuswechsel eines Objekts die verknüpften Aufgaben bis DONE schließen
|
||||
* (gedeckelt). Wird von den jeweiligen Status-Aktionen aufgerufen:
|
||||
* - policy_document: PolicyDocument.status = FREIGEGEBEN
|
||||
* - risk: Risk.status = ACCEPTED | CLOSED
|
||||
* - control: ControlImplementation.status = erledigt
|
||||
* - asset: C/I/A + Owner gesetzt
|
||||
*
|
||||
* `reached` beschreibt den erreichten Zielzustand (nur dann wird geschlossen).
|
||||
* `matchKey` ist die Referenz, über die verknüpfte Aufgaben gefunden werden
|
||||
* (z. B. Policy-Code, Risiko-Ref, Control-ID).
|
||||
*/
|
||||
export async function syncTaskFromObject(
|
||||
ctx: SyncCtx,
|
||||
entity: SyncEntity,
|
||||
opts: { entityId?: string; matchKey?: string | string[]; reached: boolean },
|
||||
): Promise<number> {
|
||||
if (!opts.reached) return 0; // Deckel: nur bei erreichtem dokumentiertem Zustand
|
||||
|
||||
const where = tasksForEntity(entity, opts);
|
||||
if (!where) return 0;
|
||||
|
||||
const open = await ctx.db.task.findMany({
|
||||
where: { ...where, status: { in: OPEN_STATES } },
|
||||
select: { id: true, status: true, recurrence: true, dueDate: true, effectiveUntil: true, type: true, title: true, domain: true, orderIdx: true, priority: true, links: true, entityType: true, entityId: true, entityRef: true, assigneeId: true },
|
||||
});
|
||||
if (!open.length) return 0;
|
||||
|
||||
let closed = 0;
|
||||
for (const t of open) {
|
||||
await ctx.db.task.update({
|
||||
where: { id: t.id },
|
||||
data: {
|
||||
status: "DONE",
|
||||
resolvedById: ctx.actorId,
|
||||
resolvedAt: new Date(),
|
||||
comments: {
|
||||
create: {
|
||||
tenantId: ctx.tenantId,
|
||||
authorId: ctx.actorId,
|
||||
kind: "submit",
|
||||
// „Existenz ≠ Wirksamkeit" ausdrücklich dokumentieren.
|
||||
body: `Automatisch auf ERLEDIGT (dokumentiert/umgesetzt) durch Statuswechsel ${entity}. Reifegrad/„audit-ready" separat mit Nachweis + Vier-Augen bestätigen.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
closed++;
|
||||
await spawnRecurrence(ctx, t);
|
||||
}
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.actorId,
|
||||
action: "update",
|
||||
entity: "task_autocomplete",
|
||||
after: { entity, matchKey: opts.matchKey ?? null, entityId: opts.entityId ?? null, closed },
|
||||
});
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** Prisma-`where` zum Finden der verknüpften Aufgaben je Objekttyp. */
|
||||
function tasksForEntity(
|
||||
entity: SyncEntity,
|
||||
opts: { entityId?: string; matchKey?: string | string[] },
|
||||
): Prisma.TaskWhereInput | null {
|
||||
const keys = (Array.isArray(opts.matchKey) ? opts.matchKey : opts.matchKey ? [opts.matchKey] : []).filter(Boolean);
|
||||
const linkMatch = (field: "document" | "risk" | "control" | "asset"): Prisma.TaskWhereInput | null =>
|
||||
keys.length ? { OR: keys.map((k) => ({ links: { path: [field], equals: k } })) } : null;
|
||||
|
||||
switch (entity) {
|
||||
case "policy_document": {
|
||||
const or: Prisma.TaskWhereInput[] = [];
|
||||
if (opts.entityId) or.push({ entityType: "policy_document", entityId: opts.entityId });
|
||||
const byLink = linkMatch("document");
|
||||
if (byLink) or.push(byLink);
|
||||
return or.length ? { OR: or } : null;
|
||||
}
|
||||
case "risk":
|
||||
return linkMatch("risk");
|
||||
case "control":
|
||||
return linkMatch("control");
|
||||
case "asset":
|
||||
return linkMatch("asset");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RecurrableTask {
|
||||
id: string;
|
||||
recurrence: string | null;
|
||||
dueDate: Date | null;
|
||||
effectiveUntil: Date | null;
|
||||
type: string;
|
||||
title: string;
|
||||
domain: import("@prisma/client").Domain | null;
|
||||
orderIdx: number;
|
||||
priority: string;
|
||||
links: Prisma.JsonValue;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
entityRef: string | null;
|
||||
assigneeId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiedervorlage (2.2): Bei DONE einer Aufgabe mit `recurrence` eine Folge-Aufgabe
|
||||
* mit vorgerücktem dueDate/effectiveUntil erzeugen. Öffentlich, damit auch manuelle
|
||||
* DONE-Übergänge (Kanban/Bearbeiten/Freigabe) die Serie fortschreiben.
|
||||
*/
|
||||
export async function spawnRecurrence(ctx: SyncCtx, task: RecurrableTask): Promise<string | null> {
|
||||
if (!task.recurrence) return null;
|
||||
const base = task.dueDate ?? new Date();
|
||||
const nextDue = addDuration(base, task.recurrence);
|
||||
if (!nextDue) return null;
|
||||
const nextEffective = task.effectiveUntil ? addDuration(task.effectiveUntil, task.recurrence) : null;
|
||||
|
||||
const created = await ctx.db.task.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
status: "OPEN",
|
||||
priority: task.priority,
|
||||
domain: task.domain,
|
||||
orderIdx: task.orderIdx,
|
||||
recurrence: task.recurrence,
|
||||
dueDate: nextDue,
|
||||
effectiveUntil: nextEffective,
|
||||
assigneeId: task.assigneeId,
|
||||
createdById: ctx.actorId,
|
||||
origin: `recurrence:${task.id}`,
|
||||
entityType: task.entityType,
|
||||
entityId: task.entityId,
|
||||
entityRef: task.entityRef,
|
||||
links: (task.links ?? undefined) as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimaler ISO-8601-Dauer-Parser (P[n]Y[n]M[n]W[n]D). Ausreichend für die
|
||||
* gängigen Wiedervorlage-Intervalle (P1Y, P6M, P1W, P30D). RRULE wird (noch) nicht
|
||||
* ausgewertet — dann bleibt die Serie ohne Folge-Task (kein Fehler).
|
||||
*/
|
||||
export function addDuration(from: Date, iso: string): Date | null {
|
||||
const m = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/.exec(iso.trim());
|
||||
if (!m || !m.slice(1).some(Boolean)) return null;
|
||||
const [, y, mo, w, d] = m;
|
||||
const next = new Date(from);
|
||||
if (y) next.setFullYear(next.getFullYear() + Number(y));
|
||||
if (mo) next.setMonth(next.getMonth() + Number(mo));
|
||||
if (w) next.setDate(next.getDate() + Number(w) * 7);
|
||||
if (d) next.setDate(next.getDate() + Number(d));
|
||||
return next;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Cockpit (M3, 2.3): Bereichs-Sichtbarkeit & RACI für das Aufgaben-Board.
|
||||
//
|
||||
// Regeln:
|
||||
// - `task:read_all` (PM/ISB) → alles.
|
||||
// - sonst: eigene (assignee) + erstellte + zugewiesene Mitwirkung (TaskParticipant)
|
||||
// + Pool (nicht zugewiesen, offen) + Aufgaben im eigenen Bereich.
|
||||
// - Der eigene Bereich kommt aus der Funktionszuordnung (`ProjectFunctionAssignment`,
|
||||
// M1). Solange M1 nicht gemerged ist, wird `memberDomains` leer übergeben; die
|
||||
// Sichtbarkeit stützt sich dann auf eigene/zugewiesene/Pool-Aufgaben. Sobald M1
|
||||
// landet, genügt es, hier die Bereiche des Users hineinzureichen.
|
||||
|
||||
import type { Prisma, Domain } from "@prisma/client";
|
||||
|
||||
export interface TaskVisibilityInput {
|
||||
userId: string;
|
||||
/** true bei `task:read_all` (PM/ISB). */
|
||||
canSeeAll: boolean;
|
||||
/** Bereiche des Users aus der Funktionszuordnung (M1). Default: leer. */
|
||||
memberDomains?: Domain[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma-`where` für die Aufgaben, die ein User sehen darf. Kombinierbar mit
|
||||
* zusätzlichen Filtern (Typ/Bereich/Person) über ein umschließendes AND.
|
||||
*/
|
||||
export function taskVisibilityWhere(input: TaskVisibilityInput): Prisma.TaskWhereInput {
|
||||
if (input.canSeeAll) return {};
|
||||
const or: Prisma.TaskWhereInput[] = [
|
||||
{ assigneeId: input.userId },
|
||||
{ createdById: input.userId },
|
||||
{ participants: { some: { userId: input.userId } } },
|
||||
// Pool: nicht zugewiesene, offene Aufgaben/Vorschläge — für alle sichtbar.
|
||||
{ assigneeId: null, status: { in: ["PROPOSED", "OPEN"] } },
|
||||
];
|
||||
if (input.memberDomains && input.memberDomains.length) {
|
||||
or.push({ domain: { in: input.memberDomains } });
|
||||
}
|
||||
return { OR: or };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback-„meine Bereiche": solange keine Funktionszuordnung (M1) vorliegt, leiten
|
||||
* wir die Default-Bereichssicht aus den Bereichen ab, in denen der User bereits
|
||||
* beteiligt ist (eigene/erstellte/zugewiesene Aufgaben).
|
||||
*/
|
||||
export function derivedMemberDomains(
|
||||
tasks: { domain: Domain | null; assigneeId: string | null; createdById: string | null }[],
|
||||
userId: string,
|
||||
): Domain[] {
|
||||
const set = new Set<Domain>();
|
||||
for (const t of tasks) {
|
||||
if (!t.domain) continue;
|
||||
if (t.assigneeId === userId || t.createdById === userId) set.add(t.domain);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
Reference in New Issue
Block a user