Files
tickte-system/scripts/seed-legal.mjs
T
ClaudeandClaude Opus 5.5 c8c1d84794 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>
2026-09-30 10:31:22 +00:00

200 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});