feat: DSFA — VVT-Verknüpfung + Residual Risk + Bundesland-Blacklists
1. VVT-Verknüpfung: Dropdown "Verknüpfte VVT-Aktivität" in Step 1, lädt Aktivitäten via API, auto-fills Verarbeitungstätigkeit bei Auswahl 2. Residual Risk: Neuer Step 5 im Wizard — Bewertung des Restrisikos nach Maßnahmen. Bei hoch/kritisch → Art. 36 Vorabkonsultation Warnung 3. Bundesland-Blacklists (Art. 35 Abs. 4): 16 Landesbehörden mit DSK-Muss-Liste (10 gemeinsame Kriterien) + länderspezifische Ergänzungen (Bayern: Whistleblower/Drohnen, NRW: Social-Media- Monitoring, Berlin: Mieterbonitätsprüfung). Automatische Prüfung gegen Scope-Antworten. Blacklist-Matches im DSFA-Banner angezeigt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,21 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
||||||
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
||||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
|
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
|
||||||
|
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
|
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
|
||||||
|
const [linkedVvtId, setLinkedVvtId] = useState('')
|
||||||
|
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
|
||||||
|
// Load VVT activities for linking
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/compliance/vvt')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
const items = Array.isArray(data) ? data : data.activities || []
|
||||||
|
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||||
@@ -39,6 +53,8 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
|
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
|
||||||
...(prefill?.involvesAi ? { involves_ai: true } : {}),
|
...(prefill?.involvesAi ? { involves_ai: true } : {}),
|
||||||
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
|
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
|
||||||
|
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
|
||||||
|
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
|
||||||
} as Partial<DSFA>)
|
} as Partial<DSFA>)
|
||||||
onClose()
|
onClose()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -59,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
{[1, 2, 3, 4].map(s => (
|
{[1, 2, 3, 4, 5].map(s => (
|
||||||
<React.Fragment key={s}>
|
<React.Fragment key={s}>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
s < step ? 'bg-green-500 text-white' :
|
s < step ? 'bg-green-500 text-white' :
|
||||||
@@ -71,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
</svg>
|
</svg>
|
||||||
) : s}
|
) : s}
|
||||||
</div>
|
</div>
|
||||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{vvtActivities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
|
||||||
|
<select value={linkedVvtId} onChange={e => {
|
||||||
|
setLinkedVvtId(e.target.value)
|
||||||
|
const selected = vvtActivities.find(a => a.id === e.target.value)
|
||||||
|
if (selected && !processingActivity) setProcessingActivity(selected.name)
|
||||||
|
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
|
||||||
|
<option value="">— Keine Verknuepfung —</option>
|
||||||
|
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||||
<input
|
<input
|
||||||
@@ -178,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
|
||||||
|
Bei hohem Restrisiko → Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
|
||||||
|
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
|
||||||
|
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
|
||||||
|
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
|
||||||
|
].map(r => (
|
||||||
|
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
|
||||||
|
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
|
||||||
|
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{r.label}</span>
|
||||||
|
<p className="text-xs text-gray-500">{r.desc}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(residualRisk === 'high' || residualRisk === 'critical') && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -190,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardP
|
|||||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
|
||||||
disabled={saving || (step === 1 && !title.trim())}
|
disabled={saving || (step === 1 && !title.trim())}
|
||||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export default function DSFAPage() {
|
|||||||
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
|
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
|
||||||
[state.companyProfile, scopeAnswers]
|
[state.companyProfile, scopeAnswers]
|
||||||
)
|
)
|
||||||
const dsfaCheck = useMemo(() => isDSFARequired(scopeAnswers), [scopeAnswers])
|
const dsfaCheck = useMemo(
|
||||||
|
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
|
||||||
|
[scopeAnswers, state.companyProfile?.headquartersState]
|
||||||
|
)
|
||||||
|
|
||||||
const loadDSFAs = useCallback(async () => {
|
const loadDSFAs = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -142,6 +145,21 @@ export default function DSFAPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{dsfaCheck.blacklistMatches.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-red-200">
|
||||||
|
<p className="text-xs font-medium text-red-800 mb-1">
|
||||||
|
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dsfaCheck.blacklistMatches.map(m => (
|
||||||
|
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
|
||||||
|
{m}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* DSFA Bundesland-Blacklists — Verarbeitungen die IMMER eine DSFA erfordern.
|
||||||
|
*
|
||||||
|
* Jede Aufsichtsbehoerde fuehrt eine eigene Positiv-/Negativliste
|
||||||
|
* gemaess Art. 35 Abs. 4 DSGVO. Diese Listen definieren Verarbeitungen
|
||||||
|
* die UNABHAENGIG von der Schwellwertanalyse eine DSFA erfordern.
|
||||||
|
*
|
||||||
|
* Quellen: Offizielle Muss-Listen der LfDI/BfDI (oeffentlich zugaenglich).
|
||||||
|
* KEIN Normtext — eigene Zusammenfassung der Kriterien.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BlacklistEntry {
|
||||||
|
id: string
|
||||||
|
description: string
|
||||||
|
triggerKeywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundeslandBlacklist {
|
||||||
|
state: string
|
||||||
|
stateCode: string
|
||||||
|
authority: string
|
||||||
|
authorityUrl: string
|
||||||
|
entries: BlacklistEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemeinsame Kriterien die in ALLEN Bundeslaendern gelten (DSK-Liste)
|
||||||
|
const DSK_COMMON: BlacklistEntry[] = [
|
||||||
|
{ id: 'dsk-01', description: 'Umfangreiche Verarbeitung besonderer Kategorien (Art. 9)', triggerKeywords: ['art9', 'gesundheit', 'biometrie', 'genetik', 'religion', 'gewerkschaft'] },
|
||||||
|
{ id: 'dsk-02', description: 'Systematische umfangreiche Ueberwachung oeffentlicher Bereiche', triggerKeywords: ['videoueberwachung', 'kamera', 'oeffentlich', 'ueberwachung'] },
|
||||||
|
{ id: 'dsk-03', description: 'Scoring/Profiling mit rechtlicher Wirkung', triggerKeywords: ['scoring', 'profiling', 'bonitaet', 'kredit', 'automatisiert'] },
|
||||||
|
{ id: 'dsk-04', description: 'Verarbeitung von Daten Minderjaehriger fuer Marketing/Profiling', triggerKeywords: ['minderjaehrig', 'kinder', 'schueler', 'marketing'] },
|
||||||
|
{ id: 'dsk-05', description: 'Zusammenfuehrung von Daten aus verschiedenen Quellen', triggerKeywords: ['zusammenfuehrung', 'matching', 'datenfusion', 'big data'] },
|
||||||
|
{ id: 'dsk-06', description: 'Einsatz neuer Technologien (KI, Biometrie, IoT)', triggerKeywords: ['ki', 'kuenstliche intelligenz', 'biometrie', 'iot', 'gesichtserkennung'] },
|
||||||
|
{ id: 'dsk-07', description: 'Umfangreiche Verarbeitung von Standortdaten', triggerKeywords: ['standort', 'gps', 'tracking', 'bewegungsprofil'] },
|
||||||
|
{ id: 'dsk-08', description: 'Verarbeitung von Beschaeftigtendaten mit Ueberwachungscharakter', triggerKeywords: ['mitarbeiterueberwachung', 'keylogger', 'bildschirmaufnahme', 'leistungskontrolle'] },
|
||||||
|
{ id: 'dsk-09', description: 'Anonymisierung besonderer Kategorien', triggerKeywords: ['anonymisierung', 'art9', 'pseudonymisierung'] },
|
||||||
|
{ id: 'dsk-10', description: 'Verarbeitung von Kommunikationsinhalten oder -metadaten', triggerKeywords: ['kommunikation', 'email', 'telefon', 'metadaten', 'inhaltsdaten'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Bundesland-spezifische Ergaenzungen
|
||||||
|
const BAYERN_EXTRA: BlacklistEntry[] = [
|
||||||
|
{ id: 'by-01', description: 'Betrieb von Whistleblower-Systemen mit Identifizierungsrisiko', triggerKeywords: ['whistleblower', 'hinweisgeber', 'meldesystem'] },
|
||||||
|
{ id: 'by-02', description: 'Einsatz von Drohnen mit Kamera in oeffentlichen Bereichen', triggerKeywords: ['drohne', 'uav', 'luftaufnahme'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const NRW_EXTRA: BlacklistEntry[] = [
|
||||||
|
{ id: 'nw-01', description: 'Social-Media-Monitoring von Mitarbeitern oder Bewerbern', triggerKeywords: ['social media', 'monitoring', 'bewerber', 'hintergrundcheck'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BERLIN_EXTRA: BlacklistEntry[] = [
|
||||||
|
{ id: 'be-01', description: 'Automatisierte Mieterbonitaetspruefung', triggerKeywords: ['mieter', 'bonitaet', 'wohnung', 'schufa'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BUNDESLAND_BLACKLISTS: Record<string, BundeslandBlacklist> = {
|
||||||
|
BW: { state: 'Baden-Wuerttemberg', stateCode: 'BW', authority: 'LfDI BW', authorityUrl: 'https://www.baden-wuerttemberg.datenschutz.de', entries: [...DSK_COMMON] },
|
||||||
|
BY: { state: 'Bayern', stateCode: 'BY', authority: 'BayLDA', authorityUrl: 'https://www.lda.bayern.de', entries: [...DSK_COMMON, ...BAYERN_EXTRA] },
|
||||||
|
BE: { state: 'Berlin', stateCode: 'BE', authority: 'BlnBDI', authorityUrl: 'https://www.datenschutz-berlin.de', entries: [...DSK_COMMON, ...BERLIN_EXTRA] },
|
||||||
|
BB: { state: 'Brandenburg', stateCode: 'BB', authority: 'LDA BB', authorityUrl: 'https://www.lda.brandenburg.de', entries: [...DSK_COMMON] },
|
||||||
|
HB: { state: 'Bremen', stateCode: 'HB', authority: 'LfDI HB', authorityUrl: 'https://www.datenschutz.bremen.de', entries: [...DSK_COMMON] },
|
||||||
|
HH: { state: 'Hamburg', stateCode: 'HH', authority: 'HmbBfDI', authorityUrl: 'https://datenschutz-hamburg.de', entries: [...DSK_COMMON] },
|
||||||
|
HE: { state: 'Hessen', stateCode: 'HE', authority: 'HBDI', authorityUrl: 'https://datenschutz.hessen.de', entries: [...DSK_COMMON] },
|
||||||
|
MV: { state: 'Mecklenburg-Vorpommern', stateCode: 'MV', authority: 'LfDI MV', authorityUrl: 'https://www.datenschutz-mv.de', entries: [...DSK_COMMON] },
|
||||||
|
NI: { state: 'Niedersachsen', stateCode: 'NI', authority: 'LfD NI', authorityUrl: 'https://lfd.niedersachsen.de', entries: [...DSK_COMMON] },
|
||||||
|
NW: { state: 'Nordrhein-Westfalen', stateCode: 'NW', authority: 'LDI NRW', authorityUrl: 'https://www.ldi.nrw.de', entries: [...DSK_COMMON, ...NRW_EXTRA] },
|
||||||
|
RP: { state: 'Rheinland-Pfalz', stateCode: 'RP', authority: 'LfDI RP', authorityUrl: 'https://www.datenschutz.rlp.de', entries: [...DSK_COMMON] },
|
||||||
|
SL: { state: 'Saarland', stateCode: 'SL', authority: 'UDZ Saarland', authorityUrl: 'https://www.datenschutz.saarland.de', entries: [...DSK_COMMON] },
|
||||||
|
SN: { state: 'Sachsen', stateCode: 'SN', authority: 'SDB', authorityUrl: 'https://www.saechsdsb.de', entries: [...DSK_COMMON] },
|
||||||
|
ST: { state: 'Sachsen-Anhalt', stateCode: 'ST', authority: 'LfD LSA', authorityUrl: 'https://datenschutz.sachsen-anhalt.de', entries: [...DSK_COMMON] },
|
||||||
|
SH: { state: 'Schleswig-Holstein', stateCode: 'SH', authority: 'ULD SH', authorityUrl: 'https://www.datenschutzzentrum.de', entries: [...DSK_COMMON] },
|
||||||
|
TH: { state: 'Thueringen', stateCode: 'TH', authority: 'TLfDI', authorityUrl: 'https://www.tlfdi.de', entries: [...DSK_COMMON] },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check scope answers against Bundesland blacklist.
|
||||||
|
* Returns matching entries that REQUIRE a DSFA.
|
||||||
|
*/
|
||||||
|
export function checkBlacklist(
|
||||||
|
stateCode: string,
|
||||||
|
scopeKeywords: string[],
|
||||||
|
): { matches: BlacklistEntry[]; authority: string; authorityUrl: string } {
|
||||||
|
const bl = BUNDESLAND_BLACKLISTS[stateCode]
|
||||||
|
if (!bl) return { matches: [], authority: 'BfDI', authorityUrl: 'https://www.bfdi.bund.de' }
|
||||||
|
|
||||||
|
const lowerKeywords = scopeKeywords.map(k => k.toLowerCase())
|
||||||
|
const matches = bl.entries.filter(entry =>
|
||||||
|
entry.triggerKeywords.some(tk => lowerKeywords.some(kw => kw.includes(tk) || tk.includes(kw)))
|
||||||
|
)
|
||||||
|
|
||||||
|
return { matches, authority: bl.authority, authorityUrl: bl.authorityUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive keywords from scope answers for blacklist matching.
|
||||||
|
*/
|
||||||
|
export function scopeAnswersToKeywords(answers: Array<{ questionId: string; value: unknown }>): string[] {
|
||||||
|
const keywords: string[] = []
|
||||||
|
for (const a of answers) {
|
||||||
|
if (a.value === true || a.value === 'yes') {
|
||||||
|
keywords.push(a.questionId.replace(/_/g, ' '))
|
||||||
|
// Map specific question IDs to keywords
|
||||||
|
if (a.questionId === 'data_art9') keywords.push('art9', 'besondere kategorien')
|
||||||
|
if (a.questionId === 'data_minors') keywords.push('minderjaehrig', 'kinder')
|
||||||
|
if (a.questionId === 'proc_adm_scoring') keywords.push('scoring', 'profiling', 'automatisiert')
|
||||||
|
if (a.questionId === 'proc_video_surveillance') keywords.push('videoueberwachung', 'kamera')
|
||||||
|
if (a.questionId === 'proc_ai_usage') keywords.push('ki', 'kuenstliche intelligenz')
|
||||||
|
if (a.questionId === 'proc_employee_monitoring') keywords.push('mitarbeiterueberwachung')
|
||||||
|
}
|
||||||
|
if (Array.isArray(a.value)) {
|
||||||
|
for (const v of a.value) keywords.push(String(v).toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keywords
|
||||||
|
}
|
||||||
@@ -173,11 +173,17 @@ export function prefillDSFAFromScope(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if DSFA is required based on scope answers (Art. 35 Abs. 3 triggers).
|
* Check if DSFA is required based on scope answers (Art. 35 Abs. 3 triggers)
|
||||||
|
* AND Bundesland-specific blacklist (Art. 35 Abs. 4).
|
||||||
*/
|
*/
|
||||||
export function isDSFARequired(scopeAnswers: ScopeProfilingAnswer[]): {
|
export function isDSFARequired(
|
||||||
|
scopeAnswers: ScopeProfilingAnswer[],
|
||||||
|
headquartersState?: string,
|
||||||
|
): {
|
||||||
required: boolean
|
required: boolean
|
||||||
triggers: string[]
|
triggers: string[]
|
||||||
|
blacklistMatches: string[]
|
||||||
|
authority?: string
|
||||||
} {
|
} {
|
||||||
const triggers: string[] = []
|
const triggers: string[] = []
|
||||||
|
|
||||||
@@ -198,5 +204,21 @@ export function isDSFARequired(scopeAnswers: ScopeProfilingAnswer[]): {
|
|||||||
triggers.push('Umfangreiche Datenverarbeitung (Art. 35 Abs. 3 lit. b)')
|
triggers.push('Umfangreiche Datenverarbeitung (Art. 35 Abs. 3 lit. b)')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { required: triggers.length > 0, triggers }
|
// Bundesland-Blacklist (Art. 35 Abs. 4)
|
||||||
|
let blacklistMatches: string[] = []
|
||||||
|
let authority: string | undefined
|
||||||
|
if (headquartersState) {
|
||||||
|
const { checkBlacklist, scopeAnswersToKeywords } = require('./bundesland-blacklists')
|
||||||
|
const keywords = scopeAnswersToKeywords(scopeAnswers)
|
||||||
|
const result = checkBlacklist(headquartersState, keywords)
|
||||||
|
blacklistMatches = result.matches.map((m: { description: string }) => m.description)
|
||||||
|
authority = result.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
required: triggers.length > 0 || blacklistMatches.length > 0,
|
||||||
|
triggers,
|
||||||
|
blacklistMatches,
|
||||||
|
authority,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user