Files
craftvia/prisma/template-store.ts
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

206 lines
9.4 KiB
TypeScript

import type { PrismaClient, Framework } from "@prisma/client";
import type { ParsedPackage, ParsedDoc } from "./import-policies";
import { parsePackageFiles, readPackageVersion, mappingFileFor } from "./import-policies";
/**
* Globale DB-Vorlagenablage (Plattform-Ebene). Master-Quelle für den Mandanten-Import:
* eine `PolicyTemplateVersion` je Paketversion mit Inhalten je Sprache (`locale`).
*
* - `syncTemplatesFromParsed`: schreibt ein `ParsedPackage` (aus den Dateien geparst)
* für eine Sprache in eine Version. Für eine bestehende Version werden die Inhalte
* dieser Sprache vollständig ersetzt (idempotent). Sicher, weil die Vorlagen-Tabellen
* keine Mandanten-/Nutzerdaten enthalten.
* - `loadPublishedPackage`: liest die aktuell veröffentlichte Version in der gewünschten
* Sprache als `ParsedPackage` — dieselbe Struktur wie der Datei-Parser, sodass der
* non-destruktive Mandanten-Reconcile identisch damit arbeitet. Liefert null, wenn es
* keine veröffentlichte Version bzw. keine Inhalte in der Sprache gibt (→ Datei-Fallback).
*/
type DocType = ParsedDoc["type"];
/** Ein Datei-Paket (eine Sprache) in eine Vorlagen-Version schreiben; Inhalte der Sprache ersetzen. */
export async function syncTemplatesFromParsed(
prisma: PrismaClient,
pkg: ParsedPackage,
locale: string,
framework: Framework = "TISAX",
options: { publish?: boolean; publishedBy?: string | null } = {},
): Promise<{ versionId: string; version: string }> {
const publish = options.publish ?? true;
// Eindeutigkeit je (Framework, Version): ISO 2.1 und TISAX 2.1 sind verschiedene Zeilen.
const existing = await prisma.policyTemplateVersion.findUnique({
where: { framework_version: { framework, version: pkg.version } },
});
const version =
existing ??
(await prisma.policyTemplateVersion.create({
data: {
framework,
version: pkg.version,
status: publish ? "PUBLISHED" : "DRAFT",
publishedBy: publish ? (options.publishedBy ?? null) : null,
publishedAt: publish ? new Date() : null,
},
}));
if (existing && publish && existing.status !== "PUBLISHED") {
await prisma.policyTemplateVersion.update({
where: { id: existing.id },
data: { status: "PUBLISHED", publishedAt: new Date(), publishedBy: options.publishedBy ?? null },
});
}
const versionId = version.id;
// Inhalte dieser Sprache vollständig ersetzen (idempotenter Sync).
await prisma.$transaction([
prisma.policyTemplateDoc.deleteMany({ where: { versionId, locale } }),
prisma.policyTemplateRequirement.deleteMany({ where: { versionId, locale } }),
prisma.policyTemplateVariable.deleteMany({ where: { versionId, locale } }),
prisma.policyTemplateBaselineParam.deleteMany({ where: { versionId, locale } }),
prisma.policyTemplateEvidence.deleteMany({ where: { versionId, locale } }),
prisma.policyTemplateDoc.createMany({
data: pkg.documents.map((d) => ({
versionId, locale, code: d.code, type: d.type, title: d.title,
policyCode: d.policyCode, fulfills: d.fulfills, rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx,
})),
}),
prisma.policyTemplateRequirement.createMany({
data: pkg.requirements.map((r) => ({
versionId, 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,
})),
}),
prisma.policyTemplateVariable.createMany({
data: pkg.variables.map((v) => ({
versionId, 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: pkg.baseline.map((b) => ({
versionId, locale, blId: b.blId, section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx,
})),
}),
prisma.policyTemplateEvidence.createMany({
data: pkg.evidence.map((e) => ({
versionId, locale, nr: e.nr, policyCode: e.policyCode, nachweis: e.nachweis,
quelle: e.quelle, verantwortlich: e.verantwortlich, turnus: e.turnus,
})),
}),
]);
return { versionId, version: version.version };
}
/**
* Aktuell veröffentlichte Vorlagen-Version in der gewünschten Sprache als `ParsedPackage`.
* Liefert null, wenn keine veröffentlichte Version existiert oder die Sprache keine
* Dokumente enthält (→ Aufrufer nutzt den Datei-Fallback).
*/
export async function loadPublishedPackage(
prisma: PrismaClient,
locale: string,
framework: Framework = "TISAX",
): Promise<ParsedPackage | null> {
const version = await prisma.policyTemplateVersion.findFirst({
where: { status: "PUBLISHED", framework },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
if (!version) return null;
const [docs, reqs, vars, baseline, evidence] = await Promise.all([
prisma.policyTemplateDoc.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } }),
prisma.policyTemplateRequirement.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } }),
prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } }),
prisma.policyTemplateBaselineParam.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } }),
prisma.policyTemplateEvidence.findMany({ where: { versionId: version.id, locale }, orderBy: { nr: "asc" } }),
]);
if (docs.length === 0) return null; // Sprache (noch) nicht befüllt → Fallback
return {
version: version.version,
documents: docs.map((d) => ({
code: d.code, type: d.type as DocType, title: d.title, policyCode: d.policyCode,
fulfills: d.fulfills, rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx,
})),
requirements: reqs.map((r) => ({
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,
})),
variables: vars.map((v) => ({
key: v.key, title: v.title, kind: v.kind, groupName: v.groupName,
value: v.value, required: v.required, orderIdx: v.orderIdx,
})),
baseline: baseline.map((b) => ({
blId: b.blId, section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx,
})),
evidence: evidence.map((e) => ({
nr: e.nr, policyCode: e.policyCode, nachweis: e.nachweis, quelle: e.quelle,
verantwortlich: e.verantwortlich, turnus: e.turnus,
})),
};
}
/** Sprachpräferenz eines Mandanten (Default "de"). Owner-Client (RLS-Bypass) — für Plattform- UND Mandanten-Aufrufer nutzbar. */
export async function getTenantLocale(prisma: PrismaClient, tenantId: string): Promise<string> {
const s = await prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } });
return s?.locale ?? "de";
}
/**
* Auflösen des Vorlagenpakets für einen Mandanten: veröffentlichte DB-Version in der
* Sprache des Mandanten → DE-Fallback → Datei-Fallback. So funktioniert der Import
* identisch, egal ob die DB-Ablage schon befüllt ist.
*/
export async function resolvePackageForTenant(
prisma: PrismaClient,
tenantId: string,
seedDir: string,
framework: Framework = "TISAX",
): Promise<{ pkg: ParsedPackage; source: "db" | "file"; locale: string }> {
const locale = await getTenantLocale(prisma, tenantId);
const inLocale = await loadPublishedPackage(prisma, locale, framework);
if (inLocale) return { pkg: inLocale, source: "db", locale };
if (locale !== "de") {
const de = await loadPublishedPackage(prisma, "de", framework);
if (de) return { pkg: de, source: "db", locale: "de" };
}
// Datei-Fallback: das framework-passende Mapping (mapping.json / mapping-iso.json).
return { pkg: parsePackageFiles(seedDir, mappingFileFor(framework)), source: "file", locale };
}
/** Verfügbare Paketversion für den Update-Vergleich: veröffentlichte DB-Version, sonst Datei-Version. */
export async function getAvailableVersion(
prisma: PrismaClient,
seedDir: string,
framework: Framework = "TISAX",
): Promise<string> {
const v = await prisma.policyTemplateVersion.findFirst({
where: { status: "PUBLISHED", framework },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
select: { version: true },
});
return v?.version ?? readPackageVersion(seedDir, mappingFileFor(framework));
}
/**
* Frameworks eines Mandanten (AP1), Primär-Framework zuerst. Fällt auf `["TISAX"]`
* zurück, falls (noch) keine `TenantFramework`-Zeile existiert — so bleibt jeder
* Bestands-/Alt-Mandant ohne Zeile als TISAX lauffähig (Fail-safe zu Invariante 3).
* Owner-Client (RLS-Bypass) — für Plattform- UND Mandanten-Aufrufer nutzbar.
*/
export async function getTenantFrameworks(prisma: PrismaClient, tenantId: string): Promise<Framework[]> {
const rows = await prisma.tenantFramework.findMany({
where: { tenantId },
orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }],
select: { framework: true },
});
return rows.length ? rows.map((r) => r.framework) : ["TISAX"];
}
/** Primär-Framework eines Mandanten (erstes aus {@link getTenantFrameworks}). */
export async function getTenantPrimaryFramework(prisma: PrismaClient, tenantId: string): Promise<Framework> {
return (await getTenantFrameworks(prisma, tenantId))[0];
}