/** * Document-scope calculation, risk flags, gap analysis, next actions, * and reasoning (audit trail) helpers for the ComplianceScopeEngine. */ import type { ComplianceDepthLevel, ComplianceScores, ScopeProfilingAnswer, TriggeredHardTrigger, RequiredDocument, RiskFlag, ScopeGap, NextAction, ScopeReasoning, ScopeDocumentType, HardTriggerRule, } from './compliance-scope-types' import { getDepthLevelNumeric, DOCUMENT_SCOPE_MATRIX, DOCUMENT_TYPE_LABELS, DOCUMENT_SDK_STEP_MAP, } from './compliance-scope-types' import { HARD_TRIGGER_RULES } from './compliance-scope-triggers' // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /** Parse employee-count bucket string to a representative number. */ export function parseEmployeeCount(value: string): number { if (value === '1-9') return 9 if (value === '10-49') return 49 if (value === '50-249') return 249 if (value === '250-999') return 999 if (value === '1000+') return 1000 return 0 } /** Derive level purely from composite score. */ export function getLevelFromScore(composite: number): ComplianceDepthLevel { if (composite <= 25) return 'L1' if (composite <= 50) return 'L2' if (composite <= 75) return 'L3' return 'L4' } /** Highest level among the given triggers. */ export function getMaxTriggerLevel(triggers: TriggeredHardTrigger[]): ComplianceDepthLevel { if (triggers.length === 0) return 'L1' let max: ComplianceDepthLevel = 'L1' for (const t of triggers) { if (getDepthLevelNumeric(t.minimumLevel) > getDepthLevelNumeric(max)) { max = t.minimumLevel } } return max } // --------------------------------------------------------------------------- // normalizeDocType // --------------------------------------------------------------------------- /** * Maps UPPERCASE document-type identifiers from the hard-trigger rules * to the lowercase ScopeDocumentType keys. */ export function normalizeDocType(raw: string): ScopeDocumentType | null { const mapping: Record = { VVT: 'vvt', TOM: 'tom', DSFA: 'dsfa', DSE: 'dsi', AGB: 'vertragsmanagement', AVV: 'av_vertrag', COOKIE_BANNER: 'einwilligung', EINWILLIGUNGEN: 'einwilligung', TRANSFER_DOKU: 'daten_transfer', AUDIT_CHECKLIST: 'audit_log', VENDOR_MANAGEMENT: 'vertragsmanagement', LOESCHKONZEPT: 'lf', DSR_PROZESS: 'betroffenenrechte', NOTFALLPLAN: 'notfallplan', AI_ACT_DOKU: 'ai_act_doku', WIDERRUFSBELEHRUNG: 'widerrufsbelehrung', PREISANGABEN: 'preisangaben', FERNABSATZ_INFO: 'fernabsatz_info', STREITBEILEGUNG: 'streitbeilegung', PRODUKTSICHERHEIT: 'produktsicherheit', } if (raw in DOCUMENT_SCOPE_MATRIX) return raw as ScopeDocumentType return mapping[raw] ?? null } // --------------------------------------------------------------------------- // Document scope // --------------------------------------------------------------------------- function getDocumentPriority( docType: ScopeDocumentType, isMandatoryFromTrigger: boolean, ): 'high' | 'medium' | 'low' { if (isMandatoryFromTrigger) return 'high' if (['VVT', 'TOM', 'DSE'].includes(docType)) return 'high' if (['DSFA', 'AVV', 'EINWILLIGUNGEN'].includes(docType)) return 'high' return 'medium' } function estimateEffort(docType: ScopeDocumentType): number { const effortMap: Partial> = { vvt: 8, tom: 12, dsfa: 16, av_vertrag: 4, dsi: 6, einwilligung: 6, lf: 10, daten_transfer: 8, betroffenenrechte: 8, notfallplan: 12, vertragsmanagement: 10, audit_log: 8, risikoanalyse: 6, schulung: 4, datenpannen: 6, zertifizierung: 8, datenschutzmanagement: 12, iace_ce_assessment: 8, widerrufsbelehrung: 3, preisangaben: 2, fernabsatz_info: 4, streitbeilegung: 1, produktsicherheit: 8, ai_act_doku: 12, } return effortMap[docType] ?? 6 } /** * Build the full document-scope list based on compliance level and triggers. */ export function buildDocumentScope( level: ComplianceDepthLevel, triggers: TriggeredHardTrigger[], _answers: ScopeProfilingAnswer[], ): RequiredDocument[] { const requiredDocs: RequiredDocument[] = [] const mandatoryFromTriggers = new Set() const triggerDocOrigins = new Map() for (const trigger of triggers) { for (const doc of trigger.mandatoryDocuments) { const normalized = normalizeDocType(doc) if (normalized) { mandatoryFromTriggers.add(normalized) if (!triggerDocOrigins.has(normalized)) triggerDocOrigins.set(normalized, []) triggerDocOrigins.get(normalized)!.push(doc) } } } for (const docType of Object.keys(DOCUMENT_SCOPE_MATRIX) as ScopeDocumentType[]) { const requirement = DOCUMENT_SCOPE_MATRIX[docType][level] const isMandatoryFromTrigger = mandatoryFromTriggers.has(docType) if (requirement === 'mandatory' || isMandatoryFromTrigger) { const originDocs = triggerDocOrigins.get(docType) ?? [] requiredDocs.push({ documentType: docType, label: DOCUMENT_TYPE_LABELS[docType], requirement: 'mandatory', priority: getDocumentPriority(docType, isMandatoryFromTrigger), estimatedEffort: estimateEffort(docType), sdkStepUrl: DOCUMENT_SDK_STEP_MAP[docType], triggeredBy: isMandatoryFromTrigger ? triggers .filter((t) => t.mandatoryDocuments.some((d) => originDocs.includes(d))) .map((t) => t.ruleId) : [], }) } else if (requirement === 'recommended') { requiredDocs.push({ documentType: docType, label: DOCUMENT_TYPE_LABELS[docType], requirement: 'recommended', priority: 'medium', estimatedEffort: estimateEffort(docType), sdkStepUrl: DOCUMENT_SDK_STEP_MAP[docType], triggeredBy: [], }) } } requiredDocs.sort((a, b) => { if (a.requirement === 'mandatory' && b.requirement !== 'mandatory') return -1 if (a.requirement !== 'mandatory' && b.requirement === 'mandatory') return 1 const priorityOrder: Record = { high: 3, medium: 2, low: 1 } return priorityOrder[b.priority] - priorityOrder[a.priority] }) return requiredDocs } // --------------------------------------------------------------------------- // Risk flags // --------------------------------------------------------------------------- function getMaturityRecommendation(ruleId: string): string { const recommendations: Record = { 'HT-I01': 'Prozess für Betroffenenrechte (DSAR) etablieren und dokumentieren', 'HT-I02': 'Löschkonzept gemäß Art. 17 DSGVO entwickeln und implementieren', 'HT-I03': 'Incident-Response-Plan für Datenschutzverletzungen (Art. 33 DSGVO) erstellen', 'HT-I04': 'Regelmäßige interne Audits und Reviews einführen', 'HT-I05': 'Schulungsprogramm für Mitarbeiter zum Datenschutz etablieren', } return recommendations[ruleId] || 'Prozess etablieren und dokumentieren' } /** * Evaluate risk flags based on process-maturity gaps and other risks. * * `checkTriggerFn` is injected to avoid a circular dependency on the engine. */ export function evaluateRiskFlags( answers: ScopeProfilingAnswer[], level: ComplianceDepthLevel, checkTriggerFn: ( rule: HardTriggerRule, answerMap: Map, answers: ScopeProfilingAnswer[], ) => boolean, ): RiskFlag[] { const flags: RiskFlag[] = [] const answerMap = new Map(answers.map((a) => [a.questionId, a.value])) const maturityRules = HARD_TRIGGER_RULES.filter((r) => r.category === 'process_maturity') for (const rule of maturityRules) { if (checkTriggerFn(rule, answerMap, answers)) { flags.push({ severity: 'medium', category: 'process', message: rule.description, legalReference: rule.legalReference, recommendation: getMaturityRecommendation(rule.id), }) } } if (getDepthLevelNumeric(level) >= 2) { const encTransit = answerMap.get('tech_encryption_transit') const encRest = answerMap.get('tech_encryption_rest') if (encTransit === false) { flags.push({ severity: 'high', category: 'technical', message: 'Fehlende Verschlüsselung bei Datenübertragung', legalReference: 'Art. 32 DSGVO', recommendation: 'TLS 1.2+ für alle Datenübertragungen implementieren', }) } if (encRest === false) { flags.push({ severity: 'high', category: 'technical', message: 'Fehlende Verschlüsselung gespeicherter Daten', legalReference: 'Art. 32 DSGVO', recommendation: 'Verschlüsselung at-rest für sensitive Daten implementieren', }) } } const thirdCountry = answerMap.get('tech_third_country') const hostingLocation = answerMap.get('tech_hosting_location') if ( thirdCountry === true && hostingLocation !== 'eu' && hostingLocation !== 'eu_us_adequacy' ) { flags.push({ severity: 'high', category: 'legal', message: 'Drittlandtransfer ohne angemessene Garantien', legalReference: 'Art. 44 ff. DSGVO', recommendation: 'Standardvertragsklauseln (SCCs) oder Binding Corporate Rules (BCRs) implementieren', }) } const hasDPO = answerMap.get('org_has_dpo') const employeeCount = answerMap.get('org_employee_count') if (hasDPO === false && parseEmployeeCount(employeeCount as string) >= 250) { flags.push({ severity: 'medium', category: 'organizational', message: 'Kein Datenschutzbeauftragter bei großer Organisation', legalReference: 'Art. 37 DSGVO', recommendation: 'Bestellung eines Datenschutzbeauftragten prüfen', }) } return flags } // --------------------------------------------------------------------------- // Gap analysis // --------------------------------------------------------------------------- export function calculateGaps( answers: ScopeProfilingAnswer[], level: ComplianceDepthLevel, ): ScopeGap[] { const gaps: ScopeGap[] = [] const answerMap = new Map(answers.map((a) => [a.questionId, a.value])) if (getDepthLevelNumeric(level) >= 3) { const hasDSFA = answerMap.get('proc_regular_audits') if (hasDSFA === false) { gaps.push({ gapType: 'documentation', severity: 'high', description: 'Datenschutz-Folgenabschätzung (DSFA) fehlt', requiredFor: level, currentState: 'Keine DSFA durchgeführt', targetState: 'DSFA für Hochrisiko-Verarbeitungen durchgeführt und dokumentiert', effort: 16, priority: 'high', }) } } const hasDeletion = answerMap.get('proc_deletion_concept') if (hasDeletion === false && getDepthLevelNumeric(level) >= 2) { gaps.push({ gapType: 'process', severity: 'medium', description: 'Löschkonzept fehlt', requiredFor: level, currentState: 'Kein systematisches Löschkonzept', targetState: 'Dokumentiertes Löschkonzept mit definierten Fristen', effort: 10, priority: 'high', }) } const hasDSAR = answerMap.get('proc_dsar_process') if (hasDSAR === false) { gaps.push({ gapType: 'process', severity: 'high', description: 'Prozess für Betroffenenrechte fehlt', requiredFor: level, currentState: 'Kein etablierter DSAR-Prozess', targetState: 'Dokumentierter Prozess zur Bearbeitung von Betroffenenrechten', effort: 8, priority: 'high', }) } const hasIncident = answerMap.get('proc_incident_response') if (hasIncident === false) { gaps.push({ gapType: 'process', severity: 'high', description: 'Incident-Response-Plan fehlt', requiredFor: level, currentState: 'Kein Prozess für Datenschutzverletzungen', targetState: 'Dokumentierter Incident-Response-Plan gemäß Art. 33 DSGVO', effort: 12, priority: 'high', }) } const hasTraining = answerMap.get('comp_training') if (hasTraining === false && getDepthLevelNumeric(level) >= 2) { gaps.push({ gapType: 'organizational', severity: 'medium', description: 'Datenschutzschulungen fehlen', requiredFor: level, currentState: 'Keine regelmäßigen Schulungen', targetState: 'Etabliertes Schulungsprogramm für alle Mitarbeiter', effort: 6, priority: 'medium', }) } return gaps } // --------------------------------------------------------------------------- // Next actions // --------------------------------------------------------------------------- export function buildNextActions( requiredDocuments: RequiredDocument[], gaps: ScopeGap[], ): NextAction[] { const actions: NextAction[] = [] for (const doc of requiredDocuments) { if (doc.requirement === 'mandatory') { actions.push({ actionType: 'create_document', title: `${doc.label} erstellen`, description: `Pflichtdokument für Compliance-Level erstellen`, priority: doc.priority, estimatedEffort: doc.estimatedEffort, documentType: doc.documentType, sdkStepUrl: doc.sdkStepUrl, blockers: [], }) } } for (const gap of gaps) { let actionType: NextAction['actionType'] = 'establish_process' if (gap.gapType === 'documentation') actionType = 'create_document' else if (gap.gapType === 'technical') actionType = 'implement_technical' else if (gap.gapType === 'organizational') actionType = 'organizational_change' actions.push({ actionType, title: `Gap schließen: ${gap.description}`, description: `Von "${gap.currentState}" zu "${gap.targetState}"`, priority: gap.priority, estimatedEffort: gap.effort, blockers: [], }) } const priorityOrder: Record = { high: 3, medium: 2, low: 1 } actions.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]) return actions } // --------------------------------------------------------------------------- // Reasoning (audit trail) // --------------------------------------------------------------------------- export function buildReasoning( scores: ComplianceScores, triggers: TriggeredHardTrigger[], level: ComplianceDepthLevel, docs: RequiredDocument[], ): ScopeReasoning[] { const reasoning: ScopeReasoning[] = [] reasoning.push({ step: 'score_calculation', description: 'Risikobasierte Score-Berechnung aus Profiling-Antworten', factors: [ `Risiko-Score: ${scores.risk_score}/10`, `Komplexitäts-Score: ${scores.complexity_score}/10`, `Assurance-Score: ${scores.assurance_need}/10`, `Composite Score: ${scores.composite_score}/10`, ], impact: `Score-basiertes Level: ${getLevelFromScore(scores.composite_score)}`, }) if (triggers.length > 0) { reasoning.push({ step: 'hard_trigger_evaluation', description: `${triggers.length} Hard Trigger Rule(s) aktiviert`, factors: triggers.map( (t) => `${t.ruleId}: ${t.description}${t.legalReference ? ` (${t.legalReference})` : ''}`, ), impact: `Höchstes Trigger-Level: ${getMaxTriggerLevel(triggers)}`, }) } reasoning.push({ step: 'level_determination', description: 'Finales Compliance-Level durch Maximum aus Score und Triggers', factors: [ `Score-Level: ${getLevelFromScore(scores.composite_score)}`, `Trigger-Level: ${getMaxTriggerLevel(triggers)}`, ], impact: `Finales Level: ${level}`, }) const mandatoryDocs = docs.filter((d) => d.requirement === 'mandatory') reasoning.push({ step: 'document_scope', description: `Dokumenten-Scope für ${level} bestimmt`, factors: [ `${mandatoryDocs.length} Pflichtdokumente`, `${docs.length - mandatoryDocs.length} empfohlene Dokumente`, ], impact: `Gesamtaufwand: ~${docs.reduce((sum, d) => sum + d.estimatedEffort, 0)} Stunden`, }) return reasoning }