syncova-policies/scripts/import-pdfs.js
Weapie 328f042307 Syncova Policies: Portal für DSGVO-Datenschutzerklärungen
Next.js 15 App Router mit öffentlicher Übersicht und geschütztem
Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO.

- Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden
- Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen
- Massenimport der Merkblätter des Amtes Leezen aus PDF
- Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:23:51 +02:00

261 lines
10 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 alle Merkblätter aus data/import in die Datenbank.
*
* node scripts/import-pdfs.js --dry-run nur prüfen, nichts schreiben
* node scripts/import-pdfs.js importieren
* node scripts/import-pdfs.js --public neue Datensätze öffentlich anlegen
*
* Vorgehen: Der über alle Merkblätter identische Textbaustein-Teil wird gegen
* das geprüfte Referenzdokument verglichen. Nur bei exakter Übereinstimmung
* kommt der redaktionell aufbereitete Text zum Einsatz; jede Abweichung wird
* gemeldet und stattdessen der tatsächliche PDF-Text übernommen.
*/
const fs = require("fs")
const path = require("path")
const { PrismaClient } = require("@prisma/client")
const {
BOILERPLATE_HEADINGS,
parsePolicyPdf,
reflow,
joinTitle,
normalize,
} = require("./lib/pdf-policy")
const prisma = new PrismaClient()
const IMPORT_DIR = path.join(process.cwd(), "data", "import")
const JSON_DIR = path.join(process.cwd(), "data", "policies")
const REFERENCE = "FBI_Bau_001.pdf"
const H = {
responsible: "Name und Kontaktdaten des Verantwortlichen",
contactDPO: "Kontaktdaten der Datenschutzbeauftragten",
rights: "Betroffenen-Rechte",
complaint: "Beschwerderecht bei der Aufsichtsbehörde",
withdrawal: "Widerrufsrecht bei Einwilligung",
activity: "Bezeichnung der Verarbeitungstätigkeit",
purpose: "Ihre Daten werden zu folgendem Zweck erhoben",
legal: "Ihre Daten wurden aufgrund folgender Rechtsgrundlage erhoben",
obligation: "Ihre Pflicht zur Bereitstellung der Daten",
consequences: "Folgen, wenn Sie die Daten nicht angeben",
recipients: "Wir beabsichtigen, Ihre Daten an folgende Empfänger weiterzuleiten",
storage: "Dauer der Speicherung der personenbezogenen Daten",
source: "Wir haben Ihre Daten aus folgender Quelle erhalten",
}
/** Redaktionell aufbereitete Fassung der Textbausteine (Wortlaut unverändert). */
const TEMPLATE = {
responsible:
"**Amt Leezen – Der Amtsvorsteher**\n\n" +
"Verantwortlich für die Datenerhebung ist:\n" +
"Amtsvorsteher Ulrich Schulz\n" +
"Hamburger Str. 28, 23816 Leezen",
contactDPO:
"**Gemeinsame Datenschutzbeauftragte**\n" +
"Hamburger Str. 30, 23795 Bad Segeberg\n\n" +
"**E-Mail:** datenschutz@segeberg.de\n" +
"**Telefon:** 04551 / 951-9851",
dataSubjectsRights:
"Nach der Datenschutz-Grundverordnung stehen Ihnen folgende Rechte zu:\n\n" +
"Werden Ihre personenbezogenen Daten verarbeitet, haben Sie das Recht Auskunft über die zu Ihrer Person gespeicherten Daten zu erhalten (**Art. 15 DSGVO**).\n\n" +
"Sollten unrichtige personenbezogene Daten verarbeitet werden, steht Ihnen ein Recht auf Berichtigung zu (**Art. 16 DSGVO**).\n\n" +
"Liegen die gesetzlichen Voraussetzungen vor, können Sie die Löschung oder Einschränkung der Verarbeitung verlangen sowie Widerspruch gegen die Verarbeitung einlegen (**Art. 17, 18 und 21 DSGVO**).\n\n" +
"Wenn Sie in die Datenverarbeitung eingewilligt haben oder ein Vertrag zur Datenverarbeitung besteht und die Datenverarbeitung mithilfe automatisierter Verfahren durchgeführt wird, steht Ihnen gegebenenfalls ein Recht auf Datenübertragbarkeit zu (**Art. 20 DSGVO**).\n\n" +
"Sollten Sie von Ihren oben genannten Rechten Gebrauch machen, prüft die öffentliche Stelle, ob die gesetzlichen Voraussetzungen hierfür erfüllt sind.\n\n" +
"Weiterhin besteht ein Beschwerderecht bei der Schleswig-Holsteinischen Landesbeauftragten für den Datenschutz.",
complaintRight:
"Ihnen steht das Recht zu, sich für eine Beschwerde an die für uns zuständige Aufsichtsbehörde zu wenden:\n\n" +
"**Unabhängiges Landeszentrum für Datenschutz Schleswig-Holstein (ULD)**\n" +
"Holstenstr. 98, 24103 Kiel\n" +
"Telefon: 0431/988-1200\n" +
"Fax: 0431/988-1223\n" +
"E-Mail: mail@datenschutzzentrum.de\n" +
"Webseite: [www.datenschutzzentrum.de](https://www.datenschutzzentrum.de)",
withdrawalRight:
"Wenn Sie in die Verarbeitung durch die Amtsverwaltung Leezen durch eine entsprechende Erklärung eingewilligt haben, können Sie die Einwilligung jederzeit für die Zukunft widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Datenverarbeitung wird durch diesen nicht berührt.\n\n" +
"*Hinweis: Dies gilt nur für die Verarbeitungstätigkeiten, bei denen wir von Ihnen eine solche Einwilligungserklärung abgefordert haben.*",
}
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."
/**
* Baut aus den Abschnittszeilen Markdown. Einfache Zeilenumbrüche bleiben
* Zeilenumbrüche (remark-breaks rendert sie als <br>), damit Adress- und
* Aufzählungsblöcke so aussehen wie im Merkblatt.
*/
const block = (lines) => {
if (!lines || !lines.length) return null
const text = reflow(lines).join("\n").trim()
return text || null
}
function slugify(value) {
return value
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)
}
async function main() {
const dryRun = process.argv.includes("--dry-run")
const makePublic = process.argv.includes("--public")
const files = fs
.readdirSync(IMPORT_DIR)
.filter((f) => f.toLowerCase().endsWith(".pdf"))
.sort()
// Referenz-Textbausteine aus dem geprüften Dokument
const reference = await parsePolicyPdf(path.join(IMPORT_DIR, REFERENCE))
const referenceFingerprint = {}
for (const heading of BOILERPLATE_HEADINGS) {
referenceFingerprint[heading] = normalize(reference.sections[heading] || [])
}
const records = []
const deviations = []
const gaps = []
for (const file of files) {
const parsed = await parsePolicyPdf(path.join(IMPORT_DIR, file))
if (parsed.unknown.length) {
deviations.push(`${file}: unbekannte Überschrift(en) – ${parsed.unknown.join(", ")}`)
}
// Textbausteine einzeln gegen die Referenz prüfen
const boiler = {}
for (const heading of BOILERPLATE_HEADINGS) {
const own = normalize(parsed.sections[heading] || [])
boiler[heading] = own === referenceFingerprint[heading]
if (!boiler[heading]) {
deviations.push(`${file}: „${heading}“ weicht von der Vorlage ab – PDF-Text wird übernommen`)
}
}
const title = joinTitle(parsed.sections[H.activity] || [])
if (!title) {
deviations.push(`${file}: keine Bezeichnung der Verarbeitungstätigkeit – übersprungen`)
continue
}
const record = {
_quelle: file,
title,
responsible: boiler[H.responsible]
? TEMPLATE.responsible
: "**Amt Leezen – Der Amtsvorsteher**\n\n" +
reflow(parsed.sections[H.responsible] || []).join("\n"),
contactDPO: boiler[H.contactDPO]
? TEMPLATE.contactDPO
: reflow(parsed.sections[H.contactDPO] || []).join("\n"),
dataSubjectsRights: boiler[H.rights]
? TEMPLATE.dataSubjectsRights
: block(parsed.sections[H.rights]),
complaintRight: boiler[H.complaint]
? TEMPLATE.complaintRight
: block(parsed.sections[H.complaint]),
withdrawalRight: boiler[H.withdrawal]
? TEMPLATE.withdrawalRight
: block(parsed.sections[H.withdrawal]),
processingDescription: `**${title}**\n\n${INTRO}`,
processingPurposes: block(parsed.sections[H.purpose]),
legalBasis: block(parsed.sections[H.legal]),
recipients: block(parsed.sections[H.recipients]),
storageDuration: block(parsed.sections[H.storage]),
dataSource: block(parsed.sections[H.source]),
provisionObligation: block(parsed.sections[H.obligation]),
provisionConsequences: block(parsed.sections[H.consequences]),
isPublic: makePublic,
}
const missing = []
if (!record.processingPurposes) missing.push("Zweck")
if (!record.legalBasis) missing.push("Rechtsgrundlage")
if (missing.length) gaps.push(`${file} – „${title}“: ohne ${missing.join(" und ")}`)
records.push(record)
}
// Doppelte Titel anhand des Zwecks unterscheidbar machen
const byTitle = new Map()
for (const r of records) {
if (!byTitle.has(r.title)) byTitle.set(r.title, [])
byTitle.get(r.title).push(r)
}
const renamed = []
for (const [title, group] of byTitle) {
if (group.length < 2) continue
group.forEach((r, index) => {
const hint = r.processingPurposes ? r.processingPurposes.split("\n")[0] : null
const unique = hint && group.filter((g) => g.processingPurposes === r.processingPurposes).length === 1
r.title = unique ? `${title} (${hint})` : `${title} (${index + 1})`
renamed.push(`${r._quelle}: „${title}“ → „${r.title}“`)
})
}
console.log(`PDFs gelesen: ${files.length}`)
console.log(`Datensätze aufbereitet: ${records.length}\n`)
console.log(`=== Abweichungen von der Vorlage: ${deviations.length} ===`)
deviations.forEach((d) => console.log(` ${d}`))
console.log(`\n=== Ohne Zweck / Rechtsgrundlage: ${gaps.length} ===`)
gaps.forEach((g) => console.log(` ${g}`))
console.log(`\n=== Titel eindeutig gemacht: ${renamed.length} ===`)
renamed.forEach((r) => console.log(` ${r}`))
if (dryRun) {
console.log("\n--dry-run: nichts geschrieben.")
return
}
fs.mkdirSync(JSON_DIR, { recursive: true })
let created = 0
let updated = 0
for (const record of records) {
const { _quelle, ...data } = record
fs.writeFileSync(
path.join(JSON_DIR, `${slugify(record.title)}.json`),
JSON.stringify(record, null, 2),
"utf8"
)
const existing = await prisma.privacyPolicy.findFirst({
where: { title: data.title },
})
if (existing) {
// Sichtbarkeit bereits vorhandener Datensätze nicht überschreiben
await prisma.privacyPolicy.update({
where: { id: existing.id },
data: { ...data, isPublic: existing.isPublic },
})
updated += 1
} else {
await prisma.privacyPolicy.create({ data })
created += 1
}
}
console.log(`\nNeu angelegt: ${created}`)
console.log(`Aktualisiert: ${updated}`)
console.log(`JSON-Dateien geschrieben nach data/policies/`)
}
main()
.catch((error) => {
console.error("Fehler beim Import:", error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})