Im Container lief der Import bis zum Ende der Auswertung und brach dann mit EACCES beim Schreiben der ersten JSON-Datei ab. Weil die Ablage vor dem Datenbankschreiben stand, wurde keine einzige Erklärung importiert. Reihenfolge umgedreht: Erst die Datenbank, danach die JSON-Dateien. Ein Fehler dabei wird einmal gemeldet und beendet nur die Ablage, nicht den Lauf. Neu ist --no-json, das sie ganz abschaltet. Getestet: Normalfall schreibt 120 Dateien, simulierter Schreibfehler meldet ihn und importiert vollständig weiter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
/**
|
||
* 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 importieren und JSON ablegen
|
||
* node scripts/import-csv.js --prune zusätzlich verwaiste Datensätze löschen
|
||
* node scripts/import-csv.js --no-json nur in die Datenbank (z. B. im Container)
|
||
*
|
||
* 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")
|
||
const ohneJson = process.argv.includes("--no-json")
|
||
|
||
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
|
||
}
|
||
|
||
// Die JSON-Ablage ist nur zur Nachvollziehbarkeit im Repository gedacht.
|
||
// Im Container liegt sie im Volume und ist je nach Einhängung nicht
|
||
// beschreibbar – das darf den eigentlichen Import nicht verhindern.
|
||
let jsonZiel = ohneJson ? null : JSON_DIR
|
||
if (jsonZiel) {
|
||
try {
|
||
fs.mkdirSync(jsonZiel, { recursive: true })
|
||
} catch (error) {
|
||
console.warn(`Hinweis: ${jsonZiel} nicht anlegbar (${error.code}) – JSON-Ablage wird übersprungen.`)
|
||
jsonZiel = null
|
||
}
|
||
}
|
||
|
||
let neu = 0
|
||
let aktualisiert = 0
|
||
let geschrieben = 0
|
||
let jsonFehler = null
|
||
|
||
for (const { nummer, daten, overrides, bestehend } of aufbereitet) {
|
||
// Datenbank zuerst: Sie ist der Zweck des Laufs.
|
||
if (bestehend) {
|
||
await prisma.privacyPolicy.update({ where: { id: bestehend.id }, data: daten })
|
||
aktualisiert++
|
||
} else {
|
||
await prisma.privacyPolicy.create({ data: daten })
|
||
neu++
|
||
}
|
||
|
||
if (!jsonZiel) continue
|
||
|
||
try {
|
||
fs.writeFileSync(
|
||
path.join(jsonZiel, `${daten.slug}.json`),
|
||
JSON.stringify({ _quelle: `DSE_Gesamtliste_Amt_Leezen.CSV · ${nummer}`, _abweichungen: overrides, ...daten }, null, 2),
|
||
"utf8"
|
||
)
|
||
geschrieben++
|
||
} catch (error) {
|
||
// Einmal melden, dann nicht weiter versuchen
|
||
jsonFehler = error
|
||
jsonZiel = null
|
||
}
|
||
}
|
||
|
||
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)`)
|
||
}
|
||
|
||
if (ohneJson) {
|
||
console.log(`JSON-Ablage: übersprungen (--no-json)`)
|
||
} else if (jsonFehler) {
|
||
console.log(`JSON-Dateien: ${geschrieben} von ${aufbereitet.length} geschrieben`)
|
||
console.warn("")
|
||
console.warn(`WARNUNG: JSON-Ablage abgebrochen – ${jsonFehler.code} bei ${jsonFehler.path}`)
|
||
console.warn("Der Import in die Datenbank ist davon unberührt und vollständig.")
|
||
console.warn("Die JSON-Dateien dienen nur der Nachvollziehbarkeit im Repository;")
|
||
console.warn("im Container lassen sie sich mit --no-json ganz abschalten.")
|
||
} else {
|
||
console.log(`JSON-Dateien: ${geschrieben} in data/policies/`)
|
||
}
|
||
}
|
||
|
||
main()
|
||
.catch((error) => {
|
||
console.error("Fehler beim Import:", error)
|
||
process.exitCode = 1
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect()
|
||
})
|