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