// Pure helpers for the Coverage page (#74). Kept separate so they are unit-testable // without rendering the server component. export interface UseCaseRow { key: string label: string group: string regulations: string[] verification_methods: string[] mapped_controls: number atom_total: number atom_relevant: number } export interface CorpusDoc { source_regulation: string license_rule: number | null license_tier: string atom_count: number use_case: string | null } export interface LicenseSummaryRow { license_rule: number | null label: string atom_count: number } export interface LicenseCatalogEntry { source_id: string title: string publisher: string | null url: string | null version: string | null license_id: string | null license_name: string | null commercial_use: string | null ship_in_product: boolean | null terms_url: string | null } export interface CorpusOverview { license_summary: LicenseSummaryRow[] documents: CorpusDoc[] license_catalog: LicenseCatalogEntry[] totals: { documents: number; catalog_sources: number } } // --- Korpus-Dokumente: gruppieren nach Art (Gesetz/Leitfaden/Standard/Urteil) // + Herausgeber-Familie (DSK, EDPB, OWASP, NIST …). Deterministisch, pure. --- interface DocCat { key: string label: string order: number } const CAT_LAW: DocCat = { key: 'law', label: 'Gesetze & Verordnungen', order: 1 } const CAT_GUIDANCE: DocCat = { key: 'guidance', label: 'Behörden-Leitfäden & Orientierungshilfen', order: 2, } const CAT_STANDARD: DocCat = { key: 'standard', label: 'Standards & Best Practice', order: 3, } const CAT_COURT: DocCat = { key: 'court', label: 'Rechtsprechung', order: 4 } export function categorizeCorpusDoc(src: string): { cat: DocCat; family: string } { const u = (src || '').toUpperCase() // Standards & Best Practice (technische Familien) if (u.includes('OWASP')) return { cat: CAT_STANDARD, family: 'OWASP' } if (u.includes('NIST')) return { cat: CAT_STANDARD, family: 'NIST' } if (u.includes('CISA')) return { cat: CAT_STANDARD, family: 'CISA' } if (u.includes('OECD')) return { cat: CAT_STANDARD, family: 'OECD' } if (u.includes('ENISA')) return { cat: CAT_STANDARD, family: 'ENISA' } // Behörden-Leitfäden (Datenschutz-Aufsicht + EU-Kommissions-Guides) if (u.startsWith('DSK')) return { cat: CAT_GUIDANCE, family: 'DSK (Datenschutzkonferenz)' } if (u.includes('EDPB')) return { cat: CAT_GUIDANCE, family: 'EDPB' } if (u.includes('EDPS')) return { cat: CAT_GUIDANCE, family: 'EDPS' } if (u.includes('WP29')) return { cat: CAT_GUIDANCE, family: 'WP29 (Art.-29-Gruppe)' } if (u.includes('BFDI')) return { cat: CAT_GUIDANCE, family: 'BfDI' } if (u.includes('EU MACHINERY GUIDE') || u.includes('EU BLUE GUIDE')) return { cat: CAT_GUIDANCE, family: 'EU-Kommission (Guides)' } // Rechtsprechung if (u.startsWith('BGH') || u.startsWith('BVGER') || u.startsWith('EUGH')) return { cat: CAT_COURT, family: 'Rechtsprechung' } // Default: Gesetz/Verordnung/Richtlinie return { cat: CAT_LAW, family: 'Gesetze & Verordnungen' } } export interface CorpusFamilyGroup { family: string total: number docs: CorpusDoc[] } export interface CorpusCatGroup { key: string label: string order: number total: number families: CorpusFamilyGroup[] } // Group corpus docs by category (ordered: laws → guidance → standards → court), // families within each sorted by size, docs within a family by size. So all DSK // sit together, all EDPB together, all OWASP/NIST together, under headings. export function groupCorpusDocs(docs: CorpusDoc[]): CorpusCatGroup[] { const cats = new Map }>() for (const d of docs) { const { cat, family } = categorizeCorpusDoc(d.source_regulation) if (!cats.has(cat.key)) cats.set(cat.key, { cat, fam: new Map() }) const fam = cats.get(cat.key)!.fam if (!fam.has(family)) fam.set(family, []) fam.get(family)!.push(d) } return [...cats.values()] .map(({ cat, fam }) => { const families = [...fam.entries()] .map(([family, ds]) => ({ family, docs: [...ds].sort((a, b) => b.atom_count - a.atom_count), total: ds.reduce((s, d) => s + d.atom_count, 0), })) .sort((a, b) => b.total - a.total) return { key: cat.key, label: cat.label, order: cat.order, total: families.reduce((s, f) => s + f.total, 0), families, } }) .sort((a, b) => a.order - b.order) } export const USE_CASE_GROUP_LABELS: Record = { document: 'Dokument-Compliance', security: 'Security', cross_cutting: 'Querschnitt', product: 'Produkt / Sektor', } export function licenseTierBadgeClass(rule: number | null): string { switch (rule) { case 1: return 'bg-green-100 text-green-800' case 2: return 'bg-blue-100 text-blue-800' case 3: return 'bg-amber-100 text-amber-800' default: return 'bg-gray-100 text-gray-700' } } export function commercialBadgeClass(commercial: string | null): string { switch ((commercial || '').toLowerCase()) { case 'allowed': return 'bg-green-100 text-green-800' case 'restricted': return 'bg-amber-100 text-amber-800' case 'prohibited': return 'bg-red-100 text-red-800' default: return 'bg-gray-100 text-gray-700' } } // --- Controls drill-down (#80 Stufe-Flip + Provenance) --- export interface ControlItem { id: string control_id?: string | null title: string objective?: string | null severity?: string | null sub_topic?: string | null canonical_obligation?: string | null source_regulation?: string | null source_article?: string | null relevant: boolean tier: 'core' | 'review' source_type: 'derived' | 'own_library' addressee?: string | null applicable: boolean is_gov: boolean } export interface ControlsResponse { use_case: string label: string group: string granularity: string tier: string total: number core_count: number review_count: number out_of_scope_count: number include_out_of_scope: boolean limit: number offset: number sub_topic: string | null subtopic_counts: Record controls: ControlItem[] } // Addressee axis: who must fulfil an obligation. out-of-scope (authority power / // member-state-EU / third party / meta) is advisory — hidden by default, never // deleted. oeffentliche_stelle = additive GOV hint (public-sector customer). export const ADDRESSEE_LABELS: Record = { unternehmen: 'Unternehmen', oeffentliche_stelle: 'Öffentliche Stelle', aufsichtsbefugnis: 'Aufsichtsbehörde', staat_eu: 'Mitgliedstaat/EU', dritter: 'Dritter', meta: 'Meta', } export function addresseeLabel(a?: string | null): string { return a ? ADDRESSEE_LABELS[a] || a : '' } // Provenance line: own library vs derived-from-document (with the document, and // article when known). The user wants to see WHERE a derived control came from. export function provenanceLabel( c: Pick, ): string { if (c.source_type === 'own_library') return 'Eigene Bibliothek' const doc = c.source_regulation?.trim() if (!doc) return 'Abgeleitet' const art = c.source_article?.trim() return art ? `Abgeleitet · ${doc} ${art}` : `Abgeleitet · ${doc}` } export function provenanceBadgeClass(sourceType: string): string { return sourceType === 'own_library' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800' } export function severityBadgeClass(sev: string | null | undefined): string { switch ((sev || '').toLowerCase()) { case 'critical': return 'bg-red-100 text-red-800' case 'high': return 'bg-orange-100 text-orange-800' case 'medium': return 'bg-yellow-100 text-yellow-800' default: return 'bg-gray-100 text-gray-600' } } // Split into the two display tiers: Kern-Pflichten (relevant) and the // 'zur Prüfung' tier (shown but flagged) — never hidden. export function splitByTier(controls: ControlItem[]): { core: ControlItem[] review: ControlItem[] } { const core: ControlItem[] = [] const review: ControlItem[] = [] for (const c of controls) (c.relevant ? core : review).push(c) return { core, review } } export interface UseCaseGroup { group: string label: string rows: UseCaseRow[] } // Group use-cases by their registry group (stable order), each group's rows // sorted by how many relevant obligations it carries (desc). export function groupUseCases(rows: UseCaseRow[]): UseCaseGroup[] { const order = ['document', 'security', 'cross_cutting', 'product'] const by: Record = {} for (const r of rows) { ;(by[r.group] ||= []).push(r) } const groups = order.filter((g) => by[g]?.length) for (const g of Object.keys(by)) { if (!order.includes(g)) groups.push(g) } return groups.map((g) => ({ group: g, label: USE_CASE_GROUP_LABELS[g] || g, rows: [...by[g]].sort((a, b) => b.atom_relevant - a.atom_relevant), })) }