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>
880 lines
31 KiB
TypeScript
880 lines
31 KiB
TypeScript
// =============================================================================
|
|
// Loeschfristen Module - Loeschkonzept Document Generator
|
|
// Generates a printable, audit-ready HTML document according to DSGVO Art. 5/17/30
|
|
// =============================================================================
|
|
|
|
import type {
|
|
LoeschfristPolicy,
|
|
RetentionDriverType,
|
|
} from './loeschfristen-types'
|
|
|
|
import {
|
|
RETENTION_DRIVER_META,
|
|
DELETION_METHOD_LABELS,
|
|
STATUS_LABELS,
|
|
TRIGGER_LABELS,
|
|
REVIEW_INTERVAL_LABELS,
|
|
formatRetentionDuration,
|
|
getEffectiveDeletionTrigger,
|
|
getActiveLegalHolds,
|
|
} from './loeschfristen-types'
|
|
|
|
import type { ComplianceCheckResult, ComplianceIssueSeverity } from './loeschfristen-compliance'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export interface LoeschkonzeptOrgHeader {
|
|
organizationName: string
|
|
industry: string
|
|
dpoName: string
|
|
dpoContact: string
|
|
responsiblePerson: string
|
|
locations: string[]
|
|
employeeCount: string
|
|
loeschkonzeptVersion: string
|
|
lastReviewDate: string
|
|
nextReviewDate: string
|
|
reviewInterval: string
|
|
}
|
|
|
|
export interface LoeschkonzeptRevision {
|
|
version: string
|
|
date: string
|
|
author: string
|
|
changes: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// DEFAULTS
|
|
// =============================================================================
|
|
|
|
export function createDefaultLoeschkonzeptOrgHeader(): LoeschkonzeptOrgHeader {
|
|
const now = new Date()
|
|
const nextYear = new Date()
|
|
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
|
|
|
return {
|
|
organizationName: '',
|
|
industry: '',
|
|
dpoName: '',
|
|
dpoContact: '',
|
|
responsiblePerson: '',
|
|
locations: [],
|
|
employeeCount: '',
|
|
loeschkonzeptVersion: '1.0',
|
|
lastReviewDate: now.toISOString().split('T')[0],
|
|
nextReviewDate: nextYear.toISOString().split('T')[0],
|
|
reviewInterval: 'Jaehrlich',
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// SEVERITY LABELS (for Compliance Status section)
|
|
// =============================================================================
|
|
|
|
const SEVERITY_LABELS_DE: Record<ComplianceIssueSeverity, string> = {
|
|
CRITICAL: 'Kritisch',
|
|
HIGH: 'Hoch',
|
|
MEDIUM: 'Mittel',
|
|
LOW: 'Niedrig',
|
|
}
|
|
|
|
const SEVERITY_COLORS: Record<ComplianceIssueSeverity, string> = {
|
|
CRITICAL: '#dc2626',
|
|
HIGH: '#ea580c',
|
|
MEDIUM: '#d97706',
|
|
LOW: '#6b7280',
|
|
}
|
|
|
|
// =============================================================================
|
|
// HTML DOCUMENT BUILDER
|
|
// =============================================================================
|
|
|
|
export function buildLoeschkonzeptHtml(
|
|
policies: LoeschfristPolicy[],
|
|
orgHeader: LoeschkonzeptOrgHeader,
|
|
vvtActivities: Array<{ id: string; vvt_id?: string; vvtId?: string; name?: string; activity_name?: string }>,
|
|
complianceResult: ComplianceCheckResult | null,
|
|
revisions: LoeschkonzeptRevision[]
|
|
): string {
|
|
const today = new Date().toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
|
|
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
|
|
const orgName = orgHeader.organizationName || 'Organisation'
|
|
|
|
// Collect unique storage locations across all policies
|
|
const allStorageLocations = new Set<string>()
|
|
for (const p of activePolicies) {
|
|
for (const loc of p.storageLocations) {
|
|
allStorageLocations.add(loc.name || loc.type)
|
|
}
|
|
}
|
|
|
|
// Collect unique responsible roles
|
|
const roleMap = new Map<string, string[]>()
|
|
for (const p of activePolicies) {
|
|
const role = p.responsibleRole || p.responsiblePerson || 'Nicht zugewiesen'
|
|
if (!roleMap.has(role)) roleMap.set(role, [])
|
|
roleMap.get(role)!.push(p.dataObjectName || p.policyId)
|
|
}
|
|
|
|
// Collect active legal holds
|
|
const allActiveLegalHolds: Array<{ policy: string; hold: LoeschfristPolicy['legalHolds'][0] }> = []
|
|
for (const p of activePolicies) {
|
|
for (const h of getActiveLegalHolds(p)) {
|
|
allActiveLegalHolds.push({ policy: p.dataObjectName || p.policyId, hold: h })
|
|
}
|
|
}
|
|
|
|
// Build VVT cross-reference data
|
|
const vvtRefs: Array<{ policyName: string; policyId: string; vvtId: string; vvtName: string }> = []
|
|
for (const p of activePolicies) {
|
|
for (const linkedId of p.linkedVVTActivityIds) {
|
|
const activity = vvtActivities.find(a => a.id === linkedId)
|
|
if (activity) {
|
|
vvtRefs.push({
|
|
policyName: p.dataObjectName || p.policyId,
|
|
policyId: p.policyId,
|
|
vvtId: activity.vvt_id || activity.vvtId || linkedId.substring(0, 8),
|
|
vvtName: activity.activity_name || activity.name || 'Unbenannte Verarbeitungstaetigkeit',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build vendor cross-reference data
|
|
const vendorRefs: Array<{ policyName: string; policyId: string; vendorId: string; duration: string }> = []
|
|
for (const p of activePolicies) {
|
|
if (p.linkedVendorIds && p.linkedVendorIds.length > 0) {
|
|
for (const vendorId of p.linkedVendorIds) {
|
|
vendorRefs.push({
|
|
policyName: p.dataObjectName || p.policyId,
|
|
policyId: p.policyId,
|
|
vendorId,
|
|
duration: formatRetentionDuration(p.retentionDuration, p.retentionUnit),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// HTML Template
|
|
// =========================================================================
|
|
|
|
let html = `<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Loeschkonzept — ${escHtml(orgName)}</title>
|
|
<style>
|
|
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
font-size: 10pt;
|
|
line-height: 1.5;
|
|
color: #1e293b;
|
|
}
|
|
|
|
/* Cover */
|
|
.cover {
|
|
min-height: 90vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
text-align: center;
|
|
page-break-after: always;
|
|
}
|
|
.cover h1 {
|
|
font-size: 28pt;
|
|
color: #5b21b6;
|
|
margin-bottom: 8px;
|
|
font-weight: 700;
|
|
}
|
|
.cover .subtitle {
|
|
font-size: 14pt;
|
|
color: #7c3aed;
|
|
margin-bottom: 40px;
|
|
}
|
|
.cover .org-info {
|
|
background: #f5f3ff;
|
|
border: 1px solid #ddd6fe;
|
|
border-radius: 8px;
|
|
padding: 24px 40px;
|
|
text-align: left;
|
|
width: 400px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.cover .org-info div { margin-bottom: 6px; }
|
|
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
|
.cover .legal-ref {
|
|
font-size: 9pt;
|
|
color: #64748b;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
/* TOC */
|
|
.toc {
|
|
page-break-after: always;
|
|
padding-top: 40px;
|
|
}
|
|
.toc h2 {
|
|
font-size: 18pt;
|
|
color: #5b21b6;
|
|
margin-bottom: 20px;
|
|
border-bottom: 2px solid #5b21b6;
|
|
padding-bottom: 8px;
|
|
}
|
|
.toc-entry {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 6px 0;
|
|
border-bottom: 1px dotted #cbd5e1;
|
|
font-size: 10pt;
|
|
}
|
|
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
|
|
|
/* Sections */
|
|
.section {
|
|
page-break-inside: avoid;
|
|
margin-bottom: 24px;
|
|
}
|
|
.section-header {
|
|
font-size: 14pt;
|
|
color: #5b21b6;
|
|
font-weight: 700;
|
|
margin: 30px 0 12px 0;
|
|
border-bottom: 2px solid #ddd6fe;
|
|
padding-bottom: 6px;
|
|
}
|
|
.section-body { margin-bottom: 16px; }
|
|
|
|
/* Tables */
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 10px 0 16px 0;
|
|
font-size: 9pt;
|
|
}
|
|
th, td {
|
|
border: 1px solid #e2e8f0;
|
|
padding: 6px 8px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
th {
|
|
background: #f5f3ff;
|
|
color: #5b21b6;
|
|
font-weight: 600;
|
|
font-size: 8.5pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
tr:nth-child(even) td { background: #faf5ff; }
|
|
|
|
/* Detail cards */
|
|
.policy-detail {
|
|
page-break-inside: avoid;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
margin-bottom: 16px;
|
|
overflow: hidden;
|
|
}
|
|
.policy-detail-header {
|
|
background: #f5f3ff;
|
|
padding: 8px 12px;
|
|
font-weight: 700;
|
|
color: #5b21b6;
|
|
border-bottom: 1px solid #ddd6fe;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
.policy-detail-body { padding: 0; }
|
|
.policy-detail-body table { margin: 0; }
|
|
.policy-detail-body th { width: 200px; }
|
|
|
|
/* Badges */
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 1px 8px;
|
|
border-radius: 9999px;
|
|
font-size: 8pt;
|
|
font-weight: 600;
|
|
}
|
|
.badge-active { background: #dcfce7; color: #166534; }
|
|
.badge-draft { background: #f3f4f6; color: #374151; }
|
|
.badge-review { background: #fef9c3; color: #854d0e; }
|
|
.badge-critical { background: #fecaca; color: #991b1b; }
|
|
.badge-high { background: #fed7aa; color: #9a3412; }
|
|
.badge-medium { background: #fef3c7; color: #92400e; }
|
|
.badge-low { background: #f3f4f6; color: #4b5563; }
|
|
|
|
/* Principles */
|
|
.principle {
|
|
margin-bottom: 10px;
|
|
padding-left: 20px;
|
|
position: relative;
|
|
}
|
|
.principle::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 6px;
|
|
width: 10px;
|
|
height: 10px;
|
|
background: #7c3aed;
|
|
border-radius: 50%;
|
|
}
|
|
.principle strong { color: #5b21b6; }
|
|
|
|
/* Score */
|
|
.score-box {
|
|
display: inline-block;
|
|
padding: 4px 16px;
|
|
border-radius: 8px;
|
|
font-size: 18pt;
|
|
font-weight: 700;
|
|
margin-right: 12px;
|
|
}
|
|
.score-excellent { background: #dcfce7; color: #166534; }
|
|
.score-good { background: #dbeafe; color: #1e40af; }
|
|
.score-needs-work { background: #fef3c7; color: #92400e; }
|
|
.score-poor { background: #fecaca; color: #991b1b; }
|
|
|
|
/* Footer */
|
|
.page-footer {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
padding: 8px 18mm;
|
|
font-size: 7.5pt;
|
|
color: #94a3b8;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
|
|
/* Print */
|
|
@media print {
|
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
.no-print { display: none !important; }
|
|
.page-break { page-break-after: always; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 0: Cover Page
|
|
// =========================================================================
|
|
html += `
|
|
<div class="cover">
|
|
<h1>Loeschkonzept</h1>
|
|
<div class="subtitle">gemaess Art. 5 Abs. 1 lit. e, Art. 17, Art. 30 DSGVO</div>
|
|
<div class="org-info">
|
|
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
|
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
|
${orgHeader.dpoName ? `<div><span class="label">Datenschutzbeauftragter:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
|
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
|
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
|
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
|
|
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
|
|
</div>
|
|
<div class="legal-ref">
|
|
Version ${escHtml(orgHeader.loeschkonzeptVersion)} | Stand: ${today}<br/>
|
|
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
|
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Table of Contents
|
|
// =========================================================================
|
|
const sections = [
|
|
'Ziel und Zweck',
|
|
'Geltungsbereich',
|
|
'Grundprinzipien der Datenspeicherung',
|
|
'Loeschregeln-Uebersicht',
|
|
'Detaillierte Loeschregeln',
|
|
'VVT-Verknuepfung',
|
|
'Auftragsverarbeiter mit Loeschpflichten',
|
|
'Legal Hold Verfahren',
|
|
'Verantwortlichkeiten',
|
|
'Pruef- und Revisionszyklus',
|
|
'Compliance-Status',
|
|
'Aenderungshistorie',
|
|
]
|
|
|
|
html += `
|
|
<div class="toc">
|
|
<h2>Inhaltsverzeichnis</h2>
|
|
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 1: Ziel und Zweck
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">1. Ziel und Zweck</div>
|
|
<div class="section-body">
|
|
<p>Dieses Loeschkonzept definiert die systematischen Regeln und Verfahren fuer die Loeschung
|
|
personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Es dient der Umsetzung
|
|
folgender DSGVO-Anforderungen:</p>
|
|
<table>
|
|
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
|
|
<tr><td><strong>Art. 5 Abs. 1 lit. e DSGVO</strong></td><td>Grundsatz der Speicherbegrenzung — personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke der Verarbeitung erforderlich ist.</td></tr>
|
|
<tr><td><strong>Art. 17 DSGVO</strong></td><td>Recht auf Loeschung („Recht auf Vergessenwerden“) — Betroffene haben das Recht, die Loeschung ihrer Daten zu verlangen.</td></tr>
|
|
<tr><td><strong>Art. 30 DSGVO</strong></td><td>Verzeichnis von Verarbeitungstaetigkeiten — vorgesehene Fristen fuer die Loeschung der verschiedenen Datenkategorien muessen dokumentiert werden.</td></tr>
|
|
</table>
|
|
<p>Das Loeschkonzept ist fester Bestandteil des Datenschutz-Managementsystems und wird
|
|
regelmaessig ueberprueft und aktualisiert.</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 2: Geltungsbereich
|
|
// =========================================================================
|
|
const storageListHtml = allStorageLocations.size > 0
|
|
? Array.from(allStorageLocations).map(s => `<li>${escHtml(s)}</li>`).join('')
|
|
: '<li><em>Keine Speicherorte dokumentiert</em></li>'
|
|
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">2. Geltungsbereich</div>
|
|
<div class="section-body">
|
|
<p>Dieses Loeschkonzept gilt fuer alle personenbezogenen Daten, die von <strong>${escHtml(orgName)}</strong>
|
|
verarbeitet werden. Es umfasst <strong>${activePolicies.length}</strong> Loeschregeln fuer folgende Systeme und Speicherorte:</p>
|
|
<ul style="margin: 8px 0 8px 24px;">
|
|
${storageListHtml}
|
|
</ul>
|
|
<p>Saemtliche Verarbeitungstaetigkeiten, die im Verzeichnis von Verarbeitungstaetigkeiten (VVT)
|
|
erfasst sind, werden durch dieses Loeschkonzept abgedeckt.</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 3: Grundprinzipien
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">3. Grundprinzipien der Datenspeicherung</div>
|
|
<div class="section-body">
|
|
<div class="principle"><strong>Speicherbegrenzung:</strong> Personenbezogene Daten werden nur so lange gespeichert, wie es fuer den jeweiligen Verarbeitungszweck erforderlich ist (Art. 5 Abs. 1 lit. e DSGVO).</div>
|
|
<div class="principle"><strong>3-Level-Loeschlogik:</strong> Die Loeschung folgt einer dreistufigen Priorisierung: (1) Zweckende, (2) gesetzliche Aufbewahrungspflichten, (3) Legal Hold — jeweils mit der laengsten Frist als massgeblich.</div>
|
|
<div class="principle"><strong>Dokumentationspflicht:</strong> Jede Loeschregel ist dokumentiert mit Rechtsgrundlage, Frist, Loeschmethode und Verantwortlichkeit.</div>
|
|
<div class="principle"><strong>Regelmaessige Ueberpruefung:</strong> Alle Loeschregeln werden im definierten Intervall ueberprueft und bei Bedarf angepasst.</div>
|
|
<div class="principle"><strong>Datenschutz durch Technikgestaltung:</strong> Loeschmechanismen werden moeglichst automatisiert, um menschliche Fehler zu minimieren (Art. 25 DSGVO).</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 4: Loeschregeln-Uebersicht
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section page-break">
|
|
<div class="section-header">4. Loeschregeln-Uebersicht</div>
|
|
<div class="section-body">
|
|
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${activePolicies.length} aktiven Loeschregeln:</p>
|
|
<table>
|
|
<tr>
|
|
<th>LF-Nr.</th>
|
|
<th>Datenobjekt</th>
|
|
<th>Loeschtrigger</th>
|
|
<th>Aufbewahrungsfrist</th>
|
|
<th>Loeschmethode</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
`
|
|
for (const p of activePolicies) {
|
|
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
|
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
|
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
|
const statusLabel = STATUS_LABELS[p.status]
|
|
const statusClass = p.status === 'ACTIVE' ? 'badge-active' : p.status === 'REVIEW_NEEDED' ? 'badge-review' : 'badge-draft'
|
|
|
|
html += ` <tr>
|
|
<td>${escHtml(p.policyId)}</td>
|
|
<td>${escHtml(p.dataObjectName)}</td>
|
|
<td>${escHtml(trigger)}</td>
|
|
<td>${escHtml(duration)}</td>
|
|
<td>${escHtml(method)}</td>
|
|
<td><span class="badge ${statusClass}">${escHtml(statusLabel)}</span></td>
|
|
</tr>
|
|
`
|
|
}
|
|
|
|
html += ` </table>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 5: Detaillierte Loeschregeln
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">5. Detaillierte Loeschregeln</div>
|
|
<div class="section-body">
|
|
`
|
|
for (const p of activePolicies) {
|
|
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
|
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
|
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
|
const statusLabel = STATUS_LABELS[p.status]
|
|
const driverLabel = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.label || p.retentionDriver : '-'
|
|
const driverStatute = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.statute || '' : ''
|
|
const locations = p.storageLocations.map(l => l.name || l.type).join(', ') || '-'
|
|
const responsible = [p.responsiblePerson, p.responsibleRole].filter(s => s.trim()).join(' / ') || '-'
|
|
const activeHolds = getActiveLegalHolds(p)
|
|
|
|
html += `
|
|
<div class="policy-detail">
|
|
<div class="policy-detail-header">
|
|
<span>${escHtml(p.policyId)} — ${escHtml(p.dataObjectName)}</span>
|
|
<span class="badge ${p.status === 'ACTIVE' ? 'badge-active' : 'badge-draft'}">${escHtml(statusLabel)}</span>
|
|
</div>
|
|
<div class="policy-detail-body">
|
|
<table>
|
|
<tr><th>Beschreibung</th><td>${escHtml(p.description || '-')}</td></tr>
|
|
<tr><th>Betroffenengruppen</th><td>${escHtml(p.affectedGroups.join(', ') || '-')}</td></tr>
|
|
<tr><th>Datenkategorien</th><td>${escHtml(p.dataCategories.join(', ') || '-')}</td></tr>
|
|
<tr><th>Verarbeitungszweck</th><td>${escHtml(p.primaryPurpose || '-')}</td></tr>
|
|
<tr><th>Loeschtrigger</th><td>${escHtml(trigger)}</td></tr>
|
|
<tr><th>Aufbewahrungstreiber</th><td>${escHtml(driverLabel)}${driverStatute ? ` (${escHtml(driverStatute)})` : ''}</td></tr>
|
|
<tr><th>Aufbewahrungsfrist</th><td>${escHtml(duration)}</td></tr>
|
|
<tr><th>Startereignis</th><td>${escHtml(p.startEvent || '-')}</td></tr>
|
|
<tr><th>Loeschmethode</th><td>${escHtml(method)}</td></tr>
|
|
<tr><th>Loeschmethode (Detail)</th><td>${escHtml(p.deletionMethodDetail || '-')}</td></tr>
|
|
<tr><th>Speicherorte</th><td>${escHtml(locations)}</td></tr>
|
|
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
|
|
<tr><th>Pruefintervall</th><td>${escHtml(REVIEW_INTERVAL_LABELS[p.reviewInterval] || p.reviewInterval)}</td></tr>
|
|
${activeHolds.length > 0 ? `<tr><th>Aktive Legal Holds</th><td>${activeHolds.map(h => `${escHtml(h.reason)} (seit ${formatDateDE(h.startDate)})`).join('<br/>')}</td></tr>` : ''}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
html += ` </div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 6: VVT-Verknuepfung
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section page-break">
|
|
<div class="section-header">6. VVT-Verknuepfung</div>
|
|
<div class="section-body">
|
|
<p>Die folgende Tabelle zeigt die Verknuepfung zwischen Loeschregeln und Verarbeitungstaetigkeiten
|
|
im VVT (Art. 30 DSGVO):</p>
|
|
`
|
|
if (vvtRefs.length > 0) {
|
|
html += ` <table>
|
|
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>VVT-Nr.</th><th>Verarbeitungstaetigkeit</th></tr>
|
|
`
|
|
for (const ref of vvtRefs) {
|
|
html += ` <tr>
|
|
<td>${escHtml(ref.policyName)}</td>
|
|
<td>${escHtml(ref.policyId)}</td>
|
|
<td>${escHtml(ref.vvtId)}</td>
|
|
<td>${escHtml(ref.vvtName)}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
html += ` </table>
|
|
`
|
|
} else {
|
|
html += ` <p><em>Noch keine VVT-Verknuepfungen dokumentiert. Verknuepfen Sie Ihre Loeschregeln
|
|
mit den entsprechenden Verarbeitungstaetigkeiten im Editor-Tab.</em></p>
|
|
`
|
|
}
|
|
|
|
html += ` </div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 7: Auftragsverarbeiter mit Loeschpflichten
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">7. Auftragsverarbeiter mit Loeschpflichten</div>
|
|
<div class="section-body">
|
|
<p>Die folgende Tabelle zeigt Loeschregeln, die mit Auftragsverarbeitern verknuepft sind.
|
|
Diese Verknuepfungen stellen sicher, dass auch bei extern verarbeiteten Daten die Loeschpflichten
|
|
eingehalten werden (Art. 28 DSGVO).</p>
|
|
`
|
|
if (vendorRefs.length > 0) {
|
|
html += ` <table>
|
|
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>Auftragsverarbeiter (ID)</th><th>Aufbewahrungsfrist</th></tr>
|
|
`
|
|
for (const ref of vendorRefs) {
|
|
html += ` <tr>
|
|
<td>${escHtml(ref.policyName)}</td>
|
|
<td>${escHtml(ref.policyId)}</td>
|
|
<td>${escHtml(ref.vendorId)}</td>
|
|
<td>${escHtml(ref.duration)}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
html += ` </table>
|
|
`
|
|
} else {
|
|
html += ` <p><em>Noch keine Auftragsverarbeiter mit Loeschregeln verknuepft. Verknuepfen Sie Ihre
|
|
Loeschregeln mit den entsprechenden Auftragsverarbeitern im Editor-Tab.</em></p>
|
|
`
|
|
}
|
|
|
|
html += ` </div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 8: Legal Hold Verfahren
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">8. Legal Hold Verfahren</div>
|
|
<div class="section-body">
|
|
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
|
|
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden,
|
|
bis der Legal Hold aufgehoben wird.</p>
|
|
<p><strong>Verfahrensschritte:</strong></p>
|
|
<ol style="margin: 8px 0 8px 24px;">
|
|
<li>Rechtsabteilung/DSB identifiziert betroffene Datenkategorien</li>
|
|
<li>Legal Hold wird im System aktiviert (Status: Aktiv)</li>
|
|
<li>Automatische Loeschung wird fuer betroffene Policies ausgesetzt</li>
|
|
<li>Regelmaessige Pruefung, ob der Legal Hold noch erforderlich ist</li>
|
|
<li>Nach Aufhebung: Regulaere Loeschfristen greifen wieder</li>
|
|
</ol>
|
|
`
|
|
if (allActiveLegalHolds.length > 0) {
|
|
html += ` <p><strong>Aktuell aktive Legal Holds (${allActiveLegalHolds.length}):</strong></p>
|
|
<table>
|
|
<tr><th>Datenobjekt</th><th>Grund</th><th>Rechtsgrundlage</th><th>Seit</th><th>Voraussichtlich bis</th></tr>
|
|
`
|
|
for (const { policy, hold } of allActiveLegalHolds) {
|
|
html += ` <tr>
|
|
<td>${escHtml(policy)}</td>
|
|
<td>${escHtml(hold.reason)}</td>
|
|
<td>${escHtml(hold.legalBasis)}</td>
|
|
<td>${formatDateDE(hold.startDate)}</td>
|
|
<td>${hold.expectedEndDate ? formatDateDE(hold.expectedEndDate) : 'Unbefristet'}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
html += ` </table>
|
|
`
|
|
} else {
|
|
html += ` <p><em>Derzeit sind keine aktiven Legal Holds vorhanden.</em></p>
|
|
`
|
|
}
|
|
|
|
html += ` </div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 9: Verantwortlichkeiten
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">9. Verantwortlichkeiten</div>
|
|
<div class="section-body">
|
|
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
|
|
die Loeschverantwortung tragen:</p>
|
|
<table>
|
|
<tr><th>Rolle / Verantwortlich</th><th>Datenobjekte</th><th>Anzahl</th></tr>
|
|
`
|
|
for (const [role, objects] of roleMap.entries()) {
|
|
html += ` <tr>
|
|
<td>${escHtml(role)}</td>
|
|
<td>${objects.map(o => escHtml(o)).join(', ')}</td>
|
|
<td>${objects.length}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
|
|
html += ` </table>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 10: Pruef- und Revisionszyklus
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">10. Pruef- und Revisionszyklus</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
|
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
|
|
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
|
|
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
|
|
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.loeschkonzeptVersion)}</td></tr>
|
|
</table>
|
|
<p style="margin-top: 8px;">Bei jeder Pruefung wird das Loeschkonzept auf folgende Punkte ueberprueft:</p>
|
|
<ul style="margin: 8px 0 8px 24px;">
|
|
<li>Vollstaendigkeit aller Loeschregeln (neue Verarbeitungen erfasst?)</li>
|
|
<li>Aktualitaet der gesetzlichen Aufbewahrungsfristen</li>
|
|
<li>Wirksamkeit der technischen Loeschmechanismen</li>
|
|
<li>Einhaltung der definierten Loeschfristen</li>
|
|
<li>Angemessenheit der Verantwortlichkeiten</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 11: Compliance-Status
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section page-break">
|
|
<div class="section-header">11. Compliance-Status</div>
|
|
<div class="section-body">
|
|
`
|
|
if (complianceResult) {
|
|
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
|
: complianceResult.score >= 75 ? 'score-good'
|
|
: complianceResult.score >= 50 ? 'score-needs-work'
|
|
: 'score-poor'
|
|
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
|
: complianceResult.score >= 75 ? 'Gut'
|
|
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
|
: 'Mangelhaft'
|
|
|
|
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
|
<table style="margin-top: 12px;">
|
|
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
|
<tr><td>Gepruefte Policies</td><td>${complianceResult.stats.total}</td></tr>
|
|
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
|
|
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
|
|
</table>
|
|
`
|
|
if (complianceResult.issues.length > 0) {
|
|
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
|
<table>
|
|
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
|
|
`
|
|
const severityOrder: ComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
for (const sev of severityOrder) {
|
|
const count = complianceResult.stats.bySeverity[sev]
|
|
if (count === 0) continue
|
|
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
|
html += ` <tr>
|
|
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
|
|
<td>${count}</td>
|
|
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
html += ` </table>
|
|
`
|
|
} else {
|
|
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Policies sind konform.</em></p>
|
|
`
|
|
}
|
|
} else {
|
|
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
|
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
|
`
|
|
}
|
|
|
|
html += ` </div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Section 12: Aenderungshistorie
|
|
// =========================================================================
|
|
html += `
|
|
<div class="section">
|
|
<div class="section-header">12. Aenderungshistorie</div>
|
|
<div class="section-body">
|
|
<table>
|
|
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
|
`
|
|
if (revisions.length > 0) {
|
|
for (const rev of revisions) {
|
|
html += ` <tr>
|
|
<td>${escHtml(rev.version)}</td>
|
|
<td>${formatDateDE(rev.date)}</td>
|
|
<td>${escHtml(rev.author)}</td>
|
|
<td>${escHtml(rev.changes)}</td>
|
|
</tr>
|
|
`
|
|
}
|
|
} else {
|
|
html += ` <tr>
|
|
<td>${escHtml(orgHeader.loeschkonzeptVersion)}</td>
|
|
<td>${today}</td>
|
|
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
|
|
<td>Erstversion des Loeschkonzepts</td>
|
|
</tr>
|
|
`
|
|
}
|
|
|
|
html += ` </table>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
// =========================================================================
|
|
// Footer
|
|
// =========================================================================
|
|
html += `
|
|
<div class="page-footer">
|
|
<span>Loeschkonzept — ${escHtml(orgName)}</span>
|
|
<span>Stand: ${today} | Version ${escHtml(orgHeader.loeschkonzeptVersion)}</span>
|
|
</div>
|
|
|
|
</body>
|
|
</html>`
|
|
|
|
return html
|
|
}
|
|
|
|
// =============================================================================
|
|
// INTERNAL HELPERS
|
|
// =============================================================================
|
|
|
|
function escHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
function formatDateDE(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return '-'
|
|
try {
|
|
const date = new Date(dateStr)
|
|
if (isNaN(date.getTime())) return '-'
|
|
return date.toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
} catch {
|
|
return '-'
|
|
}
|
|
}
|