All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s
Die öffentliche API gab bei ?admin=true alle Datensätze heraus, auch die nicht veröffentlichten: von 117 Erklärungen waren 112 Entwürfe, die jeder Besucher abrufen konnte. Der Parameter setzt jetzt eine Admin-Sitzung voraus, sonst kommt die öffentliche Liste. Der Einzelabruf behandelt Entwürfe wie nicht vorhanden, damit sich über die fortlaufende ID nicht abklopfen lässt, welche Datensätze existieren. Markdown wurde mit sanitize: false gerendert und per dangerouslySetInnerHTML eingesetzt. Das Standard-Schema lässt jetzt Überschriften, Listen, Tabellen und http(s)/mailto-Links durch und entfernt Skripte, Event-Attribute, eingebettete Rahmen und javascript:-Ziele. Der Titel in der Druckfassung war an drei Stellen unmaskiert. Standardtexte - Zehn Bausteine, die für alle Erklärungen gelten, werden einmal zentral gepflegt. In der Erklärung steht nur eine Abweichung; ein leeres Feld bedeutet "folgt dem Standard". Ein Wechsel in der Amtsleitung wirkt damit sofort auf alle Erklärungen, die nicht bewusst abweichen. - Die Migration übernimmt den häufigsten Bestandswert als Standard und setzt übereinstimmende Felder auf NULL. Von 117 Erklärungen folgen danach 116 dem Standard, eine weicht ab (Online-Verfahren mit ZIT-SH als zusätzlichem Verantwortlichen). Pflichtangaben nach Art. 13/14 DSGVO - Neue Felder für Drittlandübermittlung (Art. 13 Abs. 1 lit. f), automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f), Datenkategorien (Art. 14 Abs. 1 lit. d) und berechtigte Interessen (Art. 13 Abs. 1 lit. d). Auf die ersten beiden muss auch dann hingewiesen werden, wenn es sie nicht gibt, deshalb sind sie Standardtexte mit ausdrücklichem "findet nicht statt". - Der Hinweis auf das Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten Kasten vor allen Abschnitten, nicht als Zeile im Fließtext der Betroffenenrechte. - Zweck, Rechtsgrundlage und Speicherdauer sind Pflicht. Geprüft wird beim Veröffentlichen, nicht beim Speichern: Ein Entwurf darf unvollständig sein, eine öffentlich sichtbare Erklärung nicht. So bleiben Bestandsdaten bearbeitbar, ohne dass die Anwendung Angaben erfindet, die nur die Fachabteilung kennt. Die Admin-Übersicht markiert unvollständige Erklärungen und nennt die fehlenden Angaben. Öffentliche Seiten - Jede Erklärung hat eine dauerhafte Adresse /erklaerung/<bezeichner>, serverseitig gerendert, zum Verlinken aus Antragsformularen. Der Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten erhalten, solange der Titel gleich bleibt. Entwürfe liefern 404. - Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit werden im Admin als Markdown gepflegt und in der Fußzeile verlinkt. Die Fußzeile rendert serverseitig, weil das Impressum ohne JavaScript erreichbar sein muss. Ein leerer Text nimmt die Seite vom Netz, statt eine leere Seite auszuliefern. - Die Übersicht nutzt echte Links statt Klick-Handler und wird serverseitig vorbefüllt. APP_COMPANY_NAME ersetzt den Namen der Anwendung in Kopf- und Fußzeile, Seitentiteln, Anmeldeseite, Druckfassung und Startmeldung des Containers. Der Wert wird zur Laufzeit gelesen, ein Neubau des Images ist zum Umbenennen nicht nötig. Getestet gegen die 117 Bestandsdatensätze: Migrationen angewendet, der angezeigte Text blieb bei allen 117 x 7 Baustein-Feldern identisch. Alle 117 Bezeichner sind eindeutig und stimmen mit lib/slug.ts überein (SQLites LOWER() kennt nur ASCII, Großumlaute werden deshalb vorher ersetzt). ?admin=true liefert ohne Anmeldung 5 statt 117 Datensätze, Entwürfe 404. Über die API mit Anmeldung geprüft: unvollständig als Entwurf wird angelegt, dieselben Angaben mit isPublic abgelehnt, ebenso fehlende Datenkategorien bei angegebener Herkunft. Ohne APP_COMPANY_NAME erscheint "Syncova Policies", mit gesetztem Wert kein einziges Vorkommen mehr. Die Änderungen liegen in einem Commit, weil Schema, API-Routen und Formulare von allen Teilen berührt werden und eine thematische Aufteilung nicht lauffähige Zwischenstände ergäbe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
/**
|
||
* 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 { readDefaults, foldIntoDefaults } = require("./lib/policy-defaults")
|
||
const { resolveSlug } = require("./lib/slug")
|
||
|
||
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 })
|
||
// Standardtexte einmal lesen: Bausteine, die ihnen entsprechen, werden nicht
|
||
// je Erklärung dupliziert, sondern als „folgt dem Standard“ gespeichert.
|
||
const defaults = await readDefaults(prisma)
|
||
let created = 0
|
||
let updated = 0
|
||
let withOverrides = 0
|
||
|
||
for (const record of records) {
|
||
const { _quelle, ...data } = record
|
||
|
||
if (foldIntoDefaults(data, defaults).length > 0) withOverrides += 1
|
||
|
||
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 },
|
||
select: { id: true, title: true, slug: true, isPublic: true },
|
||
})
|
||
|
||
// Bezeichner für die öffentliche URL; bleibt bei unverändertem Titel gleich
|
||
data.slug = await resolveSlug(prisma, data.title, existing)
|
||
|
||
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()
|
||
})
|