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

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

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