Registereintrag config.features.shop (Bestelltexte mit Fassung config.shop.fassung, Rechtsunterlagen-Getter, Mailtexte, Ticket), STANDARD_HAKEN.shop (Schalter, Bestellpruefung ueber shop/anfrage.js, Unterlagen, Belehrung), legalDocs-Satz shop, shopTicketDetails sowie Rumpf von anfrageOffenePunkte. Tests angepasst und um Shop-Faelle erweitert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
8.3 KiB
JavaScript
179 lines
8.3 KiB
JavaScript
// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/):
|
|
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse), 'termine' und 'shop' (Leistungsbeschreibung, AVV und
|
|
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners bzw. Webshops). analyseLegal.js ist ein duenner Wrapper darum.
|
|
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
|
|
import { access, open, readFile, stat } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import { Router } from 'express'
|
|
import { config } from '../config.js'
|
|
|
|
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
|
|
/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */
|
|
let basisDir = publicDir
|
|
/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */
|
|
export function setLegalDirForTests(dir) {
|
|
basisDir = dir ?? publicDir
|
|
}
|
|
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
/**
|
|
* Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (<slug>-<Fassung>.html|pdf),
|
|
* pdf = Dateiname des Mail-Anhangs (<pdf>-<Fassung>.pdf). config.js baut die Adressen des Terminplaners aus denselben
|
|
* Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab.
|
|
*/
|
|
const SAETZE = {
|
|
analyse: {
|
|
avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' },
|
|
lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' },
|
|
},
|
|
termine: {
|
|
lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' },
|
|
avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' },
|
|
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
|
},
|
|
shop: {
|
|
lb: { slug: 'leistungsbeschreibung-webshop', pdf: 'Leistungsbeschreibung-Webshop' },
|
|
avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' },
|
|
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
|
},
|
|
}
|
|
|
|
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
|
|
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' }
|
|
|
|
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
|
|
const aktuelleFassung = (satz) => {
|
|
if (satz === 'analyse') return config.analyse.avvVersion
|
|
if (satz === 'shop') return config.shop.fassung
|
|
return config.termine.avvVersion
|
|
}
|
|
|
|
function satzDokumente(satz) {
|
|
if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz')
|
|
return SAETZE[satz]
|
|
}
|
|
|
|
function pruefeFassung(version) {
|
|
if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung')
|
|
return String(version)
|
|
}
|
|
|
|
/** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/<slug>-<Fassung>.html' }. */
|
|
export function legalPaths(satz, version) {
|
|
const dokumente = satzDokumente(satz)
|
|
const v = pruefeFassung(version)
|
|
return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`]))
|
|
}
|
|
|
|
/** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */
|
|
export function legalPdfFiles(satz, version) {
|
|
const dokumente = satzDokumente(satz)
|
|
const v = pruefeFassung(version)
|
|
return Object.fromEntries(
|
|
Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }])
|
|
)
|
|
}
|
|
|
|
/** Ein Anhang ist ein PDF: nicht leer und mit "%PDF-" am Anfang. */
|
|
const istPdf = (inhalt) => inhalt.length >= 5 && inhalt.subarray(0, 5).toString('latin1') === '%PDF-'
|
|
|
|
/**
|
|
* PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine
|
|
* Datei fehlt, nicht lesbar, leer oder kein PDF ist (nie ein leerer oder falscher Anhang). ohne: Dokumente, die nicht
|
|
* angehaengt werden (sie muessen nicht vorhanden sein).
|
|
*/
|
|
export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) {
|
|
const dateien = legalPdfFiles(satz, version)
|
|
for (const doc of ohne) {
|
|
if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument')
|
|
}
|
|
return Promise.all(
|
|
Object.entries(dateien)
|
|
.filter(([doc]) => !ohne.includes(doc))
|
|
.map(async ([, f]) => {
|
|
const content = await readFile(f.path)
|
|
if (!istPdf(content)) throw new Error(`Rechtstexte: ${f.filename} ist leer oder kein PDF`)
|
|
return { filename: f.filename, content, contentType: 'application/pdf' }
|
|
})
|
|
)
|
|
}
|
|
|
|
function funktionSatz(featureKey) {
|
|
if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion')
|
|
return FUNKTION_SATZ[featureKey]
|
|
}
|
|
|
|
/**
|
|
* Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei
|
|
* unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). opts.fassung waehlt eine andere
|
|
* (ausgelieferte) Fassung, etwa die, der ein Kunde bei der Bestellung zugestimmt hat; sonst gilt die aktuelle.
|
|
*/
|
|
export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) {
|
|
const satz = funktionSatz(featureKey)
|
|
return satz ? legalPdfAttachments(satz, fassung ?? aktuelleFassung(satz), opts) : []
|
|
}
|
|
|
|
/**
|
|
* Adressen der HTML-Seiten der Funktion ({ lb, avv, widerruf }, versioniert) in der aktuellen oder der gewaehlten Fassung
|
|
* (opts.fassung); news => null. Wirft bei unbekannter Funktion oder ungueltiger Fassung.
|
|
*/
|
|
export function featureLegalPaths(featureKey, { fassung } = {}) {
|
|
const satz = funktionSatz(featureKey)
|
|
return satz ? legalPaths(satz, fassung ?? aktuelleFassung(satz)) : null
|
|
}
|
|
|
|
/**
|
|
* Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die
|
|
* ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler.
|
|
*/
|
|
async function istLesbaresPdf(pfad) {
|
|
const info = await stat(pfad)
|
|
if (!info.isFile() || info.size <= 0) return false
|
|
const datei = await open(pfad, 'r')
|
|
try {
|
|
const kopf = Buffer.alloc(5)
|
|
const { bytesRead } = await datei.read(kopf, 0, 5, 0)
|
|
return bytesRead === 5 && kopf.toString('latin1') === '%PDF-'
|
|
} finally {
|
|
await datei.close()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite
|
|
* verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte
|
|
* Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle.
|
|
*/
|
|
export async function featureLegalReady(featureKey, { fassung } = {}) {
|
|
try {
|
|
const satz = funktionSatz(featureKey)
|
|
if (!satz) return true
|
|
const v = fassung ?? aktuelleFassung(satz)
|
|
await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse))))
|
|
const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path)))
|
|
return lesbar.every(Boolean)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adressen ohne Fassung (/rechtliches/<slug>.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer
|
|
* Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der
|
|
* Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient
|
|
* werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes).
|
|
*/
|
|
export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) {
|
|
const router = Router()
|
|
for (const satz of saetze) {
|
|
for (const d of Object.values(satzDokumente(satz))) {
|
|
router.get(`/${d.slug}.html`, (_req, res, next) => {
|
|
const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`)
|
|
res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
|
|
})
|
|
}
|
|
}
|
|
return router
|
|
}
|