Integrate the vendor-compliance module with four DSGVO modules to eliminate data silos and resolve the VVT processor tab's ephemeral state problem. - Reposition vendor-compliance sidebar from seq 4200 to 2500 (after VVT) - VVT: replace ephemeral ProcessorRecord state with Vendor-API fetch (read-only) - Obligations: add linked_vendor_ids (JSONB) + compliance check #12 MISSING_VENDOR_LINK - TOM: add vendor TOM-controls cross-reference table in overview tab - Loeschfristen: add linked_vendor_ids (JSONB) + vendor picker + document section - Migrations: 069_obligations_vendor_link.sql, 070_loeschfristen_vendor_link.sql - Tests: 12 new backend tests (125 total pass) - Docs: update obligations.md + vendors.md with cross-module integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
// =============================================================================
|
|
// Obligations Module - Compliance Check Engine
|
|
// Prueft Pflichten auf Vollstaendigkeit, Konsistenz und Auditfaehigkeit
|
|
// =============================================================================
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface Obligation {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
source: string
|
|
source_article: string
|
|
deadline: string | null
|
|
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
|
priority: 'critical' | 'high' | 'medium' | 'low'
|
|
responsible: string
|
|
linked_systems: string[]
|
|
linked_vendor_ids?: string[]
|
|
assessment_id?: string
|
|
rule_code?: string
|
|
notes?: string
|
|
created_at?: string
|
|
updated_at?: string
|
|
evidence?: string[]
|
|
review_date?: string
|
|
category?: string
|
|
}
|
|
|
|
export type ObligationComplianceIssueType =
|
|
| 'MISSING_RESPONSIBLE'
|
|
| 'OVERDUE_DEADLINE'
|
|
| 'MISSING_EVIDENCE'
|
|
| 'MISSING_DESCRIPTION'
|
|
| 'NO_LEGAL_REFERENCE'
|
|
| 'INCOMPLETE_REGULATION'
|
|
| 'HIGH_PRIORITY_NOT_STARTED'
|
|
| 'STALE_PENDING'
|
|
| 'MISSING_LINKED_SYSTEMS'
|
|
| 'NO_REVIEW_PROCESS'
|
|
| 'CRITICAL_WITHOUT_EVIDENCE'
|
|
| 'MISSING_VENDOR_LINK'
|
|
|
|
export type ObligationComplianceIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
|
|
|
export interface ObligationComplianceIssue {
|
|
type: ObligationComplianceIssueType
|
|
severity: ObligationComplianceIssueSeverity
|
|
message: string
|
|
affectedObligations: string[]
|
|
recommendation: string
|
|
}
|
|
|
|
export interface ObligationComplianceCheckResult {
|
|
score: number
|
|
issues: ObligationComplianceIssue[]
|
|
summary: { total: number; critical: number; high: number; medium: number; low: number }
|
|
checkedAt: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
export const OBLIGATION_SEVERITY_LABELS_DE: Record<ObligationComplianceIssueSeverity, string> = {
|
|
CRITICAL: 'Kritisch',
|
|
HIGH: 'Hoch',
|
|
MEDIUM: 'Mittel',
|
|
LOW: 'Niedrig',
|
|
}
|
|
|
|
export const OBLIGATION_SEVERITY_COLORS: Record<ObligationComplianceIssueSeverity, string> = {
|
|
CRITICAL: '#dc2626',
|
|
HIGH: '#ea580c',
|
|
MEDIUM: '#d97706',
|
|
LOW: '#6b7280',
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPERS
|
|
// =============================================================================
|
|
|
|
function daysBetween(date: Date, now: Date): number {
|
|
const diffMs = now.getTime() - date.getTime()
|
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
}
|
|
|
|
// =============================================================================
|
|
// PER-OBLIGATION CHECKS (1-5, 9, 11)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
|
* Pflicht ohne verantwortliche Person/Abteilung.
|
|
*/
|
|
function checkMissingResponsible(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o => !o.responsible || o.responsible.trim() === '')
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'MISSING_RESPONSIBLE',
|
|
severity: 'MEDIUM',
|
|
message: `${affected.length} Pflicht(en) ohne verantwortliche Person oder Abteilung. Ohne klare Zustaendigkeit koennen Pflichten nicht zuverlaessig umgesetzt werden.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Weisen Sie jeder Pflicht eine verantwortliche Person oder Abteilung zu.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 2: OVERDUE_DEADLINE (HIGH)
|
|
* Pflicht mit Deadline in der Vergangenheit + Status != completed.
|
|
*/
|
|
function checkOverdueDeadline(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const now = new Date()
|
|
const affected = obligations.filter(o => {
|
|
if (!o.deadline || o.status === 'completed') return false
|
|
return new Date(o.deadline) < now
|
|
})
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'OVERDUE_DEADLINE',
|
|
severity: 'HIGH',
|
|
message: `${affected.length} Pflicht(en) mit ueberschrittener Frist. Ueberfaellige Pflichten stellen ein Compliance-Risiko dar und koennen zu Bussgeldern fuehren.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Bearbeiten Sie ueberfaellige Pflichten umgehend oder passen Sie die Fristen an.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 3: MISSING_EVIDENCE (HIGH)
|
|
* Completed-Pflicht ohne Evidence.
|
|
*/
|
|
function checkMissingEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o =>
|
|
o.status === 'completed' && (!o.evidence || o.evidence.length === 0)
|
|
)
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'MISSING_EVIDENCE',
|
|
severity: 'HIGH',
|
|
message: `${affected.length} abgeschlossene Pflicht(en) ohne Nachweis. Ohne Nachweise ist die Erfuellung im Audit nicht belegbar.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Hinterlegen Sie Nachweisdokumente fuer alle abgeschlossenen Pflichten.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 4: MISSING_DESCRIPTION (MEDIUM)
|
|
* Pflicht ohne Beschreibung.
|
|
*/
|
|
function checkMissingDescription(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o => !o.description || o.description.trim() === '')
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'MISSING_DESCRIPTION',
|
|
severity: 'MEDIUM',
|
|
message: `${affected.length} Pflicht(en) ohne Beschreibung. Eine fehlende Beschreibung erschwert die Nachvollziehbarkeit und Umsetzung.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Ergaenzen Sie eine Beschreibung fuer jede Pflicht, die den Inhalt und die Anforderungen erlaeutert.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 5: NO_LEGAL_REFERENCE (HIGH)
|
|
* Pflicht ohne source_article (kein Artikel-Bezug).
|
|
*/
|
|
function checkNoLegalReference(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o => !o.source_article || o.source_article.trim() === '')
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'NO_LEGAL_REFERENCE',
|
|
severity: 'HIGH',
|
|
message: `${affected.length} Pflicht(en) ohne Artikel-/Paragraphen-Referenz. Ohne Rechtsbezug ist die Pflicht im Audit nicht nachvollziehbar.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Ergaenzen Sie die Rechtsgrundlage (z.B. Art. 32 DSGVO) fuer jede Pflicht.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 9: MISSING_LINKED_SYSTEMS (MEDIUM)
|
|
* Pflicht ohne verknuepfte Systeme/Verarbeitungen.
|
|
*/
|
|
function checkMissingLinkedSystems(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o => !o.linked_systems || o.linked_systems.length === 0)
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'MISSING_LINKED_SYSTEMS',
|
|
severity: 'MEDIUM',
|
|
message: `${affected.length} Pflicht(en) ohne verknuepfte Systeme oder Verarbeitungstaetigkeiten. Ohne Systemzuordnung fehlt der operative Bezug.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Ordnen Sie jeder Pflicht die betroffenen IT-Systeme oder Verarbeitungstaetigkeiten zu.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 11: CRITICAL_WITHOUT_EVIDENCE (CRITICAL)
|
|
* Critical-Pflicht ohne Evidence.
|
|
*/
|
|
function checkCriticalWithoutEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o =>
|
|
o.priority === 'critical' && (!o.evidence || o.evidence.length === 0)
|
|
)
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'CRITICAL_WITHOUT_EVIDENCE',
|
|
severity: 'CRITICAL',
|
|
message: `${affected.length} kritische Pflicht(en) ohne Nachweis. Kritische Pflichten erfordern zwingend eine Dokumentation der Erfuellung.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Hinterlegen Sie umgehend Nachweise fuer alle kritischen Pflichten.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 12: MISSING_VENDOR_LINK (MEDIUM)
|
|
* Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter.
|
|
*/
|
|
function checkMissingVendorLink(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const affected = obligations.filter(o =>
|
|
o.source_article?.includes('Art. 28') &&
|
|
(!o.linked_vendor_ids || o.linked_vendor_ids.length === 0)
|
|
)
|
|
if (affected.length === 0) return null
|
|
return {
|
|
type: 'MISSING_VENDOR_LINK',
|
|
severity: 'MEDIUM',
|
|
message: `${affected.length} Art.-28-Pflicht(en) ohne verknuepften Auftragsverarbeiter.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Verknuepfen Sie Art.-28-Pflichten mit den betroffenen Auftragsverarbeitern im Vendor Register.',
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// AGGREGATE CHECKS (6-8, 10)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check 6: INCOMPLETE_REGULATION (HIGH)
|
|
* Regulierung, bei der alle Pflichten pending/overdue sind.
|
|
*/
|
|
function checkIncompleteRegulation(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const bySource = new Map<string, Obligation[]>()
|
|
for (const o of obligations) {
|
|
const src = o.source || 'Unbekannt'
|
|
if (!bySource.has(src)) bySource.set(src, [])
|
|
bySource.get(src)!.push(o)
|
|
}
|
|
|
|
const incompleteRegs: string[] = []
|
|
const affectedIds: string[] = []
|
|
|
|
for (const [source, obls] of bySource.entries()) {
|
|
if (obls.length < 2) continue // Skip single-obligation regulations
|
|
const allStalled = obls.every(o => o.status === 'pending' || o.status === 'overdue')
|
|
if (allStalled) {
|
|
incompleteRegs.push(source)
|
|
affectedIds.push(...obls.map(o => o.id))
|
|
}
|
|
}
|
|
|
|
if (incompleteRegs.length === 0) return null
|
|
|
|
return {
|
|
type: 'INCOMPLETE_REGULATION',
|
|
severity: 'HIGH',
|
|
message: `${incompleteRegs.length} Regulierung(en) vollstaendig ohne Umsetzung: ${incompleteRegs.join(', ')}. Alle Pflichten sind ausstehend oder ueberfaellig.`,
|
|
affectedObligations: affectedIds,
|
|
recommendation: 'Beginnen Sie mit der Umsetzung der wichtigsten Pflichten in den betroffenen Regulierungen.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 7: HIGH_PRIORITY_NOT_STARTED (CRITICAL)
|
|
* Critical/High-Pflicht seit > 30 Tagen pending.
|
|
*/
|
|
function checkHighPriorityNotStarted(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const now = new Date()
|
|
const affected = obligations.filter(o => {
|
|
if (o.status !== 'pending') return false
|
|
if (o.priority !== 'critical' && o.priority !== 'high') return false
|
|
if (!o.created_at) return false
|
|
return daysBetween(new Date(o.created_at), now) > 30
|
|
})
|
|
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'HIGH_PRIORITY_NOT_STARTED',
|
|
severity: 'CRITICAL',
|
|
message: `${affected.length} hochprioritaere Pflicht(en) seit ueber 30 Tagen nicht begonnen. Dies deutet auf organisatorische Blockaden oder fehlende Priorisierung hin.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Starten Sie umgehend mit der Bearbeitung dieser kritischen/hohen Pflichten und erstellen Sie einen Umsetzungsplan.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 8: STALE_PENDING (LOW)
|
|
* Pflicht seit > 90 Tagen pending.
|
|
*/
|
|
function checkStalePending(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
const now = new Date()
|
|
const affected = obligations.filter(o => {
|
|
if (o.status !== 'pending') return false
|
|
if (!o.created_at) return false
|
|
return daysBetween(new Date(o.created_at), now) > 90
|
|
})
|
|
|
|
if (affected.length === 0) return null
|
|
|
|
return {
|
|
type: 'STALE_PENDING',
|
|
severity: 'LOW',
|
|
message: `${affected.length} Pflicht(en) seit ueber 90 Tagen ausstehend. Langfristig unbearbeitete Pflichten sollten priorisiert oder als nicht relevant markiert werden.`,
|
|
affectedObligations: affected.map(o => o.id),
|
|
recommendation: 'Pruefen Sie, ob die Pflichten weiterhin relevant sind, und setzen Sie Prioritaeten fuer die Umsetzung.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check 10: NO_REVIEW_PROCESS (MEDIUM)
|
|
* Keine einzige Pflicht hat review_date.
|
|
*/
|
|
function checkNoReviewProcess(obligations: Obligation[]): ObligationComplianceIssue | null {
|
|
if (obligations.length === 0) return null
|
|
const hasAnyReview = obligations.some(o => o.review_date)
|
|
if (hasAnyReview) return null
|
|
|
|
return {
|
|
type: 'NO_REVIEW_PROCESS',
|
|
severity: 'MEDIUM',
|
|
message: 'Keine Pflicht hat ein Pruefungsdatum (review_date). Ohne regelmaessige Ueberpruefung ist die Aktualitaet des Pflichtenregisters nicht gewaehrleistet.',
|
|
affectedObligations: [],
|
|
recommendation: 'Fuehren Sie ein Pruefintervall ein und setzen Sie review_date fuer alle Pflichten.',
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN COMPLIANCE CHECK
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Pflichten durch.
|
|
*/
|
|
export function runObligationComplianceCheck(obligations: Obligation[]): ObligationComplianceCheckResult {
|
|
const issues: ObligationComplianceIssue[] = []
|
|
|
|
const checks = [
|
|
checkMissingResponsible(obligations),
|
|
checkOverdueDeadline(obligations),
|
|
checkMissingEvidence(obligations),
|
|
checkMissingDescription(obligations),
|
|
checkNoLegalReference(obligations),
|
|
checkIncompleteRegulation(obligations),
|
|
checkHighPriorityNotStarted(obligations),
|
|
checkStalePending(obligations),
|
|
checkMissingLinkedSystems(obligations),
|
|
checkNoReviewProcess(obligations),
|
|
checkCriticalWithoutEvidence(obligations),
|
|
checkMissingVendorLink(obligations),
|
|
]
|
|
|
|
for (const issue of checks) {
|
|
if (issue !== null) {
|
|
issues.push(issue)
|
|
}
|
|
}
|
|
|
|
// Calculate score
|
|
const summary = { total: issues.length, critical: 0, high: 0, medium: 0, low: 0 }
|
|
for (const issue of issues) {
|
|
switch (issue.severity) {
|
|
case 'CRITICAL': summary.critical++; break
|
|
case 'HIGH': summary.high++; break
|
|
case 'MEDIUM': summary.medium++; break
|
|
case 'LOW': summary.low++; break
|
|
}
|
|
}
|
|
|
|
const rawScore = 100 - (summary.critical * 15 + summary.high * 10 + summary.medium * 5 + summary.low * 2)
|
|
const score = Math.max(0, rawScore)
|
|
|
|
return {
|
|
score,
|
|
issues,
|
|
summary,
|
|
checkedAt: new Date().toISOString(),
|
|
}
|
|
}
|