feat(sdk): VVT master libraries, process templates, Loeschfristen profiling + document

VVT: Master library tables (7 catalogs), 500+ seed entries, process templates
with instantiation, library API endpoints + 18 tests.
Loeschfristen: Baseline catalog, compliance checks, profiling engine, HTML document
generator, MkDocs documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-19 11:56:25 +01:00
parent f2819b99af
commit 2a70441eaa
20 changed files with 6621 additions and 9 deletions

View File

@@ -27,12 +27,18 @@ import {
exportPoliciesAsJSON, exportPoliciesAsCSV,
generateComplianceSummary, downloadFile,
} from '@/lib/sdk/loeschfristen-export'
import {
buildLoeschkonzeptHtml,
type LoeschkonzeptOrgHeader,
type LoeschkonzeptRevision,
createDefaultLoeschkonzeptOrgHeader,
} from '@/lib/sdk/loeschfristen-document'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export' | 'loeschkonzept'
// ---------------------------------------------------------------------------
// Helper: TagInput
@@ -130,6 +136,10 @@ export default function LoeschfristenPage() {
// ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([])
// ---- Loeschkonzept document state ----
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
// --------------------------------------------------------------------------
// Persistence (API-backed)
// --------------------------------------------------------------------------
@@ -247,6 +257,48 @@ export default function LoeschfristenPage() {
})
}, [tab, editingId])
// Load Loeschkonzept org header from VVT organization data + revisions from localStorage
useEffect(() => {
// Load revisions from localStorage
try {
const raw = localStorage.getItem('bp_loeschkonzept_revisions')
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setRevisions(parsed)
}
} catch { /* ignore */ }
// Load org header from localStorage (user overrides)
try {
const raw = localStorage.getItem('bp_loeschkonzept_orgheader')
if (raw) {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
setOrgHeader(prev => ({ ...prev, ...parsed }))
return // User has saved org header, skip VVT fetch
}
}
} catch { /* ignore */ }
// Fallback: fetch from VVT organization API
fetch('/api/sdk/v1/compliance/vvt/organization')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data) {
setOrgHeader(prev => ({
...prev,
organizationName: data.organization_name || data.organizationName || prev.organizationName,
industry: data.industry || prev.industry,
dpoName: data.dpo_name || data.dpoName || prev.dpoName,
dpoContact: data.dpo_contact || data.dpoContact || prev.dpoContact,
responsiblePerson: data.responsible_person || data.responsiblePerson || prev.responsiblePerson,
employeeCount: data.employee_count || data.employeeCount || prev.employeeCount,
}))
}
})
.catch(() => { /* ignore */ })
}, [])
// --------------------------------------------------------------------------
// Derived
// --------------------------------------------------------------------------
@@ -489,6 +541,7 @@ export default function LoeschfristenPage() {
{ key: 'editor', label: 'Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'export', label: 'Export & Compliance' },
{ key: 'loeschkonzept', label: 'Loeschkonzept' },
]
// --------------------------------------------------------------------------
@@ -2278,6 +2331,314 @@ export default function LoeschfristenPage() {
)
}
// ==========================================================================
// Tab 5: Loeschkonzept Document
// ==========================================================================
function handleOrgHeaderChange(field: keyof LoeschkonzeptOrgHeader, value: string | string[]) {
const updated = { ...orgHeader, [field]: value }
setOrgHeader(updated)
localStorage.setItem('bp_loeschkonzept_orgheader', JSON.stringify(updated))
}
function handleAddRevision() {
const newRev: LoeschkonzeptRevision = {
version: orgHeader.loeschkonzeptVersion,
date: new Date().toISOString().split('T')[0],
author: orgHeader.dpoName || orgHeader.responsiblePerson || '',
changes: '',
}
const updated = [...revisions, newRev]
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handleUpdateRevision(index: number, field: keyof LoeschkonzeptRevision, value: string) {
const updated = revisions.map((r, i) => i === index ? { ...r, [field]: value } : r)
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handleRemoveRevision(index: number) {
const updated = revisions.filter((_, i) => i !== index)
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handlePrintLoeschkonzept() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 300)
}
}
function handleDownloadLoeschkonzeptHtml() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
downloadFile(htmlContent, `loeschkonzept-${new Date().toISOString().split('T')[0]}.html`, 'text/html;charset=utf-8')
}
function renderLoeschkonzept() {
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
return (
<div className="space-y-4">
{/* Action bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Loeschkonzept (Art. 5/17/30 DSGVO)
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Druckfertiges Loeschkonzept mit Deckblatt, Loeschregeln, VVT-Verknuepfung und Compliance-Status.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownloadLoeschkonzeptHtml}
disabled={activePolicies.length === 0}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
HTML herunterladen
</button>
<button
onClick={handlePrintLoeschkonzept}
disabled={activePolicies.length === 0}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
Als PDF drucken
</button>
</div>
</div>
{activePolicies.length === 0 && (
<div className="bg-yellow-50 text-yellow-700 text-sm rounded-lg p-3 border border-yellow-200">
Keine aktiven Policies vorhanden. Erstellen Sie mindestens eine Policy, um das Loeschkonzept zu generieren.
</div>
)}
</div>
{/* Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Organisationsdaten (Deckblatt)</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Organisation</label>
<input
type="text"
value={orgHeader.organizationName}
onChange={e => handleOrgHeaderChange('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name der Organisation"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Branche</label>
<input
type="text"
value={orgHeader.industry}
onChange={e => handleOrgHeaderChange('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. IT / Software"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Datenschutzbeauftragter</label>
<input
type="text"
value={orgHeader.dpoName}
onChange={e => handleOrgHeaderChange('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des DSB"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">DSB-Kontakt</label>
<input
type="text"
value={orgHeader.dpoContact}
onChange={e => handleOrgHeaderChange('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="E-Mail oder Telefon"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Verantwortlicher (Art. 4 Nr. 7)</label>
<input
type="text"
value={orgHeader.responsiblePerson}
onChange={e => handleOrgHeaderChange('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des Verantwortlichen"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Mitarbeiter</label>
<input
type="text"
value={orgHeader.employeeCount}
onChange={e => handleOrgHeaderChange('employeeCount', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. 50-249"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Version</label>
<input
type="text"
value={orgHeader.loeschkonzeptVersion}
onChange={e => handleOrgHeaderChange('loeschkonzeptVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="1.0"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Pruefintervall</label>
<select
value={orgHeader.reviewInterval}
onChange={e => handleOrgHeaderChange('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="Vierteljaehrlich">Vierteljaehrlich</option>
<option value="Halbjaehrlich">Halbjaehrlich</option>
<option value="Jaehrlich">Jaehrlich</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Letzte Pruefung</label>
<input
type="date"
value={orgHeader.lastReviewDate}
onChange={e => handleOrgHeaderChange('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Naechste Pruefung</label>
<input
type="date"
value={orgHeader.nextReviewDate}
onChange={e => handleOrgHeaderChange('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Revisions */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-semibold text-gray-900">Aenderungshistorie</h4>
<button
onClick={handleAddRevision}
className="text-xs bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg px-3 py-1.5 font-medium transition"
>
+ Revision hinzufuegen
</button>
</div>
{revisions.length === 0 ? (
<p className="text-sm text-gray-400">
Noch keine Revisionen. Die Erstversion wird automatisch im Dokument eingefuegt.
</p>
) : (
<div className="space-y-3">
{revisions.map((rev, idx) => (
<div key={idx} className="grid grid-cols-[80px_120px_1fr_1fr_32px] gap-2 items-start">
<input
type="text"
value={rev.version}
onChange={e => handleUpdateRevision(idx, 'version', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="1.1"
/>
<input
type="date"
value={rev.date}
onChange={e => handleUpdateRevision(idx, 'date', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
/>
<input
type="text"
value={rev.author}
onChange={e => handleUpdateRevision(idx, 'author', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="Autor"
/>
<input
type="text"
value={rev.changes}
onChange={e => handleUpdateRevision(idx, 'changes', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="Beschreibung der Aenderungen"
/>
<button
onClick={() => handleRemoveRevision(idx)}
className="text-red-400 hover:text-red-600 p-1"
title="Revision entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
{/* Cover preview */}
<div className="text-center mb-6">
<div className="text-2xl font-bold text-purple-700 mb-1">Loeschkonzept</div>
<div className="text-sm text-purple-500 mb-4">gemaess Art. 5/17/30 DSGVO</div>
<div className="text-sm text-gray-600">
{orgHeader.organizationName || <span className="text-gray-400 italic">Organisation nicht angegeben</span>}
</div>
<div className="text-xs text-gray-400 mt-2">
Version {orgHeader.loeschkonzeptVersion} | {new Date().toLocaleDateString('de-DE')}
</div>
</div>
{/* Section list */}
<div className="border-t border-gray-200 pt-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">11 Sektionen</div>
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
<div>1. Ziel und Zweck</div>
<div>7. Legal Hold Verfahren</div>
<div>2. Geltungsbereich</div>
<div>8. Verantwortlichkeiten</div>
<div>3. Grundprinzipien</div>
<div>9. Pruef-/Revisionszyklus</div>
<div>4. Loeschregeln-Uebersicht</div>
<div>10. Compliance-Status</div>
<div>5. Detaillierte Loeschregeln</div>
<div>11. Aenderungshistorie</div>
<div>6. VVT-Verknuepfung</div>
</div>
</div>
{/* Stats */}
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
{complianceResult && (
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
)}
</div>
</div>
</div>
</div>
)
}
// ==========================================================================
// Main render
// ==========================================================================
@@ -2317,6 +2678,7 @@ export default function LoeschfristenPage() {
{tab === 'editor' && renderEditor()}
{tab === 'generator' && renderGenerator()}
{tab === 'export' && renderExport()}
{tab === 'loeschkonzept' && renderLoeschkonzept()}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
/**
* Integration Tests: Company Profile → Compliance Scope → VVT Generator
*
* Tests the complete data pipeline from Company Profile master data
* through the Compliance Scope Engine to VVT activity generation.
*/
import { describe, it, expect } from 'vitest'
import {
prefillFromCompanyProfile,
exportToVVTAnswers,
getAutoFilledScoringAnswers,
SCOPE_QUESTION_BLOCKS,
} from '../compliance-scope-profiling'
import {
generateActivities,
PROFILING_QUESTIONS,
DEPARTMENT_DATA_CATEGORIES,
SCOPE_PREFILLED_VVT_QUESTIONS,
} from '../vvt-profiling'
import type { ScopeProfilingAnswer } from '../compliance-scope-types'
// Helper
function ans(questionId: string, value: unknown): ScopeProfilingAnswer {
return { questionId, value } as ScopeProfilingAnswer
}
// =============================================================================
// 1. Company Profile → Scope Prefill
// =============================================================================
describe('CompanyProfile → Scope prefill', () => {
it('prefills org_has_dsb when dpoName is set', () => {
const profile = { dpoName: 'Max Mustermann' } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'org_has_dsb')?.value).toBe(true)
})
it('does NOT prefill org_has_dsb when dpoName is empty', () => {
const profile = { dpoName: '' } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'org_has_dsb')).toBeUndefined()
})
it('maps offerings to prod_type correctly', () => {
const profile = { offerings: ['WebApp', 'SaaS', 'API'] } as any
const answers = prefillFromCompanyProfile(profile)
const prodType = answers.find((a) => a.questionId === 'prod_type')
expect(prodType?.value).toEqual(expect.arrayContaining(['webapp', 'saas', 'api']))
})
it('detects webshop in offerings', () => {
const profile = { offerings: ['Webshop'] } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'prod_webshop')?.value).toBe(true)
})
it('returns empty array when profile has no relevant data', () => {
const profile = {} as any
const answers = prefillFromCompanyProfile(profile)
expect(answers).toEqual([])
})
})
describe('CompanyProfile → Scope scoring answers', () => {
it('maps employeeCount to org_employee_count', () => {
const profile = { employeeCount: '50-249' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_employee_count')?.value).toBe('50-249')
})
it('maps industry to org_industry', () => {
const profile = { industry: ['IT & Software', 'Finanzdienstleistungen'] } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_industry')?.value).toBe(
'IT & Software, Finanzdienstleistungen'
)
})
it('maps annualRevenue to org_annual_revenue', () => {
const profile = { annualRevenue: '1-10M' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_annual_revenue')?.value).toBe('1-10M')
})
it('maps businessModel to org_business_model', () => {
const profile = { businessModel: 'B2B' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_business_model')?.value).toBe('B2B')
})
})
// =============================================================================
// 2. Scope → VVT Answer Mapping (exportToVVTAnswers)
// =============================================================================
describe('Scope → VVT answer export', () => {
it('maps scope questions with mapsToVVTQuestion property', () => {
// Block 9: dk_dept_hr maps to dept_hr_categories
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'SALARY_DATA', 'HEALTH_DATA']),
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'SALARY_DATA', 'HEALTH_DATA'])
})
it('maps multiple department data categories', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'BANK_ACCOUNT']),
ans('dk_dept_finance', ['INVOICE_DATA', 'TAX_ID']),
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA']),
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'BANK_ACCOUNT'])
expect(vvtAnswers.dept_finance_categories).toEqual(['INVOICE_DATA', 'TAX_ID'])
expect(vvtAnswers.dept_marketing_categories).toEqual(['EMAIL', 'TRACKING_DATA'])
})
it('ignores scope questions without mapsToVVTQuestion', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('vvt_has_vvt', true), // No mapsToVVTQuestion property
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(Object.keys(vvtAnswers)).toHaveLength(0)
})
it('handles empty scope answers', () => {
const vvtAnswers = exportToVVTAnswers([])
expect(vvtAnswers).toEqual({})
})
})
// =============================================================================
// 3. Scope → VVT Profiling Prefill
// Note: prefillFromScopeAnswers() uses dynamic require('./compliance-scope-profiling')
// which doesn't resolve in vitest. We test the same pipeline by calling
// exportToVVTAnswers() directly (which is what prefillFromScopeAnswers wraps).
// =============================================================================
describe('Scope → VVT Profiling Prefill (via exportToVVTAnswers)', () => {
it('converts scope answers to VVT ProfilingAnswers format', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA']),
ans('dk_dept_finance', ['BANK_ACCOUNT']),
]
const exported = exportToVVTAnswers(scopeAnswers)
// Same transformation as prefillFromScopeAnswers
const profiling: Record<string, unknown> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) profiling[key] = value
}
expect(profiling.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA'])
expect(profiling.dept_finance_categories).toEqual(['BANK_ACCOUNT'])
})
it('filters out null/undefined values', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', null)]
const exported = exportToVVTAnswers(scopeAnswers)
const profiling: Record<string, unknown> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) profiling[key] = value
}
expect(profiling.dept_hr_categories).toBeUndefined()
})
})
// =============================================================================
// 4. VVT Generator — generateActivities
// =============================================================================
describe('generateActivities', () => {
it('always generates 4 IT baseline activities', () => {
const result = generateActivities({})
const names = result.generatedActivities.map((a) => a.name)
expect(result.generatedActivities.length).toBeGreaterThanOrEqual(4)
// IT baselines are always added
const itTemplates = result.generatedActivities.filter(
(a) => a.businessFunction === 'it_operations'
)
expect(itTemplates.length).toBeGreaterThanOrEqual(4)
})
it('triggers HR templates when dept_hr=true', () => {
const result = generateActivities({ dept_hr: true })
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThanOrEqual(3) // mitarbeiter, gehalt, zeiterfassung
})
it('triggers finance templates when dept_finance=true', () => {
const result = generateActivities({ dept_finance: true })
const financeActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'finance'
)
expect(financeActivities.length).toBeGreaterThanOrEqual(2) // buchhaltung, zahlungsverkehr
})
it('enriches activities with US cloud third-country transfer', () => {
const result = generateActivities({ dept_hr: true, transfer_cloud_us: true })
// Every activity should have a US third-country transfer
for (const activity of result.generatedActivities) {
expect(activity.thirdCountryTransfers.some((t) => t.country === 'US')).toBe(true)
}
})
it('adds HEALTH_DATA to HR activities when data_health=true', () => {
const result = generateActivities({ dept_hr: true, data_health: true })
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThan(0)
for (const hr of hrActivities) {
expect(hr.personalDataCategories).toContain('HEALTH_DATA')
}
})
it('calculates Art. 30 Abs. 5 exemption correctly', () => {
// < 250 employees, no special categories → exempt
const result1 = generateActivities({ org_employees: 50 })
expect(result1.art30Abs5Exempt).toBe(true)
// >= 250 employees → not exempt
const result2 = generateActivities({ org_employees: 500 })
expect(result2.art30Abs5Exempt).toBe(false)
// < 250 but with special categories → not exempt
const result3 = generateActivities({ org_employees: 50, data_health: true })
expect(result3.art30Abs5Exempt).toBe(false)
})
it('generates unique VVT IDs for all activities', () => {
const result = generateActivities({
dept_hr: true,
dept_finance: true,
dept_sales: true,
dept_marketing: true,
})
const ids = result.generatedActivities.map((a) => a.vvtId)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('calculates coverage score > 0 for template-generated activities', () => {
const result = generateActivities({ dept_hr: true })
expect(result.coverageScore).toBeGreaterThan(0)
})
})
// =============================================================================
// 5. Full Pipeline: Company Profile → Scope → VVT
// =============================================================================
describe('Full Pipeline: CompanyProfile → Scope → VVT Generation', () => {
// Helper: replicate what prefillFromScopeAnswers does (avoiding dynamic require)
function scopeToProfilingAnswers(
scopeAnswers: ScopeProfilingAnswer[]
): Record<string, string | string[] | number | boolean> {
const exported = exportToVVTAnswers(scopeAnswers)
const profiling: Record<string, string | string[] | number | boolean> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) {
profiling[key] = value as string | string[] | number | boolean
}
}
return profiling
}
it('complete flow: profile with DSB → scope prefill → VVT generation', () => {
// Step 1: Company Profile
const profile = {
dpoName: 'Dr. Datenschutz',
employeeCount: '50-249',
industry: ['IT & Software'],
offerings: ['WebApp', 'SaaS'],
} as any
// Step 2: Prefill scope from profile
const profileAnswers = prefillFromCompanyProfile(profile)
const scoringAnswers = getAutoFilledScoringAnswers(profile)
// Simulate user answering scope questions + auto-prefilled from profile
const userAnswers: ScopeProfilingAnswer[] = [
// Block 8: departments
ans('vvt_departments', ['personal', 'finanzen', 'it']),
// Block 9: data categories per department
ans('dk_dept_hr', ['NAME', 'ADDRESS', 'SALARY_DATA', 'HEALTH_DATA']),
ans('dk_dept_finance', ['NAME', 'BANK_ACCOUNT', 'INVOICE_DATA', 'TAX_ID']),
ans('dk_dept_it', ['USER_ACCOUNTS', 'LOG_DATA', 'DEVICE_DATA']),
// Block 2: data types
ans('data_art9', true),
ans('data_minors', false),
]
const allScopeAnswers = [...profileAnswers, ...scoringAnswers, ...userAnswers]
// Step 3: Export to VVT format
const vvtAnswers = exportToVVTAnswers(allScopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual([
'NAME',
'ADDRESS',
'SALARY_DATA',
'HEALTH_DATA',
])
expect(vvtAnswers.dept_finance_categories).toEqual([
'NAME',
'BANK_ACCOUNT',
'INVOICE_DATA',
'TAX_ID',
])
// Step 4: Prefill VVT profiling from scope (via direct export)
const profilingAnswers = scopeToProfilingAnswers(allScopeAnswers)
// Verify data survived the transformation
expect(profilingAnswers.dept_hr_categories).toEqual([
'NAME',
'ADDRESS',
'SALARY_DATA',
'HEALTH_DATA',
])
// Step 5: Generate VVT activities
// Add department triggers that match Block 8 selections
profilingAnswers.dept_hr = true
profilingAnswers.dept_finance = true
const result = generateActivities(profilingAnswers)
// Verify activities were generated
expect(result.generatedActivities.length).toBeGreaterThan(4) // 4 IT baseline + HR + Finance
// Verify HR activities exist
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThanOrEqual(3)
// Verify finance activities exist
const financeActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'finance'
)
expect(financeActivities.length).toBeGreaterThanOrEqual(2)
})
it('end-to-end: departments selected in scope generate correct VVT activities', () => {
// Simulate a complete scope session with department selections
const scopeAnswers: ScopeProfilingAnswer[] = [
// Block 2: data_art9 maps to data_health in VVT
ans('data_art9', true),
// Block 4: tech_third_country maps to transfer_cloud_us
ans('tech_third_country', true),
// Block 8: departments
ans('vvt_departments', ['personal', 'marketing', 'kundenservice']),
// Block 9: per-department data categories
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS']),
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA', 'CONSENT_DATA']),
ans('dk_dept_support', ['NAME', 'TICKET_DATA', 'COMMUNICATION_DATA']),
]
// Transform to VVT answers
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
// Verify Block 9 data categories are mapped correctly
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS'])
expect(vvtAnswers.dept_marketing_categories).toEqual([
'EMAIL',
'TRACKING_DATA',
'CONSENT_DATA',
])
expect(vvtAnswers.dept_support_categories).toEqual([
'NAME',
'TICKET_DATA',
'COMMUNICATION_DATA',
])
// Verify the full pipeline using direct export
const profilingAnswers = scopeToProfilingAnswers(scopeAnswers)
expect(profilingAnswers.dept_hr_categories).toBeDefined()
expect(profilingAnswers.dept_marketing_categories).toBeDefined()
expect(profilingAnswers.dept_support_categories).toBeDefined()
})
})
// =============================================================================
// 6. DEPARTMENT_DATA_CATEGORIES Integrity
// =============================================================================
describe('DEPARTMENT_DATA_CATEGORIES consistency', () => {
it('all 12 departments are defined', () => {
const expected = [
'dept_hr',
'dept_recruiting',
'dept_finance',
'dept_sales',
'dept_marketing',
'dept_support',
'dept_it',
'dept_recht',
'dept_produktion',
'dept_logistik',
'dept_einkauf',
'dept_facility',
]
for (const dept of expected) {
expect(DEPARTMENT_DATA_CATEGORIES[dept]).toBeDefined()
expect(DEPARTMENT_DATA_CATEGORIES[dept].categories.length).toBeGreaterThan(0)
}
})
it('every department has a label and icon', () => {
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
expect(dept.label).toBeTruthy()
expect(dept.icon).toBeTruthy()
}
})
it('every category has id and label', () => {
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
for (const cat of dept.categories) {
expect(cat.id).toBeTruthy()
expect(cat.label).toBeTruthy()
expect(cat.info).toBeTruthy()
}
}
})
it('Art. 9 categories are correctly flagged', () => {
const art9Categories = [
{ dept: 'dept_hr', id: 'HEALTH_DATA' },
{ dept: 'dept_hr', id: 'RELIGIOUS_BELIEFS' },
{ dept: 'dept_recruiting', id: 'HEALTH_DATA' },
{ dept: 'dept_recht', id: 'CRIMINAL_DATA' },
{ dept: 'dept_produktion', id: 'HEALTH_DATA' },
{ dept: 'dept_facility', id: 'HEALTH_DATA' },
]
for (const { dept, id } of art9Categories) {
const cat = DEPARTMENT_DATA_CATEGORIES[dept].categories.find((c) => c.id === id)
expect(cat?.isArt9).toBe(true)
}
})
})
// =============================================================================
// 7. Block 9 ↔ VVT Mapping Integrity
// =============================================================================
describe('Block 9 Scope ↔ VVT question mapping', () => {
it('every Block 9 question has mapsToVVTQuestion', () => {
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
expect(block9).toBeDefined()
for (const q of block9!.questions) {
expect(q.mapsToVVTQuestion).toBeTruthy()
expect(q.mapsToVVTQuestion).toMatch(/^dept_\w+_categories$/)
}
})
it('Block 9 question options match DEPARTMENT_DATA_CATEGORIES', () => {
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
expect(block9).toBeDefined()
// dk_dept_hr should have same options as DEPARTMENT_DATA_CATEGORIES.dept_hr
const hrQuestion = block9!.questions.find((q) => q.id === 'dk_dept_hr')
expect(hrQuestion).toBeDefined()
const expectedIds = DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map((c) => c.id)
const actualIds = hrQuestion!.options!.map((o) => o.value)
expect(actualIds).toEqual(expectedIds)
})
it('SCOPE_PREFILLED_VVT_QUESTIONS lists all cross-module questions', () => {
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('org_industry')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('dept_hr')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('data_health')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('transfer_cloud_us')
expect(SCOPE_PREFILLED_VVT_QUESTIONS.length).toBeGreaterThanOrEqual(15)
})
})
// =============================================================================
// 8. Edge Cases
// =============================================================================
describe('Edge cases', () => {
it('generateActivities with no answers still produces IT baselines', () => {
const result = generateActivities({})
expect(result.generatedActivities.length).toBe(4) // 4 IT baselines
expect(result.art30Abs5Exempt).toBe(true) // 0 employees, no special categories
})
it('same template triggered by multiple questions is only generated once', () => {
const result = generateActivities({
dept_sales: true, // triggers sales-kundenverwaltung
sys_crm: true, // also triggers sales-kundenverwaltung
})
const salesKunden = result.generatedActivities.filter((a) =>
a.name.toLowerCase().includes('kundenverwaltung')
)
// Should be deduplicated (Set-based triggeredIds)
expect(salesKunden.length).toBe(1)
})
it('empty department category selections produce valid but empty mappings', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', [])]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual([])
})
})

