#!/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 --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' export const BUCKET = 'legal-files' export 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 | --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' } export 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@:. */ 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. */ export 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 } } export 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 } } export 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 } export 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} 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) const d = dokumentFuer(args.documentKey) return await hochladen(api, log, { documentKey: args.documentKey, dateien: d.dateien, quelle: d.quelle, changeNote: `Fassung vom ${ddmmjjjj(args.freigabe)}: Kundenansicht neu (Fassung 6)`, freigabe: args.freigabe, }, 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 } /** * Genau ein Dokument als neue Version hochladen (auch vom Datenschutz-Skript genutzt). Als Erstes die Kartenprüfung, dann Datei- und Git-Prüfung * und die Inhaltsprüfung des Aufrufers, erst dann schreiben: erst alle Dateien in den Bucket, dann das Versionsdokument, zuletzt die bisherige * gültige Version ablösen. deps = { git, dateien } wie bei main. * @param {object} api Client aus client() * @param {Function} log * @param {object} dok documentKey; dateien = [PDF, HTML] (Pfade im Projekt, in dieser Reihenfolge hochgeladen); quelle = Pfad in source * (kundenbereich@:); changeNote und freigabe (JJJJ-MM-TT = validFrom) fertig vom Aufrufer; kartenHinweis hängt an der Meldung * „Karte fehlt“; pruefeInhalt(lies) darf nach der Git-Prüfung mit lies(pfad) → Buffer den Inhalt prüfen und gibt eine Abbruchmeldung oder null zurück * @returns {Promise} Exit-Code (0 oder 1) */ export async function hochladen(api, log, { documentKey, dateien, quelle, changeNote, freigabe, kartenHinweis = '(zuerst --karten-anlegen)', pruefeInhalt }, deps) { const karten = await alleDokumente(api, 'legalDocuments') if (!karten.some((k) => k.key === documentKey)) { log(`Karte fehlt: ${documentKey} ${kartenHinweis}`) 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 dateien) { if (!dateisystem.existsSync(rel)) { log(`Datei fehlt: ${rel}`) return 1 } } if (git(['status', '--porcelain', '--', ...dateien]).trim() !== '') { log(`Abbruch: nicht committeter Stand in ${dateien.join(' und ')} (git status --porcelain ist nicht leer)`) return 1 } const inhaltsfehler = pruefeInhalt?.((rel) => dateisystem.readFileSync(rel)) if (inhaltsfehler) { log(inhaltsfehler) return 1 } const commit = git(['rev-parse', '--short', 'HEAD']).trim() const source = `kundenbereich@${commit}:${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 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, 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 }) }