syncova-policies/scripts/import-csv.js
Weapie ae752322f0 Import nicht an der JSON-Ablage scheitern lassen
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>
2026-09-08 10:02:32 +02:00

347 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
})