Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env node
// HTML-Generator der Rechtstexte: rendert die Markdown-Quellen unter docs/rechtliches/ deterministisch in die Seiten unter
// public/rechtliches/<slug>-<Fassung>.html (dieselbe Seitenhülle wie die Seiten der Website-Analyse). Die PDFs entstehen
// danach aus diesen Seiten (scripts/build-legal-pdfs.sh termine).
//
// node scripts/build-legal-html.mjs termine schreibt die drei Seiten des Terminplaners
// node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts)
//
// Fassung = config.termine.avvVersion. 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 { fileURLToPath } from 'node:url'
import { config } from '../server/config.js'
import { legalPaths } from '../server/services/legalDocs.js'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/
export class MarkdownFehler extends Error {
constructor(zeile, was) {
super(`Markdown, Zeile ${zeile}: nicht unterstützt (${was})`)
this.name = 'MarkdownFehler'
}
}
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
/** HTML-Escape für Text und Attributwerte. */
export const esc = (text) => String(text).replace(/[&<>"']/g, (z) => ESCAPES[z])
/** Zeichenkette für ein CSS-content (in doppelten Anführungszeichen). */
const cssText = (text) => String(text).replace(/[\\"\n]/g, (z) => (z === '\n' ? '\\A ' : `\\${z}`))
// ---------------------------------------------------------------------------------------------------------- Markdown
/** Text außerhalb von `code`: rohes HTML (ein "<" unmittelbar vor Buchstabe, "/", "!" oder "?") bricht ab, alles andere wird maskiert. */
function klartext(text, zeile) {
if (/<[A-Za-z/!?]/.test(text)) throw new MarkdownFehler(zeile, 'rohes HTML')
return esc(text)
}
/** Inline-Teil: **fett**, `code`, sonst Text. Nur Text ohne Verschachtelung; alles andere wird escaped oder bricht ab. */
function inline(text, zeile) {
if (/!\[|\]\(/.test(text)) throw new MarkdownFehler(zeile, 'Bild oder Link')
if (text.includes('***')) throw new MarkdownFehler(zeile, '***')
let aus = ''
let i = 0
while (i < text.length) {
if (text.startsWith('**', i)) {
const ende = text.indexOf('**', i + 2)
if (ende < 0) throw new MarkdownFehler(zeile, 'offenes **')
const innen = text.slice(i + 2, ende)
if (innen === '' || innen.includes('`')) throw new MarkdownFehler(zeile, 'leeres oder verschachteltes **fett**')
aus += `<strong>${klartext(innen, zeile)}</strong>`
i = ende + 2
} else if (text[i] === '`') {
const ende = text.indexOf('`', i + 1)
if (ende < 0) throw new MarkdownFehler(zeile, 'offenes `')
if (ende === i + 1) throw new MarkdownFehler(zeile, 'leerer Code')
aus += `<code>${esc(text.slice(i + 1, ende))}</code>`
i = ende + 1
} else {
let ende = i
while (ende < text.length && text[ende] !== '`' && !text.startsWith('**', ende)) ende += 1
aus += klartext(text.slice(i, ende), zeile)
i = ende
}
}
return aus
}
/** Zerlegt Markdown in Blöcke { art: 'h'|'p'|'ul'|'table', … }; unbekannte Syntax wirft MarkdownFehler. */
export function parse(md) {
if (typeof md !== 'string') throw new TypeError('parse: Markdown als Text erwartet')
const zeilen = md.replace(/\r/g, '').split('\n')
const bloecke = []
let offen = null // der Block, an den die nächste Zeile anschließt (p | ul | table)
const schliesse = () => {
offen = null
}
zeilen.forEach((roh, index) => {
const nr = index + 1
if (roh.trim() === '') return schliesse()
if (/^\s/.test(roh)) throw new MarkdownFehler(nr, 'Einrückung')
if (/^(```|~~~)/.test(roh)) throw new MarkdownFehler(nr, 'Codeblock')
if (roh.startsWith('>')) throw new MarkdownFehler(nr, 'Zitat')
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(roh)) throw new MarkdownFehler(nr, 'Trennlinie')
if (/^[*+]\s/.test(roh)) throw new MarkdownFehler(nr, 'Liste mit * oder +')
if (/^\d+[.)]\s/.test(roh)) throw new MarkdownFehler(nr, 'nummerierte Liste')
if (roh.startsWith('#')) {
const m = /^(#{1,3}) (\S.*)$/.exec(roh)
if (!m) throw new MarkdownFehler(nr, /^#{4,}/.test(roh) ? 'Überschrift ab Ebene 4' : 'Überschrift ohne Leerzeichen')
schliesse()
bloecke.push({ art: 'h', ebene: m[1].length, html: inline(m[2].trim(), nr), zeile: nr })
return
}
if (roh.startsWith('- ')) {
const text = roh.slice(2).trim()
if (text === '') throw new MarkdownFehler(nr, 'leerer Listenpunkt')
if (offen?.art !== 'ul') {
offen = { art: 'ul', punkte: [], zeile: nr }
bloecke.push(offen)
}
offen.punkte.push(inline(text, nr))
return
}
if (roh.startsWith('|')) {
if (!roh.trimEnd().endsWith('|')) throw new MarkdownFehler(nr, 'Tabellenzeile ohne schließendes |')
const zellen = roh.trim().slice(1, -1).split('|').map((z) => z.trim())
if (offen?.art !== 'table') {
offen = { art: 'table', kopf: zellen.map((z) => inline(z, nr)), trenn: false, zeilen: [], zeile: nr }
bloecke.push(offen)
return
}
if (!offen.trenn) {
if (zellen.length !== offen.kopf.length || !zellen.every((z) => /^:?-{3,}:?$/.test(z)) || zellen.some((z) => z.includes(':'))) {
throw new MarkdownFehler(nr, 'Tabelle: zweite Zeile muss die Trennzeile |---|---| sein (ohne Ausrichtung)')
}
offen.trenn = true
return
}
if (zellen.length !== offen.kopf.length) throw new MarkdownFehler(nr, 'Tabellenzeile mit anderer Spaltenzahl')
offen.zeilen.push(zellen.map((z) => inline(z, nr)))
return
}
// Absatz (aufeinanderfolgende Zeilen gehören zusammen)
if (offen?.art === 'p') {
offen.html += ` ${inline(roh.trim(), nr)}`
return
}
offen = { art: 'p', html: inline(roh.trim(), nr), zeile: nr }
bloecke.push(offen)
})
for (const b of bloecke) {
if (b.art === 'table' && !b.trenn) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Trennzeile')
if (b.art === 'table' && b.zeilen.length === 0) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Zeilen')
}
return bloecke
}
// ---------------------------------------------------------------------------------------------------------- Seitenhülle
const STYLE = (seitenkopf) => ` :root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 22px 0 6px; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box.widerspruch { border: 2px solid #6d1fd1; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
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. */
function pruefeHref(href) {
if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>]/.test(href)) {
throw new Error(`render: ungültige Adresse in der Navigation: ${String(href).slice(0, 60)}`)
}
return href
}
function bloeckeHtml(bloecke) {
const aus = []
bloecke.forEach((b, i) => {
if (b.art === 'h') {
if (b.ebene === 1) {
if (i !== 0) throw new MarkdownFehler(b.zeile, '# nur als erste Zeile')
aus.push(`<div class="brand">WEBKLAR</div>\n <h1>${b.html}</h1>`)
} else {
aus.push(`<h${b.ebene}>${b.html}</h${b.ebene}>`)
}
} else if (b.art === 'p') {
aus.push(`<p${i === 1 ? ' class="meta"' : ''}>${b.html}</p>`)
} else if (b.art === 'ul') {
aus.push(`<ul>\n${b.punkte.map((p) => ` <li>${p}</li>`).join('\n')}\n </ul>`)
} else {
aus.push(
[
'<table>',
`<thead><tr>${b.kopf.map((z) => `<th>${z}</th>`).join('')}</tr></thead>`,
'<tbody>',
...b.zeilen.map((zeile) => `<tr>${zeile.map((z) => `<td>${z}</td>`).join('')}</tr>`),
'</tbody>',
'</table>',
].join('\n '),
)
}
})
return aus.join('\n\n ')
}
/**
* 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 · "
* @returns {string} HTML mit abschließendem Zeilenumbruch
*/
export function render(md, meta) {
if (!meta || !FASSUNG_RE.test(String(meta.fassung))) throw new Error('render: meta.fassung muss JJJJ-MM-TT sein')
for (const feld of ['titel', 'seitenkopf']) {
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')
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 #')
if (bloecke[1]?.art !== 'p' || !bloecke[1].html.startsWith(`<strong>Fassung ${meta.fassung}</strong>`)) {
throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`)
}
const nav = meta.nav.map((l) => `<a href="${esc(pruefeHref(l.href))}">${esc(l.text)}</a>`).join(' · ')
return `<!DOCTYPE html>
<html lang="de">
<head>
<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>
<style>
${STYLE(meta.seitenkopf)} </style>
</head>
<body>
<main>
<nav class="top">${nav}</nav>
${bloeckeHtml(bloecke)}
</main>
</body>
</html>
`
}
// ---------------------------------------------------------------------------------------------------------- Dokumentsätze
/** Namen je Dokument des Terminplaners (Schlüssel wie in legalPaths: lb, avv, widerruf). */
const TERMINE_NAMEN = {
lb: { titel: 'Leistungsbeschreibung Terminplaner', seitenkopf: 'Leistungsbeschreibung Terminplaner' },
avv: { titel: 'Auftragsverarbeitungsvertrag Terminplaner', seitenkopf: 'AVV Terminplaner' },
widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' },
}
const NAV_NAMEN = { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' }
/**
* Die drei Dokumente des Terminplaners in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts.
* Quelle docs/rechtliches/<slug>.md, Ziel public/rechtliches/<slug>-<Fassung>.html (Slugs und Adressen aus legalDocs.js).
*/
export function termineDokumente(fassung = config.termine.avvVersion) {
const adressen = legalPaths('termine', fassung)
const nav = [
{ href: '/dashboard.html', text: '← Kundenportal' },
{ href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' },
...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[doc] })),
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return Object.entries(adressen).map(([doc, adresse]) => ({
doc,
md: `docs/rechtliches/${basename(adresse, `-${fassung}.html`)}.md`,
html: `public${adresse}`,
meta: { fassung, ...TERMINE_NAMEN[doc], nav },
}))
}
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Kommandozeile: `termine [--check]`. 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 (satz !== 'termine' || optionen.some((o) => o !== '--check')) {
fehler('Aufruf: node scripts/build-legal-html.mjs termine [--check]')
return 2
}
let abweichungen = 0
for (const d of termineDokumente()) {
const html = render(readFileSync(join(root, d.md), 'utf8'), d.meta)
const ziel = join(root, d.html)
if (pruefen) {
const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html
log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`)
if (!gleich) abweichungen += 1
} else {
writeFileSync(ziel, html)
log(`-> ${d.html}`)
}
}
return abweichungen > 0 ? 1 : 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
process.exitCode = lauf(process.argv.slice(2))
} catch (err) {
console.error(err.message)
process.exitCode = 1
}
}
+37 -3
View File
@@ -1,15 +1,21 @@
#!/usr/bin/env bash
# Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen.
# Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt.
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse]
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|datenschutz]
# analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert
# termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion);
# die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine`. Neben jedem PDF liegt <PDF>.sha256 mit der
# SHA-256 der HTML-Seite, aus der es gedruckt wurde (test/buildLegalHtml.test.js prueft, dass beide zusammenpassen). Ein PDF,
# dessen Pruefsumme noch stimmt, wird nicht neu gedruckt (FORCE=1 druckt alle neu).
# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf)
# Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander.
set -euo pipefail
cd "$(dirname "$0")/.."
CHROME="${CHROME:-$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome 2>/dev/null | tail -1)}"
[ -x "$CHROME" ] || { echo "Chrome nicht gefunden (CHROME=...)"; exit 1; }
pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF)
"$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \
timeout 120 "$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \
--virtual-time-budget=3000 --print-to-pdf="$2" "file://$PWD/$1" 2>/dev/null
echo "-> $2"
}
@@ -17,11 +23,39 @@ pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF)
MODE="${1:-all}"
ANALYSE_VERSION="${ANALYSE_VERSION:-2026-10-02}"
# Fassung des Terminplaners: TERMINE_VERSION, sonst die Konstante des Servers (kein zweites Datum im Skript)
termine_version() {
local v="${TERMINE_VERSION:-}"
if [ -z "$v" ]; then
v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.termine.avvVersion)")"
fi
[[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; }
echo "$v"
}
case "$MODE" in
analyse)
pdf "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.pdf"
pdf "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.pdf"
;;
termine)
TERMINE_VERSION="$(termine_version)"
for slug in leistungsbeschreibung-terminplaner avv-terminplaner widerrufsbelehrung-funktionen; do
[ -f "public/rechtliches/$slug-$TERMINE_VERSION.html" ] || { echo "HTML fehlt: public/rechtliches/$slug-$TERMINE_VERSION.html (zuerst node scripts/build-legal-html.mjs termine)"; exit 1; }
quelle="public/rechtliches/$slug-$TERMINE_VERSION.html"
ziel="public/rechtliches/$slug-$TERMINE_VERSION.pdf"
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
;;
datenschutz)
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
;;
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
@@ -29,6 +63,6 @@ all)
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
;;
*)
echo "Usage: $0 [all|analyse]"; exit 1
echo "Usage: $0 [all|analyse|termine|datenschutz]"; exit 1
;;
esac
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
// Archiv der Gästetexte des Terminplaners: schreibt den Wortlaut der Texte, die ein Gast in der aktuellen Fassung
// (RECHTSTEXTE_VERSION in server/services/termine/rechtstexte.js) sieht, nach
// docs/rechtliches/archiv/termine-gaestetexte-<Fassung>.md (AVV Terminplaner Nr. 8 Abs. 5: der Nachweis einer Buchung speichert
// nur Hashes, das Archiv hält den Wortlaut zu jeder Fassung fest).
//
// node scripts/termine-texte-archiv.mjs schreibt die Datei der aktuellen Fassung
// node scripts/termine-texte-archiv.mjs --check vergleicht mit der eingecheckten Datei (Exit 1 bei Abweichung, schreibt nichts)
// node scripts/termine-texte-archiv.mjs --force überschreibt eine abweichende Datei der aktuellen Fassung (ohne --force: Exit 1)
//
// Die Betriebsangaben stehen als Platzhalter im Text ({{Name des Betriebs}} usw.); die Ausgabe enthält kein Datum und hängt nur von
// rechtstexte.js und einstellungen.js ab (zweimal erzeugen ergibt dieselben Bytes). Die Datei einer früheren Fassung wird von diesem
// Skript nie angefasst: sie bleibt, wie sie war (test/termineRechtstexteArchiv.test.js pinnt ihren Hash).
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { knopfText } from '../server/services/termine/einstellungen.js'
import {
GESUNDHEIT_HINWEIS,
RECHTSTEXTE_VERSION,
ZUSTIMMUNG_ERINNERUNG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_VORZEITIG,
datenschutzAbsatz,
formularHinweis,
widerrufsbelehrung,
} from '../server/services/termine/rechtstexte.js'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
/** Betriebsangaben als Platzhalter (wie in docs/rechtliches/termine-widerrufsbelehrung-muster.md). */
export const PLATZHALTER_BETRIEB = Object.freeze({ name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' })
const PLATZHALTER_STUNDEN = '{{Stunden}}'
/** Pfad der Archivdatei einer Fassung, relativ zur Wurzel des Projekts. */
export const archivPfad = (version = RECHTSTEXTE_VERSION) => `docs/rechtliches/archiv/termine-gaestetexte-${version}.md`
/** Einstellung mit Platzhalter-Betrieb und allen Schaltern an: der Datenschutz-Absatz erscheint mit allen bedingten Sätzen. */
function einstellung(anrede) {
return {
anrede,
aufbewahrungTage: 90,
betrieb: PLATZHALTER_BETRIEB,
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
recht: { gaesteSind: 'verbraucher', freizeit: false },
ressourcen: [{ id: 'r1', aktiv: true }],
leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }],
}
}
/** Text als Blockquote: jede Zeile mit "> ", Leerzeilen als ">". */
const zitat = (text) => text.split('\n').map((zeile) => (zeile === '' ? '>' : `> ${zeile}`)).join('\n')
const SOFORT = { buchungsart: 'sofort', preisArt: 'keiner', preisCent: 0 }
const MIT_PREIS = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 1 }
const ANFRAGE = { buchungsart: 'anfrage', preisArt: 'keiner', preisCent: 0 }
function knopfTabelle() {
const zeilen = [
['Sofort buchbar, ohne Preis', SOFORT],
['Sofort buchbar, mit Preis', MIT_PREIS],
['Terminanfrage', ANFRAGE],
]
return [
'| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite |',
'|---|---|---|',
...zeilen.map(([name, leistung]) => `| ${name} | ${knopfText(leistung)} | ${knopfText(leistung, { bestaetigen: true })} |`),
].join('\n')
}
/** Der ganze Inhalt der Archivdatei der aktuellen Fassung. */
export function baueArchiv() {
const teile = [
`# Gästetexte des Terminplaners, Fassung ${RECHTSTEXTE_VERSION}`,
[
'Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). '
+ 'Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst.',
`Fassung der Gästetexte: **${RECHTSTEXTE_VERSION}** (\`RECHTSTEXTE_VERSION\` in \`server/services/termine/rechtstexte.js\`). `
+ 'Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners.',
'Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war.',
'Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), '
+ 'die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen).',
].join('\n\n'),
'## 1. Hinweis unter dem Buchungsformular (Datenschutz)',
['### Sie-Fassung', zitat(formularHinweis(einstellung('sie'))), '### Du-Fassung', zitat(formularHinweis(einstellung('du')))].join('\n\n'),
'## 2. Haken',
[
'Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen.',
'### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer)',
zitat(ZUSTIMMUNG_VORZEITIG(PLATZHALTER_BETRIEB.name)),
'### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)',
zitat(ZUSTIMMUNG_STORNO),
'### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet)',
zitat(ZUSTIMMUNG_ERINNERUNG(PLATZHALTER_STUNDEN)),
].join('\n\n'),
'## 3. Hinweis zu Gesundheitsangaben',
['Steht über dem Nachrichtenfeld des Formulars.', zitat(GESUNDHEIT_HINWEIS)].join('\n\n'),
'## 4. Knopftexte',
[knopfTabelle(), 'Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`).'].join('\n\n'),
'## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste',
['Sie-Form, mit den Betriebsangaben als Platzhalter.', zitat(widerrufsbelehrung(PLATZHALTER_BETRIEB))].join('\n\n'),
'## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs',
[
'Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht.',
'### Sie-Fassung',
zitat(datenschutzAbsatz(einstellung('sie'), 'sie')),
'### Du-Fassung',
zitat(datenschutzAbsatz(einstellung('du'), 'du')),
].join('\n\n'),
'## 7. Nachweis',
'Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).',
]
return `${teile.join('\n\n')}\n`
}
/**
* Kommandozeile: `[--check] [--force]` (nicht beides). root = Wurzel des Projekts (Tests übergeben ein Verzeichnis).
* Eine Datei, die es schon gibt und deren Inhalt abweicht, wird nur mit --force überschrieben: ist die Fassung ausgeliefert,
* darf ihr Wortlaut nicht still ersetzt werden (neue Fassung, neue Datei). Eine neue oder identische Datei schreibt das Skript immer.
* => Exit-Code: 0 ok, 1 die Datei weicht ab oder fehlt (--check) bzw. weicht ab und --force fehlt, 2 falscher Aufruf
*/
export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) {
if (argv.some((o) => o !== '--check' && o !== '--force') || (argv.includes('--check') && argv.includes('--force'))) {
fehler('Aufruf: node scripts/termine-texte-archiv.mjs [--check] [--force] (nicht beides)')
return 2
}
const pfad = archivPfad()
const ziel = join(root, pfad)
const inhalt = baueArchiv()
if (argv.includes('--check')) {
const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === inhalt
log(`${gleich ? 'ok ' : 'ABWEICHT'} ${pfad}`)
return gleich ? 0 : 1
}
if (!argv.includes('--force') && existsSync(ziel) && readFileSync(ziel, 'utf8') !== inhalt) {
fehler(
`${pfad} weicht ab (von den Texten in rechtstexte.js) und wird nicht überschrieben. Ist die Fassung ausgeliefert, bekommt eine Textänderung `
+ 'eine neue Fassung (neues Datum, neue Datei). Nur wenn die Fassung noch nicht ausgeliefert ist: mit --force überschreiben.',
)
return 1
}
writeFileSync(ziel, inhalt)
log(`-> ${pfad}`)
return 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
process.exitCode = lauf(process.argv.slice(2))
} catch (err) {
console.error(err.message)
process.exitCode = 1
}
}