View File

@@ -1,9 +1,9 @@
/**
* Loeschfristen Baseline-Katalog
*
* 18 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
* 25 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
* Datenobjekte in deutschen Unternehmen. Basierend auf AO, HGB,
* UStG, BGB, ArbZG, AGG, BDSG und BSIG.
* UStG, BGB, ArbZG, AGG, BDSG, BSIG und ArbMedVV.
*
* Werden genutzt, um neue Loeschfrist-Policies schnell aus
* bewaehrten Vorlagen zu erstellen.
@@ -48,7 +48,7 @@ export interface BaselineTemplate {
}
// =============================================================================
// BASELINE TEMPLATES (18 Vorlagen)
// BASELINE TEMPLATES (25 Vorlagen)
// =============================================================================
export const BASELINE_TEMPLATES: BaselineTemplate[] = [
@@ -519,6 +519,188 @@ export const BASELINE_TEMPLATES: BaselineTemplate[] = [
reviewInterval: 'ANNUAL',
tags: ['datenschutz', 'consent'],
},
// ==================== 19. E-Mail-Archivierung ====================
{
templateId: 'email-archivierung',
dataObjectName: 'E-Mail-Archivierung',
description:
'Archivierte geschaeftliche E-Mails inkl. Anhaenge, die als Handelsbriefe oder steuerrelevante Korrespondenz einzustufen sind.',
affectedGroups: ['Mitarbeiter', 'Kunden', 'Lieferanten'],
dataCategories: ['E-Mail-Korrespondenz', 'Anhaenge', 'Metadaten'],
primaryPurpose:
'Erfuellung der handelsrechtlichen Aufbewahrungspflicht fuer geschaeftliche Korrespondenz, die als Handelsbrief einzuordnen ist.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer empfangene und versandte Handelsbriefe (6 Jahre) bzw. buchhalterisch relevante E-Mails (10 Jahre).',
retentionDuration: 6,
retentionUnit: 'YEARS',
retentionDescription: '6 Jahre nach Versand/Empfang der E-Mail',
startEvent: 'Versand- bzw. Empfangsdatum der E-Mail',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung durch das E-Mail-Archivierungssystem nach Ablauf der konfigurierten Aufbewahrungsfrist. Vor Loeschung wird geprueft, ob die E-Mail in laufenden Verfahren benoetigt wird.',
responsibleRole: 'IT-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['kommunikation', 'hgb'],
},
// ==================== 20. Zutrittsprotokolle ====================
{
templateId: 'zutrittsprotokolle',
dataObjectName: 'Zutrittsprotokolle',
description:
'Protokolle des Zutrittskontrollsystems inkl. Zeitstempel, Kartennummer, Zutrittsort und Zugangsentscheidung (gewaehrt/verweigert).',
affectedGroups: ['Mitarbeiter', 'Besucher'],
dataCategories: ['Zutrittsdaten', 'Zeitstempel', 'Kartennummern', 'Standortdaten'],
primaryPurpose:
'Sicherstellung der physischen Sicherheit, Nachvollziehbarkeit von Zutritten und Unterstuetzung bei der Aufklaerung von Sicherheitsvorfaellen.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BSIG',
retentionDriverDetail:
'Aufbewahrung gemaess BSI-Grundschutz-Empfehlung fuer Zutrittsprotokolle zur Analyse von Sicherheitsvorfaellen (90 Tage).',
retentionDuration: 90,
retentionUnit: 'DAYS',
retentionDescription: '90 Tage nach Zeitpunkt des Zutritts',
startEvent: 'Zeitpunkt des protokollierten Zutrittsereignisses',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Rotation und Loeschung der Zutrittsprotokolle durch das Zutrittskontrollsystem nach Ablauf der 90-Tage-Frist.',
responsibleRole: 'Facility Management',
reviewInterval: 'QUARTERLY',
tags: ['sicherheit', 'zutritt'],
},
// ==================== 21. Schulungsnachweise ====================
{
templateId: 'schulungsnachweise',
dataObjectName: 'Schulungsnachweise',
description:
'Teilnahmebestaetigungen, Zertifikate und Protokolle von Mitarbeiterschulungen (Datenschutz, Arbeitssicherheit, Compliance).',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Schulungsdaten', 'Zertifikate', 'Teilnahmelisten'],
primaryPurpose:
'Nachweis der Durchfuehrung gesetzlich vorgeschriebener Schulungen und Dokumentation der Mitarbeiterqualifikation.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'CUSTOM',
retentionDriverDetail:
'Aufbewahrung fuer 3 Jahre nach Ende des Beschaeftigungsverhaeltnisses als Nachweis der ordnungsgemaessen Schulungsdurchfuehrung.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
startEvent: 'Ende des Beschaeftigungsverhaeltnisses des geschulten Mitarbeiters',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die HR-Abteilung vor Loeschung, da Schulungsnachweise als Compliance-Nachweis in Audits relevant sein koennen.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'schulung'],
},
// ==================== 22. Betriebsarzt-Dokumentation ====================
{
templateId: 'betriebsarzt-doku',
dataObjectName: 'Betriebsarzt-Dokumentation',
description:
'Ergebnisse arbeitsmedizinischer Vorsorgeuntersuchungen, Eignungsuntersuchungen und arbeitsmedizinische Empfehlungen.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Gesundheitsdaten', 'Vorsorgeuntersuchungen', 'Eignungsbefunde'],
primaryPurpose:
'Erfuellung der Dokumentationspflicht fuer arbeitsmedizinische Vorsorge gemaess ArbMedVV und Nachweisfuehrung gegenueber Berufsgenossenschaften.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'CUSTOM',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess ArbMedVV (Verordnung zur arbeitsmedizinischen Vorsorge) und Berufsgenossenschaftliche Grundsaetze: bis zu 40 Jahre bei Exposition gegenueber krebserzeugenden Gefahrstoffen.',
retentionDuration: 40,
retentionUnit: 'YEARS',
retentionDescription: '40 Jahre nach letzter Exposition (bei Gefahrstoffen), sonst 10 Jahre nach Ende der Taetigkeit',
startEvent: 'Ende der expositionsrelevanten Taetigkeit bzw. Ende des Beschaeftigungsverhaeltnisses',
deletionMethod: 'PHYSICAL_DESTROY',
deletionMethodDetail:
'Physische Vernichtung der Papierunterlagen durch zertifizierten Aktenvernichtungsdienstleister (DIN 66399, Sicherheitsstufe P-5). Digitale Daten werden kryptographisch geloescht.',
responsibleRole: 'Betriebsarzt / Arbeitsmedizinischer Dienst',
reviewInterval: 'ANNUAL',
tags: ['hr', 'gesundheit'],
},
// ==================== 23. Kundenreklamationen ====================
{
templateId: 'kundenreklamationen',
dataObjectName: 'Kundenreklamationen',
description:
'Reklamationsvorgaenge inkl. Beschwerdeinhalt, Kommunikationsverlauf, Massnahmen und Ergebnis der Reklamationsbearbeitung.',
affectedGroups: ['Kunden'],
dataCategories: ['Reklamationsdaten', 'Kommunikation', 'Massnahmenprotokolle'],
primaryPurpose:
'Dokumentation und Bearbeitung von Kundenreklamationen, Qualitaetssicherung und Absicherung gegen Gewaehrleistungsansprueche.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB (3 Jahre) zur Absicherung gegen Gewaehrleistungs- und Schadensersatzansprueche.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Abschluss des Reklamationsvorgangs',
startEvent: 'Abschluss des Reklamationsvorgangs (letzte Massnahme)',
deletionMethod: 'ANONYMIZATION',
deletionMethodDetail:
'Anonymisierung der personenbezogenen Daten nach Ablauf der Frist. Anonymisierte Reklamationsstatistiken bleiben fuer die Qualitaetssicherung erhalten.',
responsibleRole: 'Qualitaetsmanagement',
reviewInterval: 'ANNUAL',
tags: ['kunden', 'qualitaet'],
},
// ==================== 24. Lieferantenbewertungen ====================
{
templateId: 'lieferantenbewertungen',
dataObjectName: 'Lieferantenbewertungen',
description:
'Bewertungen und Auditergebnisse von Lieferanten und Auftragsverarbeitern inkl. Qualitaets-, Compliance- und Datenschutz-Bewertungen.',
affectedGroups: ['Lieferanten', 'Auftragsverarbeiter'],
dataCategories: ['Bewertungsdaten', 'Auditberichte', 'Vertragsdaten'],
primaryPurpose:
'Dokumentation der Sorgfaltspflicht bei der Auswahl und Ueberwachung von Auftragsverarbeitern gemaess Art. 28 DSGVO und Qualitaetssicherung in der Lieferkette.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrung gemaess 257 HGB als handelsrelevante Unterlagen sowie zur Nachweisfuehrung der Sorgfaltspflicht bei der Auftragsverarbeitung.',
retentionDuration: 6,
retentionUnit: 'YEARS',
retentionDescription: '6 Jahre nach Ende der Geschaeftsbeziehung',
startEvent: 'Ende der Geschaeftsbeziehung mit dem Lieferanten/Auftragsverarbeiter',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch den Einkauf/Compliance-Abteilung vor Loeschung, um sicherzustellen, dass keine Nachweispflichten aus laufenden Vertraegen oder Audits bestehen.',
responsibleRole: 'Einkauf / Compliance',
reviewInterval: 'ANNUAL',
tags: ['lieferanten', 'einkauf'],
},
// ==================== 25. Social-Media-Marketingdaten ====================
{
templateId: 'social-media-daten',
dataObjectName: 'Social-Media-Marketingdaten',
description:
'Personenbezogene Daten aus Social-Media-Kampagnen inkl. Nutzerinteraktionen, Custom Audiences, Retargeting-Listen und Kampagnen-Analytics.',
affectedGroups: ['Kunden', 'Interessenten', 'Website-Besucher'],
dataCategories: ['Interaktionsdaten', 'Zielgruppendaten', 'Tracking-Daten', 'Profilmerkmale'],
primaryPurpose:
'Durchfuehrung zielgerichteter Marketing-Kampagnen auf Social-Media-Plattformen und Analyse der Kampagneneffektivitaet.',
deletionTrigger: 'PURPOSE_END',
retentionDriver: null,
retentionDriverDetail:
'Keine gesetzliche Aufbewahrungspflicht. Daten werden bis zum Widerruf der Einwilligung bzw. bis zum Ende der Kampagne gespeichert (Art. 6 Abs. 1 lit. a DSGVO).',
retentionDuration: null,
retentionUnit: null,
retentionDescription: 'Bis zum Widerruf der Einwilligung oder Ende des Kampagnenzwecks',
startEvent: 'Widerruf der Einwilligung oder Ende der Marketing-Kampagne',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der personenbezogenen Daten in den Social-Media-Werbekonten und internen Systemen nach Zweckwegfall. Custom Audiences werden bei Plattformanbietern geloescht.',
responsibleRole: 'Marketing',
reviewInterval: 'SEMI_ANNUAL',
tags: ['marketing', 'social'],
},
]
// =============================================================================

View File

@@ -6,8 +6,10 @@
import {
LoeschfristPolicy,
PolicyStatus,
RetentionDriverType,
isPolicyOverdue,
getActiveLegalHolds,
RETENTION_DRIVER_META,
} from './loeschfristen-types'
// =============================================================================
@@ -22,6 +24,10 @@ export type ComplianceIssueType =
| 'LEGAL_HOLD_CONFLICT'
| 'STALE_DRAFT'
| 'UNCOVERED_VVT_CATEGORY'
| 'MISSING_DELETION_METHOD'
| 'MISSING_STORAGE_LOCATIONS'
| 'EXCESSIVE_RETENTION'
| 'MISSING_DATA_CATEGORIES'
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
@@ -219,6 +225,108 @@ function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
return null
}
/**
* Check 8: MISSING_DELETION_METHOD (MEDIUM)
* Active policy without a deletion method detail description.
*/
function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) {
return createIssue(
policy,
'MISSING_DELETION_METHOD',
'MEDIUM',
'Keine Loeschmethode beschrieben',
`Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`,
'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).'
)
}
return null
}
/**
* Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM)
* Active policy without any documented storage locations.
*/
function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) {
return createIssue(
policy,
'MISSING_STORAGE_LOCATIONS',
'MEDIUM',
'Keine Speicherorte dokumentiert',
`Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`,
'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).'
)
}
return null
}
/**
* Check 10: EXCESSIVE_RETENTION (HIGH)
* Retention duration exceeds 2x the legal default for the driver.
*/
function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null {
if (
policy.retentionDriver &&
policy.retentionDriver !== 'CUSTOM' &&
policy.retentionDuration !== null &&
policy.retentionUnit !== null
) {
const meta = RETENTION_DRIVER_META[policy.retentionDriver]
if (meta.defaultDuration !== null && meta.defaultUnit !== null) {
// Normalize both to days for comparison
const policyDays = toDays(policy.retentionDuration, policy.retentionUnit)
const legalDays = toDays(meta.defaultDuration, meta.defaultUnit)
if (legalDays > 0 && policyDays > legalDays * 2) {
return createIssue(
policy,
'EXCESSIVE_RETENTION',
'HIGH',
'Ueberschreitung der gesetzlichen Aufbewahrungsfrist',
`Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`,
'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.'
)
}
}
}
return null
}
/**
* Check 11: MISSING_DATA_CATEGORIES (LOW)
* Non-draft policy without any data categories assigned.
*/
function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) {
return createIssue(
policy,
'MISSING_DATA_CATEGORIES',
'LOW',
'Keine Datenkategorien zugeordnet',
`Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`,
'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).'
)
}
return null
}
/**
* Helper: convert retention duration to days for comparison.
*/
function toDays(duration: number, unit: string): number {
switch (unit) {
case 'DAYS': return duration
case 'MONTHS': return duration * 30
case 'YEARS': return duration * 365
default: return duration
}
}
// =============================================================================
// MAIN COMPLIANCE CHECK
// =============================================================================
@@ -248,6 +356,10 @@ export function runComplianceCheck(
checkNoResponsible(policy),
checkLegalHoldConflict(policy),
checkStaleDraft(policy),
checkMissingDeletionMethod(policy),
checkMissingStorageLocations(policy),
checkExcessiveRetention(policy),
checkMissingDataCategories(policy),
]
for (const issue of checks) {

View File

@@ -0,0 +1,827 @@
// =============================================================================
// 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',
})
}
}
}
// =========================================================================
// 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',
'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 (&bdquo;Recht auf Vergessenwerden&ldquo;) — 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: Legal Hold Verfahren
// =========================================================================
html += `
<div class="section">
<div class="section-header">7. 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 8: Verantwortlichkeiten
// =========================================================================
html += `
<div class="section">
<div class="section-header">8. 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 9: Pruef- und Revisionszyklus
// =========================================================================
html += `
<div class="section">
<div class="section-header">9. 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 10: Compliance-Status
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">10. 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 11: Aenderungshistorie
// =========================================================================
html += `
<div class="section">
<div class="section-header">11. 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 '-'
}
}

