From e9ab15bdb53459f1c98d5d6cd1dbe7f180c3afa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:30:21 +0000 Subject: [PATCH] Webshop A1/A2: Konfiguration, Schalter, Katalog shopSetup, Schema und Setup-Skript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.shop (SHOP_ENABLED, SHOP_PILOT_EMAILS, SHOP_UPLOAD_DIR, STRIPE_CONNECT_WEBHOOK_SECRET), config.shopSetup (299 € einmalig), Collection-IDs shopConfig/shopProducts/shopOrders/shopWiderrufe, services/shop/schalter.js, Katalogschluessel shopSetup in stripeBilling und stripe-products.mjs, services/shop/schema.js, scripts/shop-setup.mjs (--check), schemaKeys fuer die Test-Mocks. Co-Authored-By: Claude Opus 5.5 --- scripts/shop-setup.mjs | 85 +++++++++ scripts/stripe-products.mjs | 5 +- server/config.js | 47 +++++ server/services/shop/schalter.js | 26 +++ server/services/shop/schema.js | 108 +++++++++++ server/services/stripeBilling.js | 4 +- test/shopSchalter.test.js | 163 +++++++++++++++++ test/shopSetup.test.js | 285 ++++++++++++++++++++++++++++++ test/stripeBilling.test.js | 21 +++ test/stripeProductsScript.test.js | 16 +- test/support/schema.js | 5 +- 11 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 scripts/shop-setup.mjs create mode 100644 server/services/shop/schalter.js create mode 100644 server/services/shop/schema.js create mode 100644 test/shopSchalter.test.js create mode 100644 test/shopSetup.test.js diff --git a/scripts/shop-setup.mjs b/scripts/shop-setup.mjs new file mode 100644 index 0000000..7ba7a65 --- /dev/null +++ b/scripts/shop-setup.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Webshop: legt die vier Collections shopConfig, shopProducts, shopOrders und shopWiderrufe mit + * Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in + * server/services/shop/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes + * auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (SHOP_ENABLED) ausfuehren. + * Usage: + * node scripts/shop-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem) + * node scripts/shop-setup.mjs (Fehlendes anlegen) + */ +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { SHOP_COLLECTIONS } from '../server/services/shop/schema.js' +import { createAppwriteClient } from './analyse-setup.mjs' + +export async function main({ check = false, client, log = console.log } = {}) { + const api = client || createAppwriteClient() + const missing = [] + const missingCollections = [] + const missingIndexes = [] + const plan = [] + + // Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert + for (const [name, { attributes, indexes }] of Object.entries(SHOP_COLLECTIONS)) { + const id = config.collections[name] + const exists = await api.collectionExists(id) + const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set() + const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set() + const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key)) + const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key)) + + if (!exists) { + log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`) + missingCollections.push(id) + } else { + log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`) + } + if (check) { + absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`)) + absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`)) + } + absentAttributes.forEach((a) => missing.push({ ...a, collection: id })) + absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id })) + plan.push({ id, exists, absentAttributes, absentIndexes }) + } + + const result = { missing, missingCollections, missingIndexes } + if (check) { + const open = missing.length + missingCollections.length + missingIndexes.length + log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') + return result + } + + for (const { id, exists, absentAttributes } of plan) { + if (!exists) await api.ensureCollection(id, id) + for (const attr of absentAttributes) { + await api.createAttribute(id, attr) + log(`+ Attribut ${id}.${attr.key}`) + } + } + // Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen + for (const { id, absentAttributes } of plan) { + for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key) + } + for (const { id, absentIndexes } of plan) { + for (const index of absentIndexes) { + await api.createIndex(id, index) + log(`+ Index ${id}.${index.key}`) + } + } + log('Fertig.') + return result +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing, missingCollections, missingIndexes }) => + process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index dac61f5..5fab24e 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner- und Webshop-Einrichtung als Einmalpreise): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'] +export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -31,6 +31,7 @@ export function catalogEntry(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'shopSetup') return config.shopSetup return config.products[key] } diff --git a/server/config.js b/server/config.js index eafd10a..15062ba 100644 --- a/server/config.js +++ b/server/config.js @@ -30,6 +30,7 @@ function nonNegativeNumber(raw, fallback) { } const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase() +const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase() // Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent, // Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein. @@ -116,6 +117,16 @@ const termineSetup = { priceNickname: 'Terminplaner-Einrichtung 149 €', } +// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect) +const shopSetup = { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', +} + // Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '') @@ -396,6 +407,11 @@ export const config = { termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings', termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims', termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox', + // Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe; scripts/shop-setup.mjs) + shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig', + shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts', + shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders', + shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), @@ -412,6 +428,7 @@ export const config = { mailStorage, newsSetup, termineSetup, + shopSetup, ustHinweis, // Register der kaufbaren Funktionen (siehe oben) features, @@ -641,6 +658,36 @@ export const config = { minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300), }, + // Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit); + // Schalter siehe server/services/shop/schalter.js + shop: { + // off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden) + mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off', + // Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle + pilotEmails: (process.env.SHOP_PILOT_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + // Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des + // Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben + fassung: '2026-10-05', + // Artikelbilder der Shops: // + uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'), + // Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503 + connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '', + // Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange + bestellAblaufMinuten: 30, + maxArtikel: 500, + maxBilder: 4, + maxVarianten: 30, + // Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J + anonymisierenJahre: 3, + // Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten + loeschTageNachAbschalten: 90, + // Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht + unbezahltLoeschTage: 30, + }, + invoiceNinja: { url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''), apiToken: process.env.INVOICE_NINJA_API_TOKEN || '', diff --git a/server/services/shop/schalter.js b/server/services/shop/schalter.js new file mode 100644 index 0000000..c154a9f --- /dev/null +++ b/server/services/shop/schalter.js @@ -0,0 +1,26 @@ +import { config } from '../../config.js' + +/** + * Webshop-Schalter (Funktionsschluessel 'shop'): SHOP_ENABLED = off (Vorgabe) | pilot | on. + * Liest config.shop bei jedem Aufruf. Bei off laufen keine Shop-Jobs und keine Shop-Abfragen. + */ + +/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */ +export function shopModus() { + const mode = config.shop.mode + return mode === 'pilot' || mode === 'on' ? mode : 'off' +} + +/** + * Darf dieser Kunde den Webshop nutzen? pilot = nur Adressen aus SHOP_PILOT_EMAILS ('*' = alle; eine leere + * Liste laesst niemanden durch); on = alle Kunden; off = niemand. + */ +export function shopFuerKunde(email) { + const modus = shopModus() + if (modus === 'off') return false + if (modus === 'on') return true + const erlaubt = config.shop.pilotEmails + if (erlaubt.includes('*')) return true + const adresse = String(email || '').trim().toLowerCase() + return adresse !== '' && erlaubt.includes(adresse) +} diff --git a/server/services/shop/schema.js b/server/services/shop/schema.js new file mode 100644 index 0000000..aca5665 --- /dev/null +++ b/server/services/shop/schema.js @@ -0,0 +1,108 @@ +/** + * Webshop: Appwrite-Schema der vier Collections (Spezifikation 2026-10-05-webshop-design.md). + * Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(), + * ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in + * config.collections.; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody + * haelt Bodies mit data fuer fertig verpackt), daher heissen die JSON-Felder einstellungen, rechtstexte, produkt, bestellung. + * + * Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false } + * Index: { key, type: 'key', attributes: [...] } + * Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen. + */ + +const str = (key, size) => ({ key, type: 'string', size, required: false }) +const int = (key) => ({ key, type: 'integer', required: false }) +const bool = (key) => ({ key, type: 'boolean', required: false }) +const dt = (key) => ({ key, type: 'datetime', required: false }) +const index = (key, ...attributes) => ({ key, type: 'key', attributes }) + +export const SHOP_COLLECTIONS = { + // Ein Shop je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Anfrage, Stripe-Konto, Einstellungen und Rechtstexte + // als JSON-Text; die grossen Spalten werden in Appwrite Textspalten und zaehlen nicht zur Zeilenbreite. + shopConfig: { + attributes: [ + str('customerId', 36), + str('projectId', 36), + str('siteKey', 16), + str('anfrage', 4000), + str('anfrageStatus', 16), + dt('anfrageAt'), + dt('anfrageEntschiedenAt'), + str('anfrageEntschiedenVon', 255), + str('anfrageBegruendung', 1000), + str('stripeAccountId', 64), + str('stripeStatus', 4000), + str('einstellungen', 20000), + str('rechtstexte', 100000), + dt('rechtstexteBestaetigtAt'), + str('rechtstexteBestaetigtVon', 255), + str('rechtstexteHash', 64), + bool('geoeffnet'), + dt('geoeffnetAt'), + int('version'), + ], + indexes: [ + index('idx_customer', 'customerId'), + index('idx_sitekey', 'siteKey'), + index('idx_anfrage', 'anfrageStatus'), + index('idx_account', 'stripeAccountId'), + ], + }, + + // Ein Artikel je Dokument (ID randomUUID); Name und Sortierung stehen zusaetzlich als Spalten, der Rest im JSON-Text produkt + shopProducts: { + attributes: [str('shopId', 36), str('customerId', 36), bool('aktiv'), int('sort'), str('name', 120), str('produkt', 30000)], + indexes: [index('idx_shop', 'shopId', 'sort')], + }, + + // Eine Bestellung je Dokument; enthaelt personenbezogene Daten des Kaeufers (Aufbewahrung und Anonymisierung: Block J) + shopOrders: { + attributes: [ + str('shopId', 36), + str('customerId', 36), + str('bestellnummer', 16), + str('status', 16), + str('tokenHash', 64), + str('stripeSessionId', 128), + str('paymentIntentId', 64), + int('summeCent'), + dt('erstelltAt'), + dt('ablaufAt'), + dt('bezahltAt'), + str('kaeuferEmail', 320), + str('bestellung', 60000), + dt('anonymisiertAt'), + ], + indexes: [ + index('idx_shop_status', 'shopId', 'status'), + index('idx_nummer', 'shopId', 'bestellnummer'), + index('idx_session', 'stripeSessionId'), + index('idx_erstellt', 'shopId', 'erstelltAt'), + ], + }, + + // Widerruf von Vertraegen (§ 356a BGB) ueber die oeffentliche Widerrufsseite + shopWiderrufe: { + attributes: [ + str('shopId', 36), + str('customerId', 36), + str('bestellnummer', 32), + str('name', 200), + str('email', 320), + str('nachricht', 2000), + dt('eingangAt'), + dt('bestaetigungAt'), + dt('bearbeitetAt'), + str('orderId', 36), + ], + indexes: [index('idx_shop', 'shopId', 'eingangAt')], + }, +} + +/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */ +export const SHOP_DATETIMES = Object.fromEntries( + Object.entries(SHOP_COLLECTIONS).map(([name, { attributes }]) => [ + name, + attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key), + ]), +) diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 0fe6f28..b3ddbcf 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -58,6 +58,7 @@ const PRODUCT_METADATA = { mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung', termineSetup: 'termine-einrichtung', + shopSetup: 'shop-einrichtung', } /** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */ @@ -65,13 +66,14 @@ function catalogFor(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup if (key === 'termineSetup') return config.termineSetup + if (key === 'shopSetup') return config.shopSetup return getProduct(key) } const priceIdCache = new Map() /** * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | - * 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. */ export async function ensurePrice(key) { diff --git a/test/shopSchalter.test.js b/test/shopSchalter.test.js new file mode 100644 index 0000000..68ec00c --- /dev/null +++ b/test/shopSchalter.test.js @@ -0,0 +1,163 @@ +import { test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { config } from '../server/config.js' +import { shopFuerKunde, shopModus } from '../server/services/shop/schalter.js' + +const SHOP_ENV = [ + 'SHOP_ENABLED', + 'SHOP_PILOT_EMAILS', + 'SHOP_UPLOAD_DIR', + 'STRIPE_CONNECT_WEBHOOK_SECRET', + 'APPWRITE_COLLECTION_SHOP_CONFIG', + 'APPWRITE_COLLECTION_SHOP_PRODUCTS', + 'APPWRITE_COLLECTION_SHOP_ORDERS', + 'APPWRITE_COLLECTION_SHOP_WIDERRUFE', +] + +let original +beforeEach(() => { + original = { ...config.shop } +}) +afterEach(() => { + Object.assign(config.shop, original) +}) + +/** Frische config.js-Instanz mit genau diesen Shop-Umgebungsvariablen (alle anderen entfernt). */ +let freshCounter = 0 +async function freshConfig(env = {}) { + const saved = Object.fromEntries(SHOP_ENV.map((key) => [key, process.env[key]])) + for (const key of SHOP_ENV) delete process.env[key] + Object.assign(process.env, env) + try { + freshCounter += 1 + return (await import(`../server/config.js?shop-test=${freshCounter}`)).config + } finally { + for (const key of SHOP_ENV) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + } +} + +test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { + const fresh = await freshConfig() + assert.deepEqual(fresh.shop, { + mode: 'off', + pilotEmails: [], + fassung: '2026-10-05', + uploadDir: path.join(process.cwd(), 'upload-data', 'shop'), + connectWebhookSecret: '', + bestellAblaufMinuten: 30, + maxArtikel: 500, + maxBilder: 4, + maxVarianten: 30, + anonymisierenJahre: 3, + loeschTageNachAbschalten: 90, + unbezahltLoeschTage: 30, + }) + assert.equal(fresh.collections.shopConfig, 'shopConfig') + assert.equal(fresh.collections.shopProducts, 'shopProducts') + assert.equal(fresh.collections.shopOrders, 'shopOrders') + assert.equal(fresh.collections.shopWiderrufe, 'shopWiderrufe') +}) + +test('Konfiguration: Umgebungsvariablen werden gelesen und bereinigt', async () => { + const fresh = await freshConfig({ + SHOP_ENABLED: ' Pilot ', + SHOP_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ', + SHOP_UPLOAD_DIR: '/tmp/shop-bilder', + STRIPE_CONNECT_WEBHOOK_SECRET: 'whsec_connect', + APPWRITE_COLLECTION_SHOP_CONFIG: 'sConfig', + APPWRITE_COLLECTION_SHOP_PRODUCTS: 'sProducts', + APPWRITE_COLLECTION_SHOP_ORDERS: 'sOrders', + APPWRITE_COLLECTION_SHOP_WIDERRUFE: 'sWiderrufe', + }) + assert.equal(fresh.shop.mode, 'pilot') + assert.deepEqual(fresh.shop.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com']) + assert.equal(fresh.shop.uploadDir, '/tmp/shop-bilder') + assert.equal(fresh.shop.connectWebhookSecret, 'whsec_connect') + assert.deepEqual( + [fresh.collections.shopConfig, fresh.collections.shopProducts, fresh.collections.shopOrders, fresh.collections.shopWiderrufe], + ['sConfig', 'sProducts', 'sOrders', 'sWiderrufe'], + ) +}) + +test('Konfiguration: leerer oder unbekannter Schalter ist off', async () => { + for (const value of ['', ' ', 'banane', 'true', '1', 'ON ']) { + const fresh = await freshConfig({ SHOP_ENABLED: value }) + assert.equal(fresh.shop.mode, value.trim().toLowerCase() === 'on' ? 'on' : 'off', `SHOP_ENABLED=${JSON.stringify(value)}`) + } +}) + +test('Konfiguration: Katalogeintrag shopSetup', () => { + assert.deepEqual(config.shopSetup, { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', + }) +}) + +test('shopModus: leer und unbekannt sind off, pilot und on bleiben', () => { + for (const value of [undefined, null, '', 'banane', 'PILOT', 'true']) { + config.shop.mode = value + assert.equal(shopModus(), 'off', `mode=${JSON.stringify(value)}`) + } + config.shop.mode = 'pilot' + assert.equal(shopModus(), 'pilot') + config.shop.mode = 'on' + assert.equal(shopModus(), 'on') +}) + +test('shopModus: Aenderung von config.shop.mode wirkt sofort', () => { + config.shop.mode = 'off' + assert.equal(shopModus(), 'off') + config.shop.mode = 'on' + assert.equal(shopModus(), 'on') + config.shop.mode = 'off' + assert.equal(shopModus(), 'off') +}) + +test('shopFuerKunde: off sperrt alle, auch mit Stern in der Liste', () => { + config.shop.mode = 'off' + config.shop.pilotEmails = ['*', 'kenso.gri@gmail.com'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) + assert.equal(shopFuerKunde('jemand@example.com'), false) +}) + +test('shopFuerKunde: pilot erlaubt nur Adressen der Liste, Schreibweise egal', () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = ['kenso.gri@gmail.com'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), true) + assert.equal(shopFuerKunde(' Kenso.Gri@Gmail.com '), true) + assert.equal(shopFuerKunde('jemand@example.com'), false) + assert.equal(shopFuerKunde(''), false) + assert.equal(shopFuerKunde(undefined), false) + assert.equal(shopFuerKunde(null), false) +}) + +test('shopFuerKunde: pilot mit leerer Liste laesst niemanden durch, Stern alle (auch ohne Adresse)', () => { + config.shop.mode = 'pilot' + config.shop.pilotEmails = [] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) + assert.equal(shopFuerKunde(''), false) + config.shop.pilotEmails = ['*'] + assert.equal(shopFuerKunde('jemand@example.com'), true) + assert.equal(shopFuerKunde(''), true) +}) + +test('shopFuerKunde: on erlaubt alle Kunden, die Liste spielt keine Rolle', () => { + config.shop.mode = 'on' + config.shop.pilotEmails = [] + assert.equal(shopFuerKunde('jemand@example.com'), true) + assert.equal(shopFuerKunde(''), true) +}) + +test('shopFuerKunde: Unbekannter Modus gilt als off', () => { + config.shop.mode = 'banane' + config.shop.pilotEmails = ['*'] + assert.equal(shopFuerKunde('kenso.gri@gmail.com'), false) +}) diff --git a/test/shopSetup.test.js b/test/shopSetup.test.js new file mode 100644 index 0000000..7da087e --- /dev/null +++ b/test/shopSetup.test.js @@ -0,0 +1,285 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_COLLECTIONS, SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Das Skript und die Konfiguration laden erst nach useTestEnv (Endpunkt des Nachbaus), daher dynamische Imports +const aw = await startAppwriteMock({ schema: { shopConfig: schemaKeys('shopConfig') }, datetimes: SHOP_DATETIMES }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { main } = await import('../scripts/shop-setup.mjs') +const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js') +after(() => Promise.all([aw.close(), st.close()])) + +const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe'] +const keysOf = (name) => SHOP_COLLECTIONS[name].attributes.map((a) => a.key) + +/** + * Appwrite-Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen + * Attributen (wie Appwrite). `initial` = { collectionId: { attrs: [...], indexes: [...] } }. + */ +function fakeClient(initial = {}) { + const state = new Map( + Object.entries(initial).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]), + ) + const calls = { collections: [], attrs: [], indexes: [] } + const need = (id) => { + if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 }) + return state.get(id) + } + return { + state, + calls, + async collectionExists(id) { + return state.has(id) + }, + async ensureCollection(id, name) { + if (state.has(id)) return false + state.set(id, { attrs: new Map(), indexes: new Set() }) + calls.collections.push({ id, name }) + return true + }, + async listAttributes(id) { + return [...need(id).attrs.keys()].map((key) => ({ key })) + }, + async createAttribute(id, attr) { + const collection = need(id) + if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`) + collection.attrs.set(attr.key, attr) + calls.attrs.push(`${id}.${attr.key}`) + }, + async waitForAttribute(id, key) { + assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`) + }, + async listIndexes(id) { + return [...need(id).indexes].map((key) => ({ key })) + }, + async createIndex(id, index) { + const collection = need(id) + for (const key of index.attributes) { + if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`) + } + collection.indexes.add(index.key) + calls.indexes.push(`${id}.${index.key}`) + }, + } +} + +const quiet = () => {} +const expectedAttrs = NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)) +const expectedIndexes = NAMES.flatMap((name) => SHOP_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`)) + +test('Schema: Attribute je Collection wie in der Spezifikation', () => { + assert.deepEqual(Object.keys(SHOP_COLLECTIONS), NAMES) + assert.deepEqual(keysOf('shopConfig'), [ + 'customerId', 'projectId', 'siteKey', 'anfrage', 'anfrageStatus', 'anfrageAt', 'anfrageEntschiedenAt', + 'anfrageEntschiedenVon', 'anfrageBegruendung', 'stripeAccountId', 'stripeStatus', 'einstellungen', 'rechtstexte', + 'rechtstexteBestaetigtAt', 'rechtstexteBestaetigtVon', 'rechtstexteHash', 'geoeffnet', 'geoeffnetAt', 'version', + ]) + assert.deepEqual(keysOf('shopProducts'), ['shopId', 'customerId', 'aktiv', 'sort', 'name', 'produkt']) + assert.deepEqual(keysOf('shopOrders'), [ + 'shopId', 'customerId', 'bestellnummer', 'status', 'tokenHash', 'stripeSessionId', 'paymentIntentId', 'summeCent', + 'erstelltAt', 'ablaufAt', 'bezahltAt', 'kaeuferEmail', 'bestellung', 'anonymisiertAt', + ]) + assert.deepEqual(keysOf('shopWiderrufe'), [ + 'shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt', 'bearbeitetAt', 'orderId', + ]) +}) + +test('Schema: Typen und Groessen', () => { + const byKey = (name) => Object.fromEntries(SHOP_COLLECTIONS[name].attributes.map((a) => [a.key, a])) + const shop = byKey('shopConfig') + const shopSizes = { + customerId: 36, projectId: 36, siteKey: 16, anfrage: 4000, anfrageStatus: 16, anfrageEntschiedenVon: 255, + anfrageBegruendung: 1000, stripeAccountId: 64, stripeStatus: 4000, einstellungen: 20000, rechtstexte: 100000, + rechtstexteBestaetigtVon: 255, rechtstexteHash: 64, + } + for (const [key, size] of Object.entries(shopSizes)) { + assert.deepEqual([shop[key].type, shop[key].size], ['string', size], `shopConfig.${key}`) + } + assert.equal(shop.geoeffnet.type, 'boolean') + assert.equal(shop.version.type, 'integer') + for (const key of ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt']) assert.equal(shop[key].type, 'datetime', key) + + const products = byKey('shopProducts') + assert.deepEqual([products.shopId.size, products.customerId.size, products.name.size, products.produkt.size], [36, 36, 120, 30000]) + assert.equal(products.aktiv.type, 'boolean') + assert.equal(products.sort.type, 'integer') + + const orders = byKey('shopOrders') + const orderSizes = { + shopId: 36, customerId: 36, bestellnummer: 16, status: 16, tokenHash: 64, stripeSessionId: 128, paymentIntentId: 64, + kaeuferEmail: 320, bestellung: 60000, + } + for (const [key, size] of Object.entries(orderSizes)) { + assert.deepEqual([orders[key].type, orders[key].size], ['string', size], `shopOrders.${key}`) + } + assert.equal(orders.summeCent.type, 'integer') + for (const key of ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt']) assert.equal(orders[key].type, 'datetime', key) + + const widerrufe = byKey('shopWiderrufe') + assert.deepEqual( + [widerrufe.shopId.size, widerrufe.customerId.size, widerrufe.bestellnummer.size, widerrufe.name.size, widerrufe.email.size, widerrufe.nachricht.size, widerrufe.orderId.size], + [36, 36, 32, 200, 320, 2000, 36], + ) + for (const key of ['eingangAt', 'bestaetigungAt', 'bearbeitetAt']) assert.equal(widerrufe[key].type, 'datetime', key) +}) + +test('Schema: jedes Attribut ist optional, hat eine gueltige Form und einen gueltigen Schluessel (kein Feld data)', () => { + for (const name of NAMES) { + const { attributes } = SHOP_COLLECTIONS[name] + assert.equal(new Set(attributes.map((a) => a.key)).size, attributes.length, `${name}: doppelte Schluessel`) + for (const attr of attributes) { + assert.match(attr.key, /^[A-Za-z][A-Za-z0-9]{0,35}$/, `${name}.${attr.key}`) + assert.notEqual(attr.key, 'data', `${name}: Feld data ist verboten`) + assert.equal(attr.required, false, `${name}.${attr.key}`) + assert.ok(['string', 'integer', 'boolean', 'datetime'].includes(attr.type), `${name}.${attr.key}: Typ ${attr.type}`) + if (attr.type === 'string') assert.ok(Number.isInteger(attr.size) && attr.size > 0, `${name}.${attr.key}: Groesse`) + else assert.equal('size' in attr, false, `${name}.${attr.key}: Groesse nur bei Text`) + } + } +}) + +test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => { + const indexes = (name) => SHOP_COLLECTIONS[name].indexes.map((i) => [i.key, ...i.attributes]) + assert.deepEqual(indexes('shopConfig'), [ + ['idx_customer', 'customerId'], + ['idx_sitekey', 'siteKey'], + ['idx_anfrage', 'anfrageStatus'], + ['idx_account', 'stripeAccountId'], + ]) + assert.deepEqual(indexes('shopProducts'), [['idx_shop', 'shopId', 'sort']]) + assert.deepEqual(indexes('shopOrders'), [ + ['idx_shop_status', 'shopId', 'status'], + ['idx_nummer', 'shopId', 'bestellnummer'], + ['idx_session', 'stripeSessionId'], + ['idx_erstellt', 'shopId', 'erstelltAt'], + ]) + assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']]) + for (const name of NAMES) { + for (const index of SHOP_COLLECTIONS[name].indexes) { + assert.equal(index.type, 'key') + assert.ok(index.attributes.every((key) => keysOf(name).includes(key)), `${name}.${index.key}`) + } + } +}) + +test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute', () => { + assert.deepEqual(SHOP_DATETIMES, { + shopConfig: ['anfrageAt', 'anfrageEntschiedenAt', 'rechtstexteBestaetigtAt', 'geoeffnetAt'], + shopProducts: [], + shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'], + shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'], + }) +}) + +test('schemaKeys kennt die Shop-Collections; bestehende Setup-Skripte funktionieren weiter', () => { + for (const name of NAMES) assert.deepEqual(schemaKeys(name), keysOf(name)) + assert.ok(schemaKeys('shopConfig').includes('einstellungen')) + assert.ok(!schemaKeys('shopConfig').includes('data')) + assert.ok(schemaKeys('termineConfig').includes('einstellung')) + assert.ok(schemaKeys('subscriptions').length > 0) + assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/) +}) + +test('Rundreise: Shop-Dokument mit Datum und JSON-Text anlegen, aendern und zuruecklesen', async () => { + const collection = config.collections.shopConfig + const einstellungen = JSON.stringify({ schema: 1, betrieb: { name: 'Blumen Müller' } }) + const created = await createDocument( + collection, + { customerId: 'c1', projectId: 'p1', siteKey: 'abc123', anfrageStatus: 'offen', anfrageAt: '2026-10-05T10:00:00.000Z', einstellungen, geoeffnet: false, version: 1 }, + 'feature-1', + ) + assert.equal(created.$id, 'feature-1') + await updateDocument(collection, 'feature-1', { anfrageStatus: 'angenommen', geoeffnet: true, version: 2 }) + const doc = await getDocument(collection, 'feature-1') + assert.equal(doc.anfrageStatus, 'angenommen') + assert.equal(doc.geoeffnet, true) + assert.equal(doc.version, 2) + assert.equal(doc.customerId, 'c1') + assert.equal(JSON.parse(doc.einstellungen).betrieb.name, 'Blumen Müller') +}) + +test('Rundreise: unbekanntes Attribut lehnt der Nachbau ab wie Appwrite (Schema-Pruefung greift)', async () => { + await assert.rejects( + () => createDocument(config.collections.shopConfig, { customerId: 'c1', fremd: 'x' }, 'feature-2'), + /Unknown attribute/, + ) +}) + +test('Konfiguration: Collection-IDs der Shop-Collections', () => { + assert.deepEqual(NAMES.map((name) => config.collections[name]), NAMES) +}) + +test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes an', async () => { + const client = fakeClient() + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id }))) + assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort()) + assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort()) + assert.equal(result.missing.length, expectedAttrs.length) + assert.deepEqual(result.missingCollections, NAMES) + assert.equal(result.missingIndexes.length, expectedIndexes.length) + const orders = client.state.get('shopOrders').attrs + assert.deepEqual(orders.get('kaeuferEmail'), { key: 'kaeuferEmail', type: 'string', size: 320, required: false }) + assert.deepEqual(orders.get('erstelltAt'), { key: 'erstelltAt', type: 'datetime', required: false }) + assert.deepEqual(orders.get('summeCent'), { key: 'summeCent', type: 'integer', required: false }) + assert.deepEqual(client.state.get('shopConfig').attrs.get('geoeffnet'), { key: 'geoeffnet', type: 'boolean', required: false }) +}) + +test('main: zweiter Lauf legt nichts mehr an', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0 + const result = await main({ client, log: quiet }) + assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] }) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection an', async () => { + const client = fakeClient({ + shopWiderrufe: { attrs: ['shopId', 'customerId', 'bestellnummer', 'name', 'email', 'nachricht', 'eingangAt', 'bestaetigungAt'], indexes: [] }, + }) + await main({ client, log: quiet }) + assert.ok(!client.calls.collections.some((c) => c.id === 'shopWiderrufe')) + assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.bearbeitetAt', 'shopWiderrufe.orderId']) + assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.idx_shop']) + assert.equal(client.calls.collections.length, 3) +}) + +test('main --check legt nichts an und meldet Fehlendes', async () => { + const client = fakeClient() + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(client.calls, { collections: [], attrs: [], indexes: [] }) + assert.equal(client.state.size, 0) + assert.deepEqual(result.missingCollections, NAMES) + assert.equal(result.missing.length, expectedAttrs.length) + assert.equal(result.missingIndexes.length, expectedIndexes.length) + assert.ok(lines.some((line) => line.includes('shopOrders') && line.includes('FEHLT'))) +}) + +test('main --check meldet bei vollstaendigem Stand nichts', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + const result = await main({ check: true, client, log: quiet }) + assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] }) +}) + +test('main --check meldet ein einzelnes fehlendes Attribut und einen fehlenden Index', async () => { + const client = fakeClient() + await main({ client, log: quiet }) + client.state.get('shopOrders').attrs.delete('anonymisiertAt') + client.state.get('shopConfig').indexes.delete('idx_account') + const lines = [] + const result = await main({ check: true, client, log: (line) => lines.push(line) }) + assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), ['shopOrders.anonymisiertAt']) + assert.deepEqual(result.missingIndexes.map((i) => `${i.collection}.${i.key}`), ['shopConfig.idx_account']) + assert.deepEqual(result.missingCollections, []) + assert.ok(lines.some((line) => line.includes('shopOrders.anonymisiertAt FEHLT'))) + assert.ok(lines.some((line) => line.includes('Index shopConfig.idx_account FEHLT'))) +}) diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js index d880a38..17a97af 100644 --- a/test/stripeBilling.test.js +++ b/test/stripeBilling.test.js @@ -281,3 +281,24 @@ test('termine_einrichtung_einmalpreis', async () => { assert.equal(await ensurePrice('termineSetup'), id) assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) }) + +test('shop_einrichtung_einmalpreis', async () => { + const before = st.calls.length + const id = await ensurePrice('shopSetup') + const calls = st.calls.slice(before) + const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(created.length, 1) + assert.equal(created[0].body['unit_amount'], '29900') + assert.equal(created[0].body['lookup_key'], 'webklar_shop_einrichtung_29900') + assert.equal(created[0].body['nickname'], 'Webshop-Einrichtung 299 €') + assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring') + assert.equal(st.prices.get(id).recurring, null) + const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') + assert.equal(products.length, 1) + assert.equal(products[0].body['name'], 'Webshop – Einrichtung') + assert.equal(products[0].body['metadata[webklar]'], 'shop-einrichtung') + // zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden) + const again = st.calls.length + assert.equal(await ensurePrice('shopSetup'), id) + assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) +}) diff --git a/test/stripeProductsScript.test.js b/test/stripeProductsScript.test.js index 8c4de44..cb72745 100644 --- a/test/stripeProductsScript.test.js +++ b/test/stripeProductsScript.test.js @@ -31,18 +31,30 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () => assert.equal(st.calls.length, 0) }) -test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => { - assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']) +test('KEYS enthaelt die Katalogschluessel der Einrichtungen', () => { + assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup']) }) test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => { assert.equal(script.catalogEntry('termineSetup'), config.termineSetup) + assert.equal(script.catalogEntry('shopSetup'), config.shopSetup) assert.equal(script.catalogEntry('newsSetup'), config.newsSetup) assert.equal(script.catalogEntry('mailStorage'), config.mailStorage) assert.equal(script.catalogEntry('wartung'), config.products.wartung) for (const key of script.KEYS) assert.ok(script.catalogEntry(key)?.lookupKey, `${key} hat einen lookupKey`) }) +test('config.shopSetup: Festwerte', () => { + assert.deepEqual(config.shopSetup, { + key: 'shopSetup', + name: 'Webshop – Einrichtung', + amountCents: 29900, + lookupKey: 'webklar_shop_einrichtung_29900', + oneTime: true, + priceNickname: 'Webshop-Einrichtung 299 €', + }) +}) + test('config.termineSetup: Festwerte', () => { assert.deepEqual(config.termineSetup, { key: 'termineSetup', diff --git a/test/support/schema.js b/test/support/schema.js index 89bdbda..b3f606d 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js' +import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js' import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs' // Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) @@ -9,12 +10,14 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw. * scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema: * Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in - * Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs); + * Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs), + * die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs); * portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES); * featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES). */ export function schemaKeys(collection) { if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key) + if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key) if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key) for (const script of SETUP_SCRIPTS) { const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')