/** * Importiert die DSE-Gesamtliste des Amtes Leezen (Excel-Export, CSV). * * node scripts/import-csv.js --dry-run nur prüfen und berichten * node scripts/import-csv.js JSON schreiben und importieren * node scripts/import-csv.js --prune zusätzlich verwaiste Datensätze löschen * * Die Liste ist führend für die Angaben je Verarbeitungstätigkeit. Die für alle * Erklärungen gleichen Bausteine kommen aus den zentralen Standardtexten und * werden hier bewusst NICHT je Erklärung dupliziert – gespeichert wird nur eine * echte Abweichung (siehe scripts/lib/policy-defaults.js). * * Zuordnung bestehender Datensätze über den Bezeichner (slug), nicht über den * Titel: Die alten, aus PDF gelesenen Titel unterscheiden sich teils nur in * Leerzeichen um Schrägstriche ("Verträge/Vereinbarungen" gegen * "Verträge /Vereinbarungen") und ergäben sonst Dubletten. */ const fs = require("fs") const path = require("path") const { PrismaClient } = require("@prisma/client") const { readPolicyCsv } = require("./lib/csv-policy") const { readDefaults, foldIntoDefaults } = require("./lib/policy-defaults") const { slugify, uniqueSlug } = require("./lib/slug") const prisma = new PrismaClient() const CSV = path.join(process.cwd(), "data", "source", "DSE_Gesamtliste_Amt_Leezen.CSV") const JSON_DIR = path.join(process.cwd(), "data", "policies") const C = { bereich: "Fachbereich", dienst: "Fachdienst", aufgabe: "Aufgabenbereich", zufish: "ZuFiSH-Dienstleistung", titel: "Formular-Bezeichnung", nummer: "Formularnummer", zweck: "Verarbeitungszweck", rechtsgrundlage: "Rechtsgrundlage f.d. Datenerhebung", pflicht: "Pflicht zur Bereitstellung der Daten", folgen: "Folgen, wenn Daten nicht angeben werden", empfaenger: "Empfänger f. d. beabsichtigte Weitergabe der Daten", quelle: "Datenquelle (bei Dritterhebung)", speicherdauer: "Dauer der Speicherung (Löschfristen)", ozgVerantwortlich: "OZG Verantwortlicher", ozgDsb: "OZG Datenschutzbeauftragter", ozgEmpfaenger: "OZG Empfänger f. d. beabsichtigte Weitergabe der Daten", } /** * Satz aus dem Merkblatt, der in allen 117 Vorlagen steht, in der Liste aber * nicht wiederholt wird. Ohne ihn verlören 27 Erklärungen jede Empfängerangabe. */ const EMPFAENGER_STANDARD = "Bei Ein- und Auszahlungen: Finanzbuchhaltung" /** Einleitung der gedruckten Merkblätter, wortgleich in allen Vorlagen. */ const INTRO = "Wir verarbeiten Ihre notwendigen personenbezogenen Daten zur Erfüllung unserer Aufgaben stets im Einklang mit den jeweils anwendbaren gesetzlichen Datenschutzanforderungen zu den nachfolgend aufgeführten Zwecken. Personenbezogene Daten sind sämtliche Informationen, die einen Bezug zu einer Person aufweisen." /** Werte, die „keine Angabe" bedeuten und nicht als Inhalt zählen dürfen. */ const LEERWERTE = new Set(["entfällt", "entfaellt", "keine", "-", "--", "n/a", "nein"]) /** Mehrfache Leerzeichen aus dem Excel-Export zusammenziehen. */ function clean(value) { return String(value ?? "").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").trim() } /** Wie clean(), liefert aber null statt Leerstring und filtert Platzhalter. */ function textOrNull(value) { const text = clean(value) if (!text) return null return LEERWERTE.has(text.toLowerCase()) ? null : text } /** Baut aus den Organisationsspalten die Bezeichnung der Verarbeitungstätigkeit. */ function buildDescription(record, titel) { const einordnung = [ record[C.bereich] && `Fachbereich: ${clean(record[C.bereich])}`, record[C.dienst] && `Fachdienst: ${clean(record[C.dienst])}`, record[C.aufgabe] && `Aufgabenbereich: ${clean(record[C.aufgabe])}`, record[C.zufish] && `Dienstleistung: ${clean(record[C.zufish])}`, record[C.nummer] && `Formularnummer: ${clean(record[C.nummer])}`, ].filter(Boolean) return `**${titel}**\n\n${einordnung.join("\n")}\n\n${INTRO}` } /** Empfängerangabe: Standardsatz, Angaben der Liste, dann der OZG-Zusatz. */ function buildRecipients(record) { const teile = [EMPFAENGER_STANDARD] const eigene = textOrNull(record[C.empfaenger]) if (eigene) teile.push(eigene) const ozg = textOrNull(record[C.ozgEmpfaenger]) if (ozg) { teile.push( `Im Rahmen des Online-Verfahrens werden die Daten zusätzlich weitergegeben an: ${ozg}` ) } return teile.join("\n") } /** Adressblock aus einer mit Semikolon getrennten Zeile lesbar umbrechen. */ function addressBlock(value) { return clean(value) .split(";") .map((part) => part.trim()) .filter(Boolean) .join("\n") } async function main() { const dryRun = process.argv.includes("--dry-run") const prune = process.argv.includes("--prune") if (!fs.existsSync(CSV)) { console.error(`Liste nicht gefunden: ${CSV}`) process.exitCode = 1 return } const { records } = readPolicyCsv(CSV) const defaults = await readDefaults(prisma) // Titel eindeutig machen – bei Gleichstand entscheidet der Aufgabenbereich const nachTitel = new Map() for (const r of records) { const t = clean(r[C.titel]) if (!nachTitel.has(t)) nachTitel.set(t, []) nachTitel.get(t).push(r) } const umbenannt = [] const titelVon = new Map() for (const [titel, gruppe] of nachTitel) { if (gruppe.length === 1) { titelVon.set(gruppe[0][C.nummer], titel) continue } gruppe.forEach((r, i) => { const zusatz = clean(r[C.aufgabe]) || clean(r[C.zweck]) || String(i + 1) const neu = `${titel} (${zusatz})` titelVon.set(r[C.nummer], neu) umbenannt.push(`${r[C.nummer]}: „${titel}" → „${neu}"`) }) } // Bestehende Datensätze für die Zuordnung über den Bezeichner const vorhandene = await prisma.privacyPolicy.findMany({ select: { id: true, slug: true, title: true }, }) const nachSlug = new Map(vorhandene.map((p) => [p.slug, p])) const belegt = new Set(vorhandene.map((p) => p.slug)) const angefasst = new Set() const aufbereitet = [] const hinweise = { ohneZweck: [], ohneRecht: [], ohneDauer: [], platzhalter: [], art14: [] } for (const record of records) { const nummer = clean(record[C.nummer]) const titel = titelVon.get(record[C.nummer]) const quelle = textOrNull(record[C.quelle]) const speicherdauer = textOrNull(record[C.speicherdauer]) const zweck = textOrNull(record[C.zweck]) const rechtsgrundlage = textOrNull(record[C.rechtsgrundlage]) const daten = { title: titel, processingDescription: buildDescription(record, titel), processingPurposes: zweck, legalBasis: rechtsgrundlage, legitimateInterests: null, recipients: buildRecipients(record), storageDuration: speicherdauer, dataSource: quelle, dataCategories: null, // Bausteine: leer heißt „folgt dem Standard" responsible: null, contactDPO: null, dataSubjectsRights: null, complaintRight: null, withdrawalRight: null, objectionRight: null, thirdCountryTransfer: null, automatedDecision: null, provisionObligation: textOrNull(record[C.pflicht]), provisionConsequences: textOrNull(record[C.folgen]), isPublic: false, } // Gemeinsame Verantwortlichkeit im Online-Verfahren: bewusste Abweichung const ozgV = textOrNull(record[C.ozgVerantwortlich]) if (ozgV) { daten.responsible = `${defaults.responsible}\n\nVerantwortlich für das Online-Verfahren ist:\n${addressBlock(ozgV)}` } const ozgD = textOrNull(record[C.ozgDsb]) if (ozgD) { daten.contactDPO = `${defaults.contactDPO}\n\nDatenschutzbeauftragte/r für das Online-Verfahren:\n${addressBlock(ozgD)}` } if (!zweck) hinweise.ohneZweck.push(`${nummer} – ${titel}`) if (!rechtsgrundlage) hinweise.ohneRecht.push(`${nummer} – ${titel}`) if (!speicherdauer) hinweise.ohneDauer.push(`${nummer} – ${titel}`) if (speicherdauer && /\bx\b/i.test(speicherdauer)) { hinweise.platzhalter.push(`${nummer} – „${speicherdauer}"`) } if (quelle && !daten.dataCategories) hinweise.art14.push(`${nummer} – ${titel}`) const overrides = foldIntoDefaults(daten, defaults) // Bezeichner: bestehender bleibt erhalten, sonst aus dem Titel ableiten const basis = slugify(titel) const bestehend = nachSlug.get(basis) if (bestehend) { daten.slug = bestehend.slug angefasst.add(bestehend.slug) } else { daten.slug = uniqueSlug(titel, belegt) belegt.add(daten.slug) } aufbereitet.push({ nummer, daten, overrides, bestehend }) } // Bericht console.log(`Zeilen der Liste: ${records.length}`) console.log(`Datensätze aufbereitet: ${aufbereitet.length}`) console.log(`Standardtexte gepflegt: ${Object.entries(defaults).filter(([, v]) => String(v).trim()).map(([k]) => k).join(", ") || "keine"}`) console.log("") if (umbenannt.length) { console.log(`=== Titel eindeutig gemacht (${umbenannt.length}) ===`) umbenannt.forEach((z) => console.log(" " + z)) console.log("") } const mitAbweichung = aufbereitet.filter((a) => a.overrides.length) console.log(`=== Abweichungen vom Standardtext (${mitAbweichung.length}) ===`) mitAbweichung.forEach((a) => console.log(` ${a.nummer}: ${a.overrides.join(", ")}`)) console.log("") const zeige = (titel, liste) => { console.log(`=== ${titel} (${liste.length}) ===`) liste.forEach((z) => console.log(" " + z)) console.log("") } zeige("Ohne Zweck – Art. 13 Abs. 1 lit. c", hinweise.ohneZweck) zeige("Ohne Rechtsgrundlage – Art. 13 Abs. 1 lit. c", hinweise.ohneRecht) zeige("Ohne Speicherdauer – Art. 13 Abs. 2 lit. a", hinweise.ohneDauer) zeige("Platzhalter statt Frist", hinweise.platzhalter) zeige("Herkunft angegeben, Datenkategorien fehlen – Art. 14 Abs. 1 lit. d", hinweise.art14) const verwaist = vorhandene.filter((p) => !angefasst.has(p.slug)) console.log(`=== Nicht in der Liste enthalten (${verwaist.length}) ===`) verwaist.forEach((p) => console.log(` ${p.slug} – „${p.title}"`)) console.log("") if (dryRun) { console.log("--dry-run: nichts geschrieben.") return } fs.mkdirSync(JSON_DIR, { recursive: true }) let neu = 0 let aktualisiert = 0 for (const { nummer, daten, overrides, bestehend } of aufbereitet) { fs.writeFileSync( path.join(JSON_DIR, `${daten.slug}.json`), JSON.stringify({ _quelle: `DSE_Gesamtliste_Amt_Leezen.CSV · ${nummer}`, _abweichungen: overrides, ...daten }, null, 2), "utf8" ) if (bestehend) { await prisma.privacyPolicy.update({ where: { id: bestehend.id }, data: daten }) aktualisiert++ } else { await prisma.privacyPolicy.create({ data: daten }) neu++ } } console.log(`Neu angelegt: ${neu}`) console.log(`Aktualisiert: ${aktualisiert}`) if (prune && verwaist.length) { for (const p of verwaist) { await prisma.privacyPolicy.delete({ where: { id: p.id } }) } console.log(`Gelöscht: ${verwaist.length}`) } else if (verwaist.length) { console.log(`Unberührt: ${verwaist.length} (mit --prune entfernen)`) } console.log(`JSON-Dateien: data/policies/`) } main() .catch((error) => { console.error("Fehler beim Import:", error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() })