View File

@@ -1,6 +1,6 @@
// =============================================================================
// Loeschfristen Module - Profiling Wizard
// 4-Step Profiling (15 Fragen) zur Generierung von Baseline-Loeschrichtlinien
// 4-Step Profiling (16 Fragen) zur Generierung von Baseline-Loeschrichtlinien
// =============================================================================
import type { LoeschfristPolicy, StorageLocation } from './loeschfristen-types'
@@ -42,7 +42,7 @@ export interface ProfilingResult {
}
// =============================================================================
// PROFILING STEPS (4 Steps, 15 Questions)
// PROFILING STEPS (4 Steps, 16 Questions)
// =============================================================================
export const PROFILING_STEPS: ProfilingStep[] = [
@@ -163,7 +163,7 @@ export const PROFILING_STEPS: ProfilingStep[] = [
},
// =========================================================================
// Step 3: Systeme (3 Fragen)
// Step 3: Systeme (4 Fragen)
// =========================================================================
{
id: 'systems',
@@ -194,6 +194,14 @@ export const PROFILING_STEPS: ProfilingStep[] = [
type: 'boolean',
required: true,
},
{
id: 'sys-zutritt',
step: 'systems',
question: 'Nutzen Sie ein Zutrittskontrollsystem?',
helpText: 'Zutrittskontrollsysteme erzeugen Protokolle, die personenbezogene Daten enthalten und einer Loeschfrist unterliegen.',
type: 'boolean',
required: true,
},
],
},
@@ -340,6 +348,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('zeiterfassung')
matchedTemplateIds.add('bewerbungsunterlagen')
matchedTemplateIds.add('krankmeldungen')
matchedTemplateIds.add('schulungsnachweise')
}
// -------------------------------------------------------------------------
@@ -358,6 +367,8 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('vertraege')
matchedTemplateIds.add('geschaeftsbriefe')
matchedTemplateIds.add('kundenstammdaten')
matchedTemplateIds.add('kundenreklamationen')
matchedTemplateIds.add('lieferantenbewertungen')
}
// -------------------------------------------------------------------------
@@ -367,6 +378,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('newsletter-einwilligungen')
matchedTemplateIds.add('crm-kontakthistorie')
matchedTemplateIds.add('cookie-consent-logs')
matchedTemplateIds.add('social-media-daten')
}
// -------------------------------------------------------------------------
@@ -384,6 +396,20 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('cookie-consent-logs')
}
// -------------------------------------------------------------------------
// Cloud (sys-cloud = true) → E-Mail-Archivierung
// -------------------------------------------------------------------------
if (getBool('sys-cloud')) {
matchedTemplateIds.add('email-archivierung')
}
// -------------------------------------------------------------------------
// Zutritt (sys-zutritt = true)
// -------------------------------------------------------------------------
if (getBool('sys-zutritt')) {
matchedTemplateIds.add('zutrittsprotokolle')
}
// -------------------------------------------------------------------------
// ERP/CRM (sys-erp = true)
// -------------------------------------------------------------------------
@@ -405,6 +431,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
if (getBool('special-gesundheit')) {
// Ensure krankmeldungen is included even without full HR data
matchedTemplateIds.add('krankmeldungen')
matchedTemplateIds.add('betriebsarzt-doku')
}
// -------------------------------------------------------------------------

View File

@@ -103,6 +103,21 @@ export interface VVTActivity {
owner: string
createdAt: string
updatedAt: string
// Library refs (optional, parallel to freetext)
purposeRefs?: string[]
legalBasisRefs?: string[]
dataSubjectRefs?: string[]
dataCategoryRefs?: string[]
recipientRefs?: string[]
retentionRuleRef?: string
transferMechanismRefs?: string[]
tomRefs?: string[]
linkedLoeschfristenIds?: string[]
linkedTomMeasureIds?: string[]
sourceTemplateId?: string
riskScore?: number
art30Completeness?: VVTCompleteness
}
// Processor-Record (Art. 30 Abs. 2)
@@ -186,6 +201,82 @@ export const ART9_CATEGORIES: string[] = [
'CRIMINAL_DATA',
]
// =============================================================================
// LIBRARY TYPES (Master Libraries)
// =============================================================================
export interface VVTLibraryItem {
id: string
labelDe: string
descriptionDe?: string
sortOrder?: number
}
export interface VVTDataCategory extends VVTLibraryItem {
parentId?: string | null
isArt9: boolean
isArt10?: boolean
riskWeight: number
defaultRetentionRule?: string
defaultLegalBasis?: string
children?: VVTDataCategory[]
}
export interface VVTLibRecipient extends VVTLibraryItem {
type: 'INTERNAL' | 'PROCESSOR' | 'CONTROLLER' | 'AUTHORITY'
isThirdCountry?: boolean
country?: string
}
export interface VVTLibLegalBasis extends VVTLibraryItem {
article: string
type: string
isArt9?: boolean
}
export interface VVTLibRetentionRule extends VVTLibraryItem {
legalBasis?: string
duration: number
durationUnit: 'DAYS' | 'MONTHS' | 'YEARS'
startEvent?: string
deletionProcedure?: string
}
export interface VVTLibTom extends VVTLibraryItem {
category: 'accessControl' | 'confidentiality' | 'integrity' | 'availability' | 'separation'
art32Reference?: string
}
export interface VVTProcessTemplate {
id: string
name: string
description?: string
businessFunction: BusinessFunction
purposeRefs: string[]
legalBasisRefs: string[]
dataSubjectRefs: string[]
dataCategoryRefs: string[]
recipientRefs: string[]
tomRefs: string[]
transferMechanismRefs: string[]
retentionRuleRef?: string
typicalSystems: string[]
protectionLevel: string
dpiaRequired: boolean
riskScore?: number
tags: string[]
isSystem: boolean
sortOrder: number
}
export interface VVTCompleteness {
score: number
missing: string[]
warnings: string[]
passed: number
total: number
}
// =============================================================================
// HELPER: Create empty activity
// =============================================================================