diff --git a/scripts/legal-seed.json b/scripts/legal-seed.json new file mode 100644 index 0000000..6b40b0c --- /dev/null +++ b/scripts/legal-seed.json @@ -0,0 +1,180 @@ +{ + "versions": [ + { + "documentKey": "agb", + "version": "v1", + "validFrom": "2026-02-02", + "status": "superseded", + "changeNote": "Erste Fassung der AGB auf webklar.com", + "source": "webklar.com@217bbdc:src/pages/AGB.tsx" + }, + { + "documentKey": "agb", + "version": "v2", + "validFrom": "2026-09-27", + "status": "superseded", + "changeNote": "Modul C Hosting ergänzt (Hosting-Abo, Wartungs-Abo mit beliebig vielen Postfächern)", + "source": "webklar.com@dc27e50:src/pages/AGB.tsx" + }, + { + "documentKey": "agb", + "version": "v3", + "validFrom": "2026-09-27", + "status": "superseded", + "changeNote": "An die Vertragsbedingungen angeglichen (Wechsel, Zusatzspeicher, Sicherheit), Verweise auf die Abo-Bedingungen", + "source": "webklar.com@2c1a4c1:src/pages/AGB.tsx" + }, + { + "documentKey": "agb", + "version": "v4", + "validFrom": "2026-09-28", + "status": "superseded", + "changeNote": "Haftungssatz in Modul C entfällt; Kündigung nach Verlängerung für Verbraucher", + "source": "webklar.com@2bffca4:src/pages/AGB.tsx" + }, + { + "documentKey": "agb", + "version": "v5", + "validFrom": "2026-09-28", + "status": "current", + "changeNote": "Verbraucher-Kündigung als Ausnahme formuliert", + "source": "webklar.com@044f788:src/pages/AGB.tsx" + }, + { + "documentKey": "agb", + "version": "v6", + "validFrom": "2026-09-28", + "status": "draft", + "changeNote": "§ 11 Haftung neu nach dem Muster der Abo-Bedingungen (noch nicht live)", + "source": "webklar.com@f40e889:src/pages/AGB.tsx" + }, + { + "documentKey": "abo-bedingungen", + "version": "v1", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Erste Fassung: Wartungs-Abo 50 €/Monat über Stripe mit Stundenkonto", + "source": "kundenbereich@65429aa:public/abo-bedingungen.html" + }, + { + "documentKey": "abo-bedingungen", + "version": "v2", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Vertragsunterlagen Version 2 nach Rechtsrunde, Postfächer und Domains bei Hetzner", + "source": "kundenbereich@4ab154b:public/abo-bedingungen.html" + }, + { + "documentKey": "abo-bedingungen", + "version": "v3", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Version 3: Mindestlaufzeit 12 Monate; Kontakt support@webklar.com", + "source": "kundenbereich@dbabbf1:public/abo-bedingungen.html" + }, + { + "documentKey": "abo-bedingungen", + "version": "v4", + "validFrom": "2026-09-30", + "status": "current", + "changeNote": "Version 4: Hosting-Abo und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel, AVV; § 13 Abs. 3 mit Ausnahme für Absatz 1; Hosting-Preis 20 €", + "source": "kundenbereich@e4efdac:public/abo-bedingungen.html" + }, + { + "documentKey": "avv", + "version": "v2", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Erste AVV als Anlage der Vertragsbedingungen Version 2 (in Version 1 nur „auf Anfrage“)", + "source": "kundenbereich@4ab154b:public/abo-bedingungen.html" + }, + { + "documentKey": "avv", + "version": "v3", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "AVV als Anlage der Vertragsbedingungen Version 3", + "source": "kundenbereich@dbabbf1:public/abo-bedingungen.html" + }, + { + "documentKey": "avv", + "version": "v4", + "validFrom": "2026-09-30", + "status": "current", + "changeNote": "AVV als Anlage der Vertragsbedingungen Version 4 (Anlagen 1 und 3 überarbeitet)", + "source": "kundenbereich@e4efdac:public/abo-bedingungen.html" + }, + { + "documentKey": "datenschutz", + "version": "v1", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Stand 25.09.2026: Abo, Übernahmegutschrift", + "source": "kundenbereich@48b13f4:public/datenschutz.html" + }, + { + "documentKey": "datenschutz", + "version": "v2", + "validFrom": "2026-09-26", + "status": "superseded", + "changeNote": "Stand 26.09.2026: Chat mit Bildern, Dateien und Sprachnachrichten", + "source": "kundenbereich@35d98da:public/datenschutz.html" + }, + { + "documentKey": "datenschutz", + "version": "v3", + "validFrom": "2026-09-27", + "status": "superseded", + "changeNote": "Stand 27.09.2026: E-Mail bei Hetzner statt Porkbun, UptimeRobot, Bestellungen im Portal", + "source": "kundenbereich@7e86928:public/datenschutz.html" + }, + { + "documentKey": "datenschutz", + "version": "v4", + "validFrom": "2026-09-30", + "status": "current", + "changeNote": "Stand 30.09.2026: Livegang mit Vertragsbedingungen Version 4", + "source": "kundenbereich@78b4d7b:public/datenschutz.html" + }, + { + "documentKey": "impressum", + "version": "v1", + "validFrom": "2026-02-02", + "status": "superseded", + "changeNote": "Erste Fassung", + "source": "webklar.com@6af24d2:src/pages/Impressum.tsx" + }, + { + "documentKey": "impressum", + "version": "v2", + "validFrom": "2026-09-25", + "status": "current", + "changeNote": "Neu nach DDG mit Gesellschaftern; Kontakt support@webklar.com", + "source": "webklar.com@f74ddfb:src/pages/Impressum.tsx" + }, + { + "documentKey": "widerruf", + "version": "v1", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Widerrufsformular im Kundenportal", + "source": "kundenbereich@65429aa:public/widerrufen.html" + }, + { + "documentKey": "widerruf", + "version": "v2", + "validFrom": "2026-09-25", + "status": "superseded", + "changeNote": "Überarbeitet nach Rechtsrunde", + "source": "kundenbereich@4ab154b:public/widerrufen.html" + }, + { + "documentKey": "widerruf", + "version": "v3", + "validFrom": "2026-09-29", + "status": "current", + "changeNote": "Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)", + "source": "kundenbereich@7ffe95f:public/widerrufen.html" + } + ] +} \ No newline at end of file diff --git a/scripts/seed-legal.mjs b/scripts/seed-legal.mjs new file mode 100644 index 0000000..5a6daf7 --- /dev/null +++ b/scripts/seed-legal.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * WOMS Rechtliches – Erstbefuellung (idempotent ueber das Feld `source`). + * Liest scripts/legal-seed.json, erzeugt je Version eine Archivkopie als HTML + * (Portal-HTML direkt aus Git, Website-Seiten per React zu HTML gerendert), + * laedt sie in den Bucket legal-files und legt die Versionen an. Dazu die + * Anbieter-Vertraege ohne Fristen und Preise. + * + * Usage: + * node scripts/seed-legal.mjs --dry-run [--out DIR] (nur rendern, nichts hochladen) + * APPWRITE_API_KEY=... node scripts/seed-legal.mjs + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const APPS = "/home/webklar/apps"; +const REPOS = { + "webklar.com": process.env.WEBSITE_REPO || `${APPS}/webklar.com/build`, + kundenbereich: process.env.PORTAL_REPO || `${APPS}/kundenbereich.webklar.com/build`, +}; +const NODE_MODULES = process.env.WOMS_NODE_MODULES || `${APPS}/ticket.webklar.com/build/node_modules`; + +const dryRun = process.argv.includes("--dry-run"); +const outIdx = process.argv.indexOf("--out"); +const outDir = outIdx > 0 ? process.argv[outIdx + 1] : null; +const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1"; +const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05"; +const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database"; +const apiKey = process.env.APPWRITE_API_KEY; + +export const PROVIDERS = [ + { provider: "Hetzner", purpose: "Server (Hosting aller Websites und Systeme)" }, + { provider: "Hetzner", purpose: "E-Mail-Postfächer" }, + { provider: "Porkbun", purpose: "Domains" }, + { provider: "Stripe", purpose: "Zahlungen der Abos" }, +]; + +const require = createRequire(path.join(NODE_MODULES, "noop.js")); + +function gitShow(repo, hash, file) { + return execFileSync("git", ["-C", REPOS[repo], "show", `${hash}:${file}`], { maxBuffer: 20 * 1024 * 1024 }); +} + +// Alles ausser React wird zu einem Platzhalter: Komponenten geben nur ihre Kinder aus, +// Link wird zu , Icons verschwinden. Die Exportnamen kommen aus den Imports der Seite. +function stubFor(source, specifier) { + const names = new Set(); + let hasDefault = false; + const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*["']([^"']+)["']/g; + for (const m of source.matchAll(re)) { + if (m[3] !== specifier) continue; + if (m[1]) hasDefault = true; + for (const n of (m[2] || "").split(",")) { + const name = n.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, ""); + if (name) names.add(name); + } + } + const lines = [ + "import React from 'react';", + "const Pass = (p) => (p && p.children !== undefined ? React.createElement(React.Fragment, null, p.children) : null);", + ]; + for (const n of names) { + lines.push(n === "Link" || n === "NavLink" + ? `export const ${n} = (p) => React.createElement('a', { href: p.to || p.href }, p.children);` + : /^[A-Z]/.test(n) ? `export const ${n} = Pass;` : `export const ${n} = () => undefined;`); + } + if (hasDefault) lines.push("export default Pass;"); + return lines.join("\n"); +} + +async function renderTsx(source) { + const esbuild = require("esbuild"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "legal-")); + const entry = path.join(tmp, "page.tsx"); + fs.writeFileSync(entry, source); + const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + format: "cjs", + platform: "node", + jsx: "automatic", + nodePaths: [NODE_MODULES], + external: ["react", "react-dom", "react/jsx-runtime"], + logLevel: "silent", + plugins: [{ + name: "stub", + setup(b) { + b.onResolve({ filter: /.*/ }, (a) => + a.kind === "entry-point" || /^react(\/jsx-runtime)?$/.test(a.path) ? undefined : { path: a.path, namespace: "stub" }); + b.onLoad({ filter: /.*/, namespace: "stub" }, (a) => ({ contents: stubFor(source, a.path), loader: "js", resolveDir: NODE_MODULES })); + }, + }], + }); + fs.rmSync(tmp, { recursive: true, force: true }); + const mod = { exports: {} }; + new Function("module", "exports", "require", result.outputFiles[0].text)(mod, mod.exports, require); + const Page = mod.exports.default; + const React = require("react"); + const { renderToStaticMarkup } = require("react-dom/server"); + const html = renderToStaticMarkup(React.createElement(Page)); + const main = //.exec(html); + return main ? main[0] : html; +} + +function wrap(title, entry, body) { + return ` + +${title} ${entry.version} – Archivkopie + +
Archivkopie aus WOMS · ${title} ${entry.version} · gültig ab ${entry.validFrom} · Quelle ${entry.source}
+${body} +`; +} + +export async function buildFile(entry, title) { + const [repo, rest] = entry.source.split("@"); + const [hash, file] = rest.split(":"); + const raw = gitShow(repo, hash, file); + const name = `${entry.documentKey}-${entry.version}-${hash}.html`; + if (file.endsWith(".html")) return { name, content: raw }; + const body = await renderTsx(raw.toString("utf8")); + return { name, content: Buffer.from(wrap(title, entry, body), "utf8") }; +} + +async function run() { + const { versions } = JSON.parse(fs.readFileSync(path.join(HERE, "legal-seed.json"), "utf8")); + const { DOCUMENTS } = await import("./setup-legal.mjs"); + const titles = Object.fromEntries(DOCUMENTS.map((d) => [d.key, d.title])); + + if (dryRun) { + if (outDir) fs.mkdirSync(outDir, { recursive: true }); + for (const v of versions) { + const f = await buildFile(v, titles[v.documentKey]); + if (outDir) fs.writeFileSync(path.join(outDir, f.name), f.content); + console.log(`${v.documentKey} ${v.version} ${v.status} ab ${v.validFrom}: ${f.name} (${f.content.length} Bytes)`); + } + for (const p of PROVIDERS) console.log(`Anbieter: ${p.provider} – ${p.purpose}`); + return; + } + if (!apiKey) { + console.error("APPWRITE_API_KEY required (oder --dry-run)"); + process.exit(1); + } + const { Client, Databases, Storage, ID, Query } = await import("node-appwrite"); + const { InputFile } = await import("node-appwrite/file"); + const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey); + const db = new Databases(client); + const storage = new Storage(client); + + let created = 0; + for (const v of versions) { + const found = await db.listDocuments(databaseId, "legalVersions", [Query.equal("source", v.source), Query.equal("documentKey", v.documentKey), Query.limit(1)]); + if (found.total > 0) { + console.log("vorhanden:", v.documentKey, v.version); + continue; + } + const f = await buildFile(v, titles[v.documentKey]); + const file = await storage.createFile("legal-files", ID.unique(), InputFile.fromBuffer(f.content, f.name)); + const doc = { ...v, validTo: null, fileIds: [file.$id] }; + await db.createDocument(databaseId, "legalVersions", ID.unique(), doc); + created++; + console.log("angelegt:", v.documentKey, v.version); + } + + // validTo der abgeloesten Versionen: Tag vor der naechsten nicht-Entwurf-Version, nie vor dem eigenen Beginn + const all = (await db.listDocuments(databaseId, "legalVersions", [Query.limit(5000)])).documents; + for (const v of all.filter((x) => x.status === "superseded" && !x.validTo)) { + const next = all + .filter((x) => x.documentKey === v.documentKey && x.status !== "draft" && x.$id !== v.$id && + (x.validFrom > v.validFrom || (x.validFrom === v.validFrom && x.version > v.version))) + .sort((a, b) => (a.validFrom + a.version).localeCompare(b.validFrom + b.version))[0]; + if (!next) continue; + const d = new Date(`${next.validFrom}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - 1); + const before = d.toISOString().slice(0, 10); + await db.updateDocument(databaseId, "legalVersions", v.$id, { validTo: before < v.validFrom ? v.validFrom : before }); + } + + const existing = (await db.listDocuments(databaseId, "providerContracts", [Query.limit(5000)])).documents; + for (const p of PROVIDERS) { + if (existing.some((c) => c.provider === p.provider && c.purpose === p.purpose)) continue; + await db.createDocument(databaseId, "providerContracts", ID.unique(), { ...p, fileIds: [] }); + console.log("Anbieter angelegt:", p.provider, p.purpose); + } + console.log(`Fertig: ${created} Versionen angelegt.`); +} + +run().catch((e) => { + console.error(e.message || e); + process.exit(1); +}); diff --git a/scripts/setup-legal.mjs b/scripts/setup-legal.mjs index ebbe75d..76f657d 100644 --- a/scripts/setup-legal.mjs +++ b/scripts/setup-legal.mjs @@ -10,6 +10,8 @@ * APPWRITE_API_KEY=... node scripts/setup-legal.mjs */ +import { pathToFileURL } from "node:url"; + const dryRun = process.argv.includes("--dry-run"); const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1"; const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05"; @@ -83,8 +85,7 @@ export const DOCUMENTS = [ { key: "agb", title: "AGB", liveUrl: "https://webklar.com/agb" }, { key: "abo-bedingungen", title: "Abo-Bedingungen", liveUrl: "https://project.webklar.com/abo-bedingungen.html" }, { key: "avv", title: "AVV (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/abo-bedingungen.html" }, - { key: "datenschutz-website", title: "Datenschutz Website", liveUrl: "https://webklar.com/datenschutz" }, - { key: "datenschutz-portal", title: "Datenschutz Kundenportal", liveUrl: "https://project.webklar.com/datenschutz.html" }, + { key: "datenschutz", title: "Datenschutzerklärung (Website und Portal)", liveUrl: "https://project.webklar.com/datenschutz.html" }, { key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" }, { key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" }, ].map((d, i) => ({ ...d, sort: i + 1 })); @@ -173,7 +174,10 @@ async function run() { console.log("Fertig."); } -run().catch((e) => { - console.error(e.message || e); - process.exit(1); -}); +// Nur direkt aufgerufen ausfuehren; seed-legal.mjs importiert DOCUMENTS. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch((e) => { + console.error(e.message || e); + process.exit(1); + }); +}