Files
craftvia/src/server/control-descriptions-context.ts
T
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

146 lines
5.9 KiB
TypeScript

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),
};
}