GAP WP3.0: Generisches, editierbares Register-Datenmodell + Editor

Verwaltete Register (GAP-Report §5.2) sind nicht mehr nur benannte Dokumente,
sondern editierbare Register mit Pflichtspalten und Cross-Links.

- Schema: ManagedRegister (code, title, columns JSON, reviewCycle, responsible,
  supplierLink/assetLink) + RegisterRow (values JSON, supplierRef, assetRef).
  Migration + RLS; in TENANT_MODELS.
- import-managed.ts: 7 generische Register (REG-PROJECTS, -SENS-ROLES, -AUDIT-PLAN,
  -EXT-SERVICES, -SW-WHITELIST, -CRIT-SERVICES, -NET) mit definierten Pflichtspalten
  + Cross-Link-Flags; legt Register-Dokument (Bibliothek/Link) UND ManagedRegister-
  Definition idempotent an (RegisterRow bleibt bei Re-Import erhalten).
- actions/register.ts: addRegisterRow/updateRegisterRow/deleteRegisterRow
  (Modul policies, policy:write; Werte gegen die Pflichtspalten, optional Lieferant/Asset).
- components/generic-register.tsx: editierbare Zeilen-Tabelle + Cross-Link-Selects
  (Lieferant = Asset SUPPLIER/IT_SERVICE, Asset = alle); in /policies/[code] eingebunden.

Browser-verifiziert: REG-EXT-SERVICES rendert 7 Spalten + Lieferant/Asset-Selects;
Eintrag anlegen persistiert alle Werte. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:07:08 +02:00
co-authored by Claude Opus 4.8
parent b9784e55f4
commit bd56a478fc
8 changed files with 326 additions and 8 deletions
+45 -6
View File
@@ -100,6 +100,34 @@ const HANDBOOK: { category: string; title: string; bodyMd: string; sourceRefs: s
}, },
]; ];
/** Generische verwaltete Register (WP3.0): Code, Titel, Pflichtspalten, Cross-Links. */
const GENERIC_REGISTERS: {
code: string; title: string; description: string; orderIdx: number;
columns: { key: string; label: string }[]; supplierLink?: boolean; assetLink?: boolean;
}[] = [
{ code: "REG-PROJECTS", title: "Register Informationssicherheit in Projekten", orderIdx: 905,
description: "Projekte mit IS-Klassifizierung, Risikobewertung, Maßnahmen und ISB-Einbindung (R01 / VA-19).",
columns: [{ key: "projekt", label: "Projekt" }, { key: "klassifizierung", label: "IS-Klassifizierung" }, { key: "risiko", label: "Risikobewertung" }, { key: "massnahmen", label: "Maßnahmen/Status" }, { key: "isb", label: "ISB-Einbindung" }] },
{ code: "REG-SENS-ROLES", title: "Register sensibler Tätigkeiten", orderIdx: 906,
description: "Sensible Tätigkeitsbereiche/Rollen mit Eignungsnachweisen und Prüftiefe (R05 / VA-14).",
columns: [{ key: "rolle", label: "Rolle/Bereich" }, { key: "nachweise", label: "Geforderte Eignungsnachweise" }, { key: "prueftiefe", label: "Prüftiefe" }, { key: "verantwortlich", label: "Verantwortlich" }, { key: "review", label: "Review" }] },
{ code: "REG-AUDIT-PLAN", title: "Audit-Programm-Register", orderIdx: 907,
description: "Auditprogramm mit Zeitplan, Umfang, geprüften Controls, Prüfern und Ergebnissen (R03 / VA-15).",
columns: [{ key: "zeitplan", label: "Zeitplan" }, { key: "umfang", label: "Umfang" }, { key: "controls", label: "Geprüfte Controls" }, { key: "pruefer", label: "Prüfer" }, { key: "ergebnisse", label: "Ergebnisse" }] },
{ code: "REG-EXT-SERVICES", title: "Register externe IT-/Cloud-/KI-Dienste", orderIdx: 908,
description: "Externe/Cloud-/KI-Dienste mit Schutzbedarf, Datenlokation, Verschlüsselung, Exit und Freigabe (R13/VA-10 + R02/VA-08).",
columns: [{ key: "dienst", label: "Dienst" }, { key: "schutzbedarf", label: "Schutzbedarf" }, { key: "datenlokation", label: "Datenlokation (EU)" }, { key: "verschluesselung", label: "Verschlüsselung" }, { key: "exit", label: "Exit-Strategie" }, { key: "freigabe", label: "Freigabestatus" }, { key: "freigeber", label: "Freigeber" }], supplierLink: true, assetLink: true },
{ code: "REG-SW-WHITELIST", title: "Register Software-Whitelist", orderIdx: 909,
description: "Zugelassene Software mit Version/Patch-Stand, Quelle/Lieferant, Freigabestatus und Review (R02 1.3.4).",
columns: [{ key: "software", label: "Software" }, { key: "version", label: "Version/Patch-Stand" }, { key: "freigabe", label: "Freigabestatus" }, { key: "freigeber", label: "Freigeber" }, { key: "review", label: "Review" }], supplierLink: true, assetLink: true },
{ code: "REG-CRIT-SERVICES", title: "Register kritischer IT-Dienste", orderIdx: 910,
description: "Kritische IT-Dienste mit BIA-Einstufung, RTO/RPO, Abhängigkeiten und Wiederanlaufreihenfolge (R04 5.2.8 / VA-02).",
columns: [{ key: "dienst", label: "Kritischer IT-Dienst" }, { key: "bia", label: "BIA-Einstufung" }, { key: "rto", label: "RTO" }, { key: "rpo", label: "RPO" }, { key: "abhaengigkeiten", label: "Abhängigkeiten" }, { key: "wiederanlauf", label: "Wiederanlaufreihenfolge" }], assetLink: true },
{ code: "REG-NET", title: "Netz-/Netzdienste-Register", orderIdx: 911,
description: "Netzplan/Segmentierung, Netzdienste, Zonen und Review-Turnus (R09 5.1.2 / R10 5.2.7 / VA-07).",
columns: [{ key: "bezeichnung", label: "Netzplan/Segment" }, { key: "netzdienst", label: "Netzdienst" }, { key: "zone", label: "Zone" }, { key: "review", label: "Review" }] },
];
export async function importManaged(prisma: PrismaClient, tenantId: string) { export async function importManaged(prisma: PrismaClient, tenantId: string) {
// Idempotent: alte Datensätze pro Mandant entfernen // Idempotent: alte Datensätze pro Mandant entfernen
await prisma.handlingRule.deleteMany({ where: { tenantId } }); await prisma.handlingRule.deleteMany({ where: { tenantId } });
@@ -117,12 +145,6 @@ export async function importManaged(prisma: PrismaClient, tenantId: string) {
{ code: "RISKMATRIX", type: "REGISTER" as const, title: "Risiko-Bewertungsmatrix", orderIdx: 903, intro: "Zentrale Bewertungsmatrix Schadensausmaß × Eintrittswahrscheinlichkeit (R03 / VA-09), Defaults nach FB-80-04." }, { code: "RISKMATRIX", type: "REGISTER" as const, title: "Risiko-Bewertungsmatrix", orderIdx: 903, intro: "Zentrale Bewertungsmatrix Schadensausmaß × Eintrittswahrscheinlichkeit (R03 / VA-09), Defaults nach FB-80-04." },
{ code: "CLASSIFICATION", type: "REGISTER" as const, title: "Klassifizierung & Handhabungsmatrix", orderIdx: 904, intro: "Schutzklassen und Handhabungsregeln (R02 / VA-08), Defaults nach AA-80-20." }, { code: "CLASSIFICATION", type: "REGISTER" as const, title: "Klassifizierung & Handhabungsmatrix", orderIdx: 904, intro: "Schutzklassen und Handhabungsregeln (R02 / VA-08), Defaults nach AA-80-20." },
{ code: "HANDBUCH", type: "HANDBUCH" as const, title: "Anwender-Handbuch Informationssicherheit", orderIdx: 950, intro: "Kompakte Zusammenfassung der wichtigsten Regeln für Mitarbeitende mit Deep-Links in die Quelldokumente." }, { code: "HANDBUCH", type: "HANDBUCH" as const, title: "Anwender-Handbuch Informationssicherheit", orderIdx: 950, intro: "Kompakte Zusammenfassung der wichtigsten Regeln für Mitarbeitende mit Deep-Links in die Quelldokumente." },
// Verwaltete Register (GAP-Report §5.2). Vorerst als benannte Register-Dokumente mit
// dokumentierten Pflichtspalten; die editierbare Datenpflege (Attribute/Cross-Links) folgt (WP3.0).
{ code: "REG-PROJECTS", type: "REGISTER" as const, title: "Register Informationssicherheit in Projekten", orderIdx: 905, intro: "Projekte mit IS-Klassifizierung, Risikobewertung, abgeleiteten Maßnahmen/Status und ISB-Einbindung (R01 / VA-19). Pflichtspalten: Projekt, IS-Klassifizierung, Risikobewertung, Maßnahmen/Status, ISB-Einbindung." },
{ code: "REG-SENS-ROLES", type: "REGISTER" as const, title: "Register sensibler Tätigkeiten", orderIdx: 906, intro: "Sensible Tätigkeitsbereiche/Rollen mit geforderten Eignungsnachweisen und Prüftiefe (R05 / VA-14). Pflichtspalten: Rolle/Bereich, geforderte Eignungsnachweise, Prüftiefe, Verantwortlich, Review." },
{ code: "REG-AUDIT-PLAN", type: "REGISTER" as const, title: "Audit-Programm-Register", orderIdx: 907, intro: "Auditprogramm mit Zeitplan, Umfang, geprüften Controls, Prüfern und Ergebnissen (R03 / VA-15). Pflichtspalten: Zeitplan, Umfang, geprüfte Controls, Prüfer, Ergebnisse." },
{ code: "REG-EXT-SERVICES", type: "REGISTER" as const, title: "Register externe IT-/Cloud-/KI-Dienste", orderIdx: 908, intro: "Externe/Cloud-/KI-Dienste mit Schutzbedarf, Datenlokation (EU), Verschlüsselung, Exit-Strategie, Quelle/Lieferant und Freigabestatus (R13/VA-10 + R02/VA-08). Cross-Link an Lieferantensteuerung und Asset-Inventar." },
]; ];
for (const d of registerDocs) { for (const d of registerDocs) {
await prisma.policyDocument.upsert({ await prisma.policyDocument.upsert({
@@ -132,6 +154,23 @@ export async function importManaged(prisma: PrismaClient, tenantId: string) {
}); });
} }
// Generische verwaltete Register (GAP-Report WP3.0 / §5.2): Dokument (Bibliothek/Link)
// + editierbare ManagedRegister-Definition mit Pflichtspalten + Cross-Links. Zeilen
// (RegisterRow) bleiben bei Re-Import erhalten (nicht-destruktiv).
for (const g of GENERIC_REGISTERS) {
const intro = `${g.description} Pflichtspalten: ${g.columns.map((c) => c.label).join(", ")}.`;
await prisma.policyDocument.upsert({
where: { tenantId_code: { tenantId, code: g.code } },
update: { title: g.title, type: "REGISTER", orderIdx: g.orderIdx, rawMarkdown: `# ${g.title}\n\n${intro}` },
create: { tenantId, code: g.code, type: "REGISTER", title: g.title, version: "1.0", status: "FREIGEGEBEN", orderIdx: g.orderIdx, rawMarkdown: `# ${g.title}\n\n${intro}` },
});
await prisma.managedRegister.upsert({
where: { tenantId_code: { tenantId, code: g.code } },
update: { title: g.title, description: g.description, columns: g.columns, supplierLink: g.supplierLink ?? false, assetLink: g.assetLink ?? false },
create: { tenantId, code: g.code, title: g.title, description: g.description, columns: g.columns, supplierLink: g.supplierLink ?? false, assetLink: g.assetLink ?? false },
});
}
// Krypto-Register // Krypto-Register
for (let i = 0; i < CRYPTO.length; i++) { for (let i = 0; i < CRYPTO.length; i++) {
const c = CRYPTO[i]; const c = CRYPTO[i];
@@ -0,0 +1,55 @@
-- CreateTable
CREATE TABLE "managed_registers" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"columns" JSONB NOT NULL DEFAULT '[]',
"review_cycle" TEXT,
"responsible" TEXT,
"supplier_link" BOOLEAN NOT NULL DEFAULT false,
"asset_link" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "managed_registers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "register_rows" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"register_id" TEXT NOT NULL,
"values" JSONB NOT NULL DEFAULT '{}',
"supplier_ref" TEXT,
"asset_ref" TEXT,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "register_rows_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "managed_registers_tenant_id_idx" ON "managed_registers"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "managed_registers_tenant_id_code_key" ON "managed_registers"("tenant_id", "code");
-- CreateIndex
CREATE INDEX "register_rows_tenant_id_register_id_idx" ON "register_rows"("tenant_id", "register_id");
-- AddForeignKey
ALTER TABLE "register_rows" ADD CONSTRAINT "register_rows_register_id_fkey" FOREIGN KEY ("register_id") REFERENCES "managed_registers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RLS für die neuen mandantenbezogenen Tabellen.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['managed_registers','register_rows'] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))', t);
END LOOP;
END $$;
+41
View File
@@ -1029,3 +1029,44 @@ model HandbookTopic {
@@index([tenantId]) @@index([tenantId])
@@map("handbook_topics") @@map("handbook_topics")
} }
// Generisches verwaltetes Register (GAP-Report WP3.0): benannte Register mit
// Register-ID (REG-…), definierten Pflichtspalten, Review-Turnus und optionalen
// Cross-Links an Lieferanten (R13/VA-10) und Asset-Inventar (R02/VA-08).
model ManagedRegister {
id String @id @default(cuid())
tenantId String @map("tenant_id")
code String // REG-PROJECTS, REG-SW-WHITELIST, …
title String
description String?
columns Json @default("[]") // [{ key, label }] Pflichtspalten
reviewCycle String? @map("review_cycle")
responsible String?
supplierLink Boolean @default(false) @map("supplier_link") // Cross-Link Lieferant (Asset)
assetLink Boolean @default(false) @map("asset_link") // Cross-Link Asset
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
rows RegisterRow[]
@@unique([tenantId, code])
@@index([tenantId])
@@map("managed_registers")
}
model RegisterRow {
id String @id @default(cuid())
tenantId String @map("tenant_id")
registerId String @map("register_id")
values Json @default("{}") // { spaltenKey: wert }
supplierRef String? @map("supplier_ref") // Asset-ID (Lieferant)
assetRef String? @map("asset_ref") // Asset-ID
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
register ManagedRegister @relation(fields: [registerId], references: [id], onDelete: Cascade)
@@index([tenantId, registerId])
@@map("register_rows")
}
+1
View File
@@ -30,6 +30,7 @@ const ACTION_MODULE: Record<string, string> = {
"suppliers.ts": "suppliers", "suppliers.ts": "suppliers",
"services.ts": "suppliers", "services.ts": "suppliers",
"policies.ts": "policies", "policies.ts": "policies",
"register.ts": "policies",
// Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind // Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard. // keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
"admin.ts": "EXEMPT", "admin.ts": "EXEMPT",
+25 -2
View File
@@ -14,6 +14,7 @@ import {
POLICY_TYPE_LABEL, POLICY_TYPE_LABEL,
} from "@/components/policy-modals"; } from "@/components/policy-modals";
import { PolicyRegisterView } from "@/components/policy-registers"; import { PolicyRegisterView } from "@/components/policy-registers";
import { GenericRegisterView, type RegisterColumn } from "@/components/generic-register";
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]); const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
// Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor) // Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor)
@@ -35,9 +36,31 @@ export default async function PolicyDetailPage({
const canWrite = hasPermission(session, "policy:write"); const canWrite = hasPermission(session, "policy:write");
const isRegister = REGISTER_CODES.has(doc.code); const isRegister = REGISTER_CODES.has(doc.code);
// Generisches verwaltetes Register (WP3.0): editierbare Zeilen nach Pflichtspalten.
const genericReg = doc.type === "REGISTER" && !isRegister
? await db.managedRegister.findFirst({ where: { code: doc.code } })
: null;
let content: React.ReactNode; let content: React.ReactNode;
if (isRegister) { if (genericReg) {
const [rows, allAssets] = await Promise.all([
db.registerRow.findMany({ where: { registerId: genericReg.id }, orderBy: { orderIdx: "asc" } }),
db.asset.findMany({ select: { id: true, name: true, type: true }, orderBy: { name: "asc" } }),
]);
content = (
<GenericRegisterView
register={{
code: genericReg.code, title: genericReg.title, description: genericReg.description,
columns: (genericReg.columns as unknown as RegisterColumn[]) ?? [],
supplierLink: genericReg.supplierLink, assetLink: genericReg.assetLink,
}}
rows={rows.map((r) => ({ id: r.id, values: (r.values as Record<string, string>) ?? {}, supplierRef: r.supplierRef, assetRef: r.assetRef }))}
canWrite={canWrite}
suppliers={allAssets.filter((a) => a.type === "SUPPLIER" || a.type === "IT_SERVICE").map((a) => ({ id: a.id, name: a.name }))}
assets={allAssets.map((a) => ({ id: a.id, name: a.name }))}
/>
);
} else if (isRegister) {
const data = const data =
doc.code === "CRYPTO" doc.code === "CRYPTO"
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) } ? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
@@ -81,7 +104,7 @@ export default async function PolicyDetailPage({
</div> </div>
<p className="mt-0.5 text-[12.5px] text-muted-foreground"> <p className="mt-0.5 text-[12.5px] text-muted-foreground">
{POLICY_TYPE_LABEL[doc.type]} · v{doc.version} {POLICY_TYPE_LABEL[doc.type]} · v{doc.version}
{isRegister && canWrite ? ` · ${t("editableInline")}` : ""} {(isRegister || genericReg) && canWrite ? ` · ${t("editableInline")}` : ""}
</p> </p>
</div> </div>
{showEdit && ( {showEdit && (
+88
View File
@@ -0,0 +1,88 @@
import { Plus, Trash2 } from "lucide-react";
import { addRegisterRow, updateRegisterRow, deleteRegisterRow } from "@/server/actions/register";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
/**
* Generischer Editor für verwaltete Register (WP3.0): rendert die Pflichtspalten
* als editierbare Zeilen inkl. optionaler Cross-Links (Lieferant/Asset).
*/
export interface RegisterColumn { key: string; label: string }
export interface RegisterInfo {
code: string; title: string; description: string | null;
columns: RegisterColumn[]; supplierLink: boolean; assetLink: boolean;
}
export interface RegisterRowData { id: string; values: Record<string, string>; supplierRef: string | null; assetRef: string | null }
export interface AssetOption { id: string; name: string }
const cell = "h-8 text-[12.5px]";
function RefSelect({ name, value, options, label }: { name: string; value: string | null; options: AssetOption[]; label: string }) {
return (
<div>
<label className="block text-[11px] text-muted-foreground">{label}</label>
<select name={name} defaultValue={value ?? ""} className="mt-0.5 h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
<option value=""></option>
{options.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
);
}
function Fields({ register, row, suppliers, assets, disabled }: {
register: RegisterInfo; row?: RegisterRowData; suppliers: AssetOption[]; assets: AssetOption[]; disabled?: boolean;
}) {
return (
<>
{register.columns.map((c) => (
<div key={c.key}>
<label className="block text-[11px] text-muted-foreground">{c.label}</label>
<Input name={`col_${c.key}`} defaultValue={row?.values?.[c.key] ?? ""} disabled={disabled} className={`mt-0.5 ${cell}`} />
</div>
))}
{register.supplierLink && <RefSelect name="supplierRef" value={row?.supplierRef ?? null} options={suppliers} label="Lieferant" />}
{register.assetLink && <RefSelect name="assetRef" value={row?.assetRef ?? null} options={assets} label="Asset" />}
</>
);
}
export function GenericRegisterView({ register, rows, canWrite, suppliers, assets }: {
register: RegisterInfo; rows: RegisterRowData[]; canWrite: boolean; suppliers: AssetOption[]; assets: AssetOption[];
}) {
const gridCols = register.columns.length + (register.supplierLink ? 1 : 0) + (register.assetLink ? 1 : 0);
const gridStyle = { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` };
return (
<div className="space-y-3">
{register.description && <p className="text-[13px] text-muted-foreground">{register.description}</p>}
<div className="space-y-2">
{rows.map((r) => (
<div key={r.id} className="rounded-xl border bg-card p-3">
<div className="flex flex-wrap items-end gap-2">
<form action={updateRegisterRow.bind(null, r.id)} className="grid flex-1 gap-2" style={gridStyle}>
<Fields register={register} row={r} suppliers={suppliers} assets={assets} disabled={!canWrite} />
{canWrite && <div className="col-span-full"><Button type="submit" variant="outline" size="sm">Speichern</Button></div>}
</form>
{canWrite && (
<form action={deleteRegisterRow.bind(null, r.id)}>
<Button type="submit" variant="ghost" size="sm" title="Löschen"><Trash2 className="size-4" /></Button>
</form>
)}
</div>
</div>
))}
{rows.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Einträge in diesem Register.</p>}
</div>
{canWrite && (
<form action={addRegisterRow.bind(null, register.code)} className="rounded-xl border border-dashed bg-card p-3">
<p className="mb-2 font-heading text-[12.5px] font-semibold">Eintrag hinzufügen</p>
<div className="grid gap-2" style={gridStyle}>
<Fields register={register} suppliers={suppliers} assets={assets} />
</div>
<Button type="submit" size="sm" className="mt-3"><Plus className="size-4" /> Hinzufügen</Button>
</form>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
"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}`);
}
+2
View File
@@ -30,6 +30,8 @@ const TENANT_MODELS = new Set<string>([
"AuditLog", "AuditLog",
"Task", "Task",
"TaskComment", "TaskComment",
"ManagedRegister",
"RegisterRow",
"TenantSettings", "TenantSettings",
"TenantModule", "TenantModule",
"Asset", "Asset",