Rechtliches: Erstbefuellung aus der Git-Geschichte (22 Versionen, 4 Anbieter)

Website-Seiten werden per React zu HTML gerendert, Portal-HTML kommt direkt
aus Git. Eine gemeinsame Datenschutzerklaerung statt zwei Karten, weil
webklar.com/datenschutz nur auf das Portal weiterleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:31:22 +00:00
1 parent c7c1e338a3
commit c8c1d84794
3 files changed
+389 -6

No files matched your search

+180
View File
@@ -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"
}
]
}
+199
View File
@@ -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 <a>, 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 = /<main[\s\S]*<\/main>/.exec(html);
return main ? main[0] : html;
}
function wrap(title, entry, body) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} ${entry.version} – Archivkopie</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:820px;margin:0 auto;padding:24px;color:#111}
.archiv{background:#f3f4f6;border:1px solid #d1d5db;padding:8px 12px;border-radius:6px;font-size:13px;margin-bottom:24px}
svg{display:none}h1{font-size:28px}</style></head>
<body><div class="archiv">Archivkopie aus WOMS · ${title} ${entry.version} · gültig ab ${entry.validFrom} · Quelle ${entry.source}</div>
${body}
</body></html>`;
}
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);
});
+10 -6
View File
@@ -10,6 +10,8 @@
* APPWRITE_API_KEY=... node scripts/setup-legal.mjs * APPWRITE_API_KEY=... node scripts/setup-legal.mjs
*/ */
import { pathToFileURL } from "node:url";
const dryRun = process.argv.includes("--dry-run"); const dryRun = process.argv.includes("--dry-run");
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1"; const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05"; 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: "agb", title: "AGB", liveUrl: "https://webklar.com/agb" },
{ key: "abo-bedingungen", title: "Abo-Bedingungen", liveUrl: "https://project.webklar.com/abo-bedingungen.html" }, { 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: "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", title: "Datenschutzerklärung (Website und Portal)", liveUrl: "https://project.webklar.com/datenschutz.html" },
{ key: "datenschutz-portal", title: "Datenschutz Kundenportal", liveUrl: "https://project.webklar.com/datenschutz.html" },
{ key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" }, { key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" },
{ key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" }, { key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" },
].map((d, i) => ({ ...d, sort: i + 1 })); ].map((d, i) => ({ ...d, sort: i + 1 }));
@@ -173,7 +174,10 @@ async function run() {
console.log("Fertig."); console.log("Fertig.");
} }
run().catch((e) => { // Nur direkt aufgerufen ausfuehren; seed-legal.mjs importiert DOCUMENTS.
console.error(e.message || e); if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(1); run().catch((e) => {
}); console.error(e.message || e);
process.exit(1);
});
}