Merge kn/recht (P8 Rechtsrunde Fassung 6) in feature/kundenansicht-neu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-16 16:59:27 +00:00
commit 72c5fa9fea
39 files changed
+5772 -35

No files matched your search

+122 -12
View File
@@ -18,18 +18,25 @@
// dasselbe für die Widerrufsbelehrung der Projektaufträge (Wortlaut aus server/services/auftraege/texte.js)
// und die AGB-Kopie (Quelle docs/rechtliches/agb.md)
//
// node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=<doc,doc>]
// die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Quellen docs/rechtliches/<slug>-v6.md)
// im neuen Stil (meta.stil 'neu') unter public/neu/rechtliches/<slug>-<Fassung>.html; --nur= begrenzt auf die
// genannten Dokumente (news-lb, termine-lb, termine-avv, shop-lb, shop-avv, unterkunft-lb, unterkunft-avv, zeiten-lb,
// analyse-lb, analyse-avv; auch der Dateiname ohne Fassung, z. B. avv-webshop). Solange ENTWURF_V6 gilt, tragen die Seiten meta robots noindex.
//
// Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und
// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ###
// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen).
// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab,
// damit nie eine Seite mit falsch dargestelltem Rechtstext entsteht.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join } from 'node:path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../server/config.js'
import { legalPaths } from '../server/services/legalDocs.js'
import { TEXTE_FASSUNG, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from '../server/services/auftraege/texte.js'
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/
@@ -190,6 +197,44 @@ const STYLE = (seitenkopf) => ` :root { color-scheme: light; }
}
`
/**
* Neue Seitenhülle (Fassung 6, Spec 5.1): weiße Fläche, Text #1a1f36, Zweittext #5e6678, Linien #ebeef1/#d8dde3, Akzent #111, Systemschrift,
* keine Karten, kein Lila, kein Gewicht 800, kein Dunkelmodus. Dieselben Tokens wie die Abo-Bedingungen unter public/neu/rechtliches/.
*/
const STYLE_NEU = (seitenkopf) => ` :root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: #1a1f36; font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-variant-numeric: tabular-nums; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 600; letter-spacing: 0.12em; font-size: 13px; color: #111; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; font-weight: 600; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #ebeef1; font-weight: 600; }
h3 { font-size: 16.5px; margin: 22px 0 6px; font-weight: 600; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #5e6678; font-size: 14px; }
.box { border-top: 1px solid #d8dde3; border-bottom: 1px solid #d8dde3; padding: 14px 0; margin: 18px 0; }
.box.widerspruch { border-top: 2px solid #111; border-bottom: 2px solid #111; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #ebeef1; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #5e6678; background: #f8f9fb; padding-left: 8px; }
a { color: #111; text-decoration: underline; text-decoration-color: #d8dde3; text-underline-offset: 2px; }
a:hover { background: #f6f8fa; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { display: inline-block; background: #fcedb9; color: #a82c00; padding: 1px 10px; border-radius: 6px; font-weight: 600; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #8a93a3; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #8a93a3; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
}
`
/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https; kein Rückwärtsschrägstrich (Browser lesen ihn wie "/"). */
function pruefeHref(href) {
if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>\\]/.test(href)) {
@@ -231,8 +276,10 @@ function bloeckeHtml(bloecke) {
/**
* Rendert ein Dokument in die Seitenhülle der Rechtstexte.
* @param {string} md Markdown (Teilmenge, siehe Kopf); erste Zeile "# Titel", danach der Absatz "**Fassung JJJJ-MM-TT**[ · Zusatz]"
* @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }> }} meta
* titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · "
* @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }>, stil?: 'neu', noindex?: boolean }} meta
* titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · ";
* stil = 'neu' wählt die weiße Hülle der Fassung 6 (ohne stil bleibt die Ausgabe bytegleich zur bisherigen Hülle);
* noindex = true ergänzt meta robots noindex (Entwurfsseiten)
* @returns {string} HTML mit abschließendem Zeilenumbruch
*/
export function render(md, meta) {
@@ -241,6 +288,7 @@ export function render(md, meta) {
if (typeof meta[feld] !== 'string' || meta[feld].trim() === '') throw new Error(`render: meta.${feld} fehlt`)
}
if (!Array.isArray(meta.nav) || meta.nav.length === 0) throw new Error('render: meta.nav fehlt')
if (meta.stil !== undefined && meta.stil !== 'neu') throw new Error(`render: meta.stil ist unbekannt (erlaubt: 'neu' oder weglassen): ${String(meta.stil).slice(0, 30)}`)
const bloecke = parse(md)
if (bloecke[0]?.art !== 'h' || bloecke[0].ebene !== 1) throw new MarkdownFehler(1, 'die erste Zeile muss "# Titel" sein')
if (bloecke.filter((b) => b.art === 'h' && b.ebene === 1).length !== 1) throw new MarkdownFehler(1, 'genau eine Überschrift #')
@@ -254,9 +302,9 @@ export function render(md, meta) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="${esc(meta.fassung)}">
<title>${esc(meta.titel)} – WEBklar</title>
${meta.noindex === true ? ' <meta name="robots" content="noindex">\n' : ''} <title>${esc(meta.titel)} – WEBklar</title>
<style>
${STYLE(meta.seitenkopf)} </style>
${(meta.stil === 'neu' ? STYLE_NEU : STYLE)(meta.seitenkopf)} </style>
</head>
<body>
<main>
@@ -338,7 +386,49 @@ export function unterkunftDokumente(fassung = config.unterkunft.fassung) {
)
}
/** Sätze der Kommandozeile: Name => Dokumente. */
/** Namen der Funktionen in Titel und Kopfzeile der Fassung 6 (Schlüssel wie in FUNKTIONEN_UNTERLAGEN_V6). */
const FUNKTIONEN6_NAMEN = {
news: 'News-Pinnwand',
termine: 'Terminplaner',
shop: 'Webshop',
unterkunft: 'Buchungskalender',
zeiten: 'Öffnungszeiten live',
analyse: 'Website-Analyse',
}
/**
* Entwurfsstand der Fassung 6: solange true, tragen die Funktionsseiten meta robots noindex (Nachtrag N45). Bei der Freigabe auf false setzen,
* zusammen mit dem Entfernen der Entwurfsmarke aus den Quellen (Ablauf in docs/rechtliches/kundenansicht-entscheidungen.md).
*/
export const ENTWURF_V6 = true
/**
* Die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Reihenfolge wie FUNKTIONEN_UNTERLAGEN_V6) im neuen Stil.
* doc = '<key>-lb' | '<key>-avv'; Quelle docs/rechtliches/<slug>-v6.md, Ziel public/neu/rechtliches/<slug>-<Fassung>.html (Adressen aus products.js).
* Die Navigation verweist nur auf Seiten im Vorschaupfad, nie auf die eingefrorenen Seiten unter /rechtliches/.
*/
export function funktionen6Dokumente() {
return Object.entries(FUNKTIONEN_UNTERLAGEN_V6).flatMap(([key, unterlagen]) => {
const name = FUNKTIONEN6_NAMEN[key]
const dokumente = [
{ art: 'lb', d: unterlagen.lb, titel: `Leistungsbeschreibung ${name}`, seitenkopf: `Leistungsbeschreibung ${name}` },
...(unterlagen.avv ? [{ art: 'avv', d: unterlagen.avv, titel: `Auftragsverarbeitungsvertrag ${name}`, seitenkopf: `AVV ${name}` }] : []),
]
const nav = [
{ href: '/dashboard.html', text: '← Kundenportal' },
{ href: '/neu/rechtliches/abo-bedingungen.html', text: 'Abo-Bedingungen' },
...dokumente.map((x) => ({ href: x.d.url, text: x.seitenkopf })),
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return dokumente.map((x) => ({
doc: `${key}-${x.art}`,
md: `docs/rechtliches/${basename(x.d.url, `-${x.d.version}.html`)}-v6.md`,
html: `public${x.d.url}`,
meta: { fassung: x.d.version, titel: x.titel, seitenkopf: x.seitenkopf, nav, stil: 'neu', noindex: ENTWURF_V6 },
}))
})
}
/** Markdown eines Dokuments; trägt die Quelle eine andere Fassung (d.stempel), wird die Fassungszeile auf die des Dokuments gesetzt. */
export function quelleVon(d, root = ROOT) {
@@ -407,23 +497,42 @@ export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AG
}
/** Sätze der Kommandozeile: Name => Dokumente. */
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente }
const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente, funktionen6: funktionen6Dokumente }
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien).
* Kommandozeile: `termine|shop|unterkunft|auftraege [--check]` und `funktionen6 [--check] [--nur=<doc,doc>]`. root = Wurzel des Projekts
* (Tests übergeben ein Verzeichnis mit Kopien).
* => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf
*/
export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) {
const [satz, ...optionen] = argv
const pruefen = optionen.includes('--check')
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) {
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n node scripts/build-legal-html.mjs auftraege [--check]')
const nurOptionen = optionen.filter((o) => o.startsWith('--nur='))
const usage = () => fehler(
'Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n'
+ ' node scripts/build-legal-html.mjs auftraege [--check]\n'
+ ' node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=<doc,doc>]',
)
if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check' && !o.startsWith('--nur=')) || nurOptionen.length > 1 || (nurOptionen.length === 1 && satz !== 'funktionen6')) {
usage()
return 2
}
let dokumente = SAETZE[satz]()
if (nurOptionen.length === 1) {
// erlaubt sind der Name des Dokuments (doc, z. B. shop-avv) und der Name seiner Datei ohne Fassung und Endung (z. B. avv-webshop)
const namen = (d) => [d.doc, basename(d.html).replace(/-\d{4}-\d{2}-\d{2}\.html$/, '')]
const gewuenscht = nurOptionen[0].slice('--nur='.length).split(',').map((x) => x.trim())
if (gewuenscht.some((x) => !dokumente.some((d) => namen(d).includes(x)))) {
fehler(`Unbekanntes Dokument in --nur=. Erlaubt: ${dokumente.map((d) => d.doc).join(', ')} (oder der Dateiname ohne Fassung)`)
usage()
return 2
}
dokumente = dokumente.filter((d) => namen(d).some((n) => gewuenscht.includes(n)))
}
let abweichungen = 0
for (const d of SAETZE[satz]()) {
for (const d of dokumente) {
const html = render(d.markdown ? d.markdown() : quelleVon(d, root), d.meta)
const ziel = join(root, d.html)
if (pruefen) {
@@ -431,6 +540,7 @@ export function lauf(argv, { root = ROOT, log = console.log, fehler = console.er
log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`)
if (!gleich) abweichungen += 1
} else {
mkdirSync(dirname(ziel), { recursive: true })
writeFileSync(ziel, html)
log(`-> ${d.html}`)
}
+46 -1
View File
@@ -15,6 +15,11 @@
# auftraege = Widerrufsbelehrung der Projektauftraege (Fassung TEXTE_VERSION, Standard: TEXTE_FASSUNG aus server/services/auftraege/texte.js) und AGB
# (Fassung AGB_VERSION, Standard: AGB_FASSUNG aus server/services/auftraege/konstanten.js); die HTML-Seiten kommen vorher aus
# `node scripts/build-legal-html.mjs auftraege`, Pruefsumme und Neudruck wie bei termine und shop. `all` druckt auch diese beiden PDFs.
# abo6 = Abo-Bedingungen Fassung 6 (public/neu/rechtliches/abo-bedingungen.html -> WEBklar-Abo-Vertragsunterlagen.pdf daneben, <PDF>.sha256 mit der SHA-256 der
# Seite, Neudruck nur bei geaenderter Seite, FORCE=1 druckt neu). Entwurf; erst nach der Freigabe drucken, vorher `free -m`, mit `nice`. `all` druckt diese Datei nicht.
# funktionen6 = die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Fassung FUNKTIONEN6_VERSION, Standard: FUNKTIONEN_FASSUNG_V6 aus
# server/services/products.js) aus public/neu/rechtliches/<slug>-<Fassung>.html nach <slug>-<Fassung>.pdf; die HTML-Seiten kommen vorher aus
# `node scripts/build-legal-html.mjs funktionen6`, Pruefsumme und Neudruck wie bei abo6. Entwurf; erst nach der Freigabe drucken, vorher `free -m`, mit `nice`.
# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf); daneben liegt <PDF>.sha256 mit der SHA-256 von
# public/datenschutz.html (test/legalTexts.test.js prueft sie); ein PDF mit passender Pruefsumme wird nicht neu gedruckt (FORCE=1 druckt neu)
# Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander.
@@ -82,6 +87,16 @@ agb_version() {
echo "$v"
}
# Fassung der Funktionsunterlagen 6: FUNKTIONEN6_VERSION, sonst FUNKTIONEN_FASSUNG_V6 aus server/services/products.js
funktionen6_version() {
local v="${FUNKTIONEN6_VERSION:-}"
if [ -z "$v" ]; then
v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { FUNKTIONEN_FASSUNG_V6 } = await import('./server/services/products.js'); console.log(FUNKTIONEN_FASSUNG_V6)")"
fi
[[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; }
echo "$v"
}
# Widerrufsbelehrung der Projektauftraege und AGB drucken (je PDF nur, wenn die Pruefsumme der HTML-Seite nicht mehr passt)
auftraege_pdfs() {
local texte agb eintrag slug fassung quelle ziel summe
@@ -173,6 +188,36 @@ datenschutz)
echo "$summe" > "$ziel.sha256"
fi
;;
abo6)
# Entwurf Fassung 6 der Abo-Bedingungen: Seite und PDF liegen im Vorschaupfad; erst nach der Freigabe drucken
quelle="public/neu/rechtliches/abo-bedingungen.html"
ziel="public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf"
[ -f "$quelle" ] || { echo "HTML fehlt: $quelle"; exit 1; }
summe="$(sha256sum "$quelle" | cut -d' ' -f1)"
if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then
echo "= $ziel (unveraendert)"
else
pdf "$quelle" "$ziel"
echo "$summe" > "$ziel.sha256"
fi
;;
funktionen6)
# Entwurf Fassung 6 der Funktionsunterlagen (zehn Seiten): Leistungsbeschreibungen und AVV, nacheinander, nie parallel
fassung="$(funktionen6_version)"
for slug in leistungsbeschreibung-news leistungsbeschreibung-terminplaner avv-terminplaner leistungsbeschreibung-webshop avv-webshop \
leistungsbeschreibung-unterkunft avv-unterkunft leistungsbeschreibung-zeiten leistungsbeschreibung-website-analyse avv-website-analyse; do
quelle="public/neu/rechtliches/$slug-$fassung.html"
ziel="public/neu/rechtliches/$slug-$fassung.pdf"
[ -f "$quelle" ] || { echo "HTML fehlt: $quelle (zuerst node scripts/build-legal-html.mjs funktionen6)"; exit 1; }
summe="$(sha256sum "$quelle" | cut -d' ' -f1)"
if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then
echo "= $ziel (unveraendert)"
else
pdf "$quelle" "$ziel"
echo "$summe" > "$ziel.sha256"
fi
done
;;
all)
pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf
# Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt
@@ -182,6 +227,6 @@ all)
auftraege_pdfs
;;
*)
echo "Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]"; echo " $0 auftraege (Widerrufsbelehrung der Projektauftraege und AGB)"; exit 1
echo "Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]"; echo " $0 auftraege (Widerrufsbelehrung der Projektauftraege und AGB)"; echo " $0 abo6|funktionen6 (Entwurf Fassung 6: Abo-Bedingungen bzw. Funktionsunterlagen, erst nach der Freigabe)"; exit 1
;;
esac
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env node
// Lädt die Rechtstexte der Fassung 6 (Kundenansicht neu) nach der Freigabe in WOMS „Rechtliches“ hoch (Bucket legal-files, Collection
// legalVersions), wie es die WOMS-Oberfläche tut: neue Fassung "current", die bisherige gültige wird "superseded" mit validTo. Ein Dokument
// je Aufruf, nur mit --freigabe=JJJJ-MM-TT; schon hochgeladene Fassungen (gleiche source) werden übersprungen.
//
// node scripts/rechtliches-hochladen.mjs --probe nur lesen: Schlüssel, Bucket, Versionen und Karten; jede fehlende Karte ist ein Stopp
// node scripts/rechtliches-hochladen.mjs --karten-anlegen legt die fehlenden Karten in legalDocuments an (vorhandene nie), einmal vor dem ersten Hochladen
// node scripts/rechtliches-hochladen.mjs <documentKey> --freigabe=JJJJ-MM-TT
// lädt genau ein Dokument hoch (validFrom = Freigabedatum); ohne Karte Abbruch vor dem Schreiben
// node scripts/rechtliches-hochladen.mjs --pruefen nur lesen: Nachkontrolle, Exit 0 nur, wenn alle elf Dokumente eine aktuelle Version
// mit passender source haben
//
// Schlüssel: kurzlebiger Appwrite-Schlüssel mit files.read/files.write/documents.read/documents.write in /root/.appwrite-rechtliches.key (chmod 600).
// Der Import dieser Datei liest weder den Schlüssel noch nutzt er das Netz; beides geschieht nur in main (deps.schluessel, deps.fetch).
// Dieses Skript wird im Paket P8 nur geschrieben und getestet (mit Nachbau-deps), nie ausgeführt.
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
const ENDPOINT = 'https://ticket.webklar.com/v1'
const PROJECT = '6a1058610003c5a13a05'
const DB = 'woms-database'
const BUCKET = 'legal-files'
const KEY_DATEI = '/root/.appwrite-rechtliches.key'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const LIVE = 'https://project.webklar.com/'
const USAGE = 'Aufruf: node scripts/rechtliches-hochladen.mjs --probe | --karten-anlegen | --pruefen | <documentKey> --freigabe=JJJJ-MM-TT (genau ein Dokument je Aufruf)'
// ---------------------------------------------------------------------------------------------------------- Dokumente
const NAMEN = {
news: 'News-Pinnwand',
termine: 'Terminplaner',
shop: 'Webshop',
unterkunft: 'Buchungskalender',
zeiten: 'Öffnungszeiten live',
analyse: 'Website-Analyse',
}
const ABO = Object.freeze({
documentKey: 'abo-bedingungen',
dateien: Object.freeze(['public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', 'public/neu/rechtliches/abo-bedingungen.html']),
titel: 'Abo-Bedingungen',
quelle: 'public/neu/rechtliches/abo-bedingungen.html',
})
/**
* Die elf Dokumente der Fassung 6 in der Reihenfolge der Tabelle: die Abo-Bedingungen, dann je Funktion Leistungsbeschreibung und AVV wie in
* FUNKTIONEN_UNTERLAGEN_V6 (documentKey = Slug der Datei ohne Fassung). dateien = [PDF, HTML]; quelle = der HTML-Pfad, den source beim Hochladen
* trägt (verschiebt P10 die Abo-Seite, ändert sich dateien, quelle bleibt).
*/
export const DOKUMENTE_V6 = Object.freeze([
ABO,
...Object.entries(FUNKTIONEN_UNTERLAGEN_V6).flatMap(([key, unterlagen]) =>
[['lb', unterlagen.lb, `Leistungsbeschreibung ${NAMEN[key]}`], ['avv', unterlagen.avv, `AVV ${NAMEN[key]}`]]
.filter(([, dok]) => dok)
.map(([, dok, titel]) => {
const html = `public${dok.url}`
return Object.freeze({
documentKey: path.basename(dok.url, `-${dok.version}.html`),
dateien: Object.freeze([`public/${dok.pdfFile}`, html]),
titel,
quelle: html,
})
}),
),
])
/** Eintrag der Tabelle zu einem documentKey; sonst Error('Unbekanntes Dokument: …'). */
export function dokumentFuer(documentKey) {
const d = DOKUMENTE_V6.find((x) => x.documentKey === documentKey)
if (!d) throw new Error(`Unbekanntes Dokument: ${String(documentKey).slice(0, 80)}`)
return d
}
// ---------------------------------------------------------------------------------------------------------- reine Funktionen
const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' }
const istKalendertag = (iso) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const d = new Date(`${iso}T12:00:00Z`)
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso
}
/**
* Kommandozeile prüfen: ein Modus allein (--probe, --karten-anlegen, --pruefen) oder genau ein documentKey mit --freigabe=JJJJ-MM-TT.
* @returns {{ modus: 'probe'|'karten'|'pruefen'|'hochladen', documentKey: string|null, freigabe: string|null }}
*/
export function parseArgs(argv) {
const optionen = argv.filter((a) => a.startsWith('--'))
const dokumente = argv.filter((a) => !a.startsWith('--'))
const modi = optionen.filter((o) => Object.hasOwn(MODI, o))
const freigaben = optionen.filter((o) => o.startsWith('--freigabe='))
const fremde = optionen.filter((o) => !Object.hasOwn(MODI, o) && !o.startsWith('--freigabe='))
if (fremde.length > 0) throw new Error(`${USAGE}\nUnbekannte Option: ${fremde[0].slice(0, 40)}`)
if (modi.length > 0) {
if (modi.length > 1 || dokumente.length > 0 || freigaben.length > 0) throw new Error(`${USAGE}\n${modi[0]} steht allein.`)
return { modus: MODI[modi[0]], documentKey: null, freigabe: null }
}
if (dokumente.length !== 1) throw new Error(`${USAGE}\nGenau ein Dokument je Aufruf.`)
if (freigaben.length !== 1) throw new Error('Freigabe fehlt: --freigabe=JJJJ-MM-TT ist beim Hochladen Pflicht.')
const freigabe = freigaben[0].slice('--freigabe='.length)
if (!istKalendertag(freigabe)) throw new Error(`Freigabe ungültig (erwartet JJJJ-MM-TT, ein echter Kalendertag): ${freigabe.slice(0, 20)}`)
dokumentFuer(dokumente[0])
return { modus: 'hochladen', documentKey: dokumente[0], freigabe }
}
/** Der Tag vor einem Datum (JJJJ-MM-TT). */
export function tagVorher(iso) {
const d = new Date(`${iso}T12:00:00Z`)
d.setUTCDate(d.getUTCDate() - 1)
return d.toISOString().slice(0, 10)
}
/**
* Plan für eine neue Version eines Dokuments: schon = gleiche source ist hochgeladen; nummer = vN (Zahl der Versionen mit ^v\d+$ plus eins);
* aktuellId = die bisherige gültige Version (jüngstes validFrom), die abgelöst wird; validToAltes = Tag vor validFrom, bei gleichem oder
* früherem validFrom = validFrom (wie die Vorlage des Webshop-Livegangs).
*/
export function planVersion({ alle, source, validFrom }) {
const aktuell = alle.filter((v) => v.status === 'current').sort((a, b) => b.validFrom.localeCompare(a.validFrom))[0]
return {
schon: alle.some((v) => v.source === source),
nummer: `v${alle.filter((v) => /^v\d+$/.test(v.version)).length + 1}`,
aktuellId: aktuell ? aktuell.$id : null,
validToAltes: aktuell ? (validFrom > aktuell.validFrom ? tagVorher(validFrom) : validFrom) : null,
}
}
/** Dokument-ID einer Karte: der Schlüssel, wenn er höchstens 36 Zeichen hat, sonst 27 Zeichen davon, "-" und 8 Hexzeichen von sha256(key). */
export function kartenId(key) {
if (key.length <= 36) return key
return `${key.slice(0, 27)}-${createHash('sha256').update(key).digest('hex').slice(0, 8)}`
}
/** Die fehlenden Karten in Tabellenreihenfolge: [{ id, daten: { key, title, liveUrl, sort } }], sort ab hoechsteSort + 1. */
export function kartenPlan({ vorhandeneKeys, hoechsteSort }) {
const vorhanden = new Set(vorhandeneKeys)
let sort = hoechsteSort
return DOKUMENTE_V6.filter((d) => !vorhanden.has(d.documentKey)).map((d) => {
sort += 1
return { id: kartenId(d.documentKey), daten: { key: d.documentKey, title: d.titel, liveUrl: `${LIVE}${d.dateien[1].replace(/^public\//, '')}`, sort } }
})
}
const maskiere = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** Nachkontrolle: { [documentKey]: Version[] } -> { fehlt: documentKey[] }; ein Dokument zählt mit einer Version status current und source kundenbereich@<Hash>:<quelle>. */
export function pruefeHochgeladen(versionenJe) {
const fehlt = DOKUMENTE_V6.filter((d) => {
const muster = new RegExp(`^kundenbereich@[0-9a-f]{7,40}:${maskiere(d.quelle)}$`)
return !(versionenJe[d.documentKey] ?? []).some((v) => v.status === 'current' && muster.test(v.source ?? ''))
}).map((d) => d.documentKey)
return { fehlt }
}
// ---------------------------------------------------------------------------------------------------------- Zugriff auf WOMS
const q = (o) => encodeURIComponent(JSON.stringify(o))
const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20)
/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */
function client(fetchFn, schluessel) {
const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel }
return async function api(method, pfad, body) {
const formular = typeof FormData !== 'undefined' && body instanceof FormData
const res = await fetchFn(`${ENDPOINT}${pfad}`, {
method,
headers: formular ? kopf : { ...kopf, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : formular ? body : JSON.stringify(body),
})
const text = await res.text()
if (!res.ok) throw Object.assign(new Error(`${method} ${pfad}: ${res.status} ${text.slice(0, 200)}`), { status: res.status })
return text ? JSON.parse(text) : null
}
}
async function alleDokumente(api, sammlung) {
const aus = []
for (let offset = 0; ; offset += 100) {
const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`)
aus.push(...r.documents)
if (r.documents.length < 100) return aus
}
}
async function versionen(api, documentKey) {
const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`)
return r.documents
}
const ddmmjjjj = (iso) => iso.split('-').reverse().join('.')
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Einstieg für alle Aufrufe. deps = { fetch, schluessel, log, git, dateien } mit den Standards globales fetch, Datei /root/.appwrite-rechtliches.key,
* console.log, git im Projekt und das Dateisystem; der einzige Weg zu Schlüssel und Netz.
* @returns {Promise<number>} Exit-Code (0 oder 1)
*/
export async function main(argv, deps = {}) {
const log = deps.log ?? console.log
let args
try {
args = parseArgs(argv)
} catch (err) {
log(err.message)
return 1
}
try {
const fetchFn = deps.fetch ?? globalThis.fetch
const api = client(fetchFn, (deps.schluessel ?? (() => readFileSync(KEY_DATEI, 'utf8').trim()))())
if (args.modus === 'probe') return await probe(api, log)
if (args.modus === 'karten') return await kartenAnlegen(api, log)
if (args.modus === 'pruefen') return await pruefen(api, log)
return await hochladen(api, log, args, deps)
} catch (err) {
log(`Fehler: ${err.message}`)
return 1
}
}
/** --probe: nur lesen. Jede fehlende Karte ist ein Stopp. */
async function probe(api, log) {
await api('GET', `/storage/buckets/${BUCKET}/files?queries[]=${q({ method: 'limit', values: [1] })}`)
const karten = new Set((await alleDokumente(api, 'legalDocuments')).map((k) => k.key))
let stopp = 0
for (const d of DOKUMENTE_V6) {
log(`${d.documentKey}: ${(await versionen(api, d.documentKey)).map((v) => `${v.version}:${v.status}`).join(' ') || '—'}`)
if (!karten.has(d.documentKey)) {
log(`STOPP: Karte fehlt: ${d.documentKey}`)
stopp += 1
}
}
if (stopp > 0) return 1
log('Schlüssel ok (files.read + documents.read), alle Karten vorhanden.')
return 0
}
/** --karten-anlegen: legt nur die fehlenden Karten an, vorhandene nie. */
async function kartenAnlegen(api, log) {
const vorhanden = await alleDokumente(api, 'legalDocuments')
const plan = kartenPlan({ vorhandeneKeys: vorhanden.map((k) => k.key), hoechsteSort: Math.max(0, ...vorhanden.map((k) => Number(k.sort) || 0)) })
for (const karte of plan) {
await api('POST', `/databases/${DB}/collections/legalDocuments/documents`, { documentId: karte.id, data: karte.daten })
log(`+ Karte ${karte.daten.key} (ID ${karte.id})`)
}
if (plan.length === 0) log('Alle Karten sind vorhanden.')
return 0
}
/** --pruefen: nur lesen, Nachkontrolle nach den Uploads. */
async function pruefen(api, log) {
const je = {}
for (const d of DOKUMENTE_V6) je[d.documentKey] = await versionen(api, d.documentKey)
const { fehlt } = pruefeHochgeladen(je)
for (const d of DOKUMENTE_V6) log(`${fehlt.includes(d.documentKey) ? 'FEHLT' : 'ok'} ${d.documentKey}`)
return fehlt.length > 0 ? 1 : 0
}
/** <documentKey> --freigabe=…: genau ein Dokument hochladen. Als Erstes die Kartenprüfung, dann Git- und Dateiprüfung, erst dann schreiben. */
async function hochladen(api, log, { documentKey, freigabe }, deps) {
const d = dokumentFuer(documentKey)
const karten = await alleDokumente(api, 'legalDocuments')
if (!karten.some((k) => k.key === documentKey)) {
log(`Karte fehlt: ${documentKey} (zuerst --karten-anlegen)`)
return 1
}
const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }))
const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) }
for (const rel of d.dateien) {
if (!dateisystem.existsSync(rel)) {
log(`Datei fehlt: ${rel}`)
return 1
}
}
if (git(['status', '--porcelain', '--', ...d.dateien]).trim() !== '') {
log(`Abbruch: nicht committeter Stand in ${d.dateien.join(' und ')} (git status --porcelain ist nicht leer)`)
return 1
}
const commit = git(['rev-parse', '--short', 'HEAD']).trim()
const source = `kundenbereich@${commit}:${d.quelle}`
const alle = await versionen(api, documentKey)
const plan = planVersion({ alle, source, validFrom: freigabe })
if (plan.schon) {
log(`= ${documentKey}: schon hochgeladen`)
return 0
}
const fileIds = []
for (const rel of d.dateien) {
const form = new FormData()
form.append('fileId', neueId())
form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel))
fileIds.push((await api('POST', `/storage/buckets/${BUCKET}/files`, form)).$id)
}
await api('POST', `/databases/${DB}/collections/legalVersions/documents`, {
documentId: neueId(),
data: {
documentKey,
version: plan.nummer,
validFrom: freigabe,
validTo: null,
status: 'current',
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: Kundenansicht neu (Fassung 6)`,
fileIds,
source,
},
})
if (plan.aktuellId) {
await api('PATCH', `/databases/${DB}/collections/legalVersions/documents/${plan.aktuellId}`, { data: { status: 'superseded', validTo: plan.validToAltes } })
}
log(`+ ${documentKey} ${plan.nummer} (${fileIds.length} Dateien)${plan.aktuellId ? ', bisherige Version abgelöst' : ''}`)
return 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main(process.argv.slice(2)).then((code) => {
process.exitCode = code
})
}