#!/usr/bin/env node /** * Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der * Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung), * und die Collection featureConsents (Zustimmungsnachweis zur Bestellung, nur Server-Key) samt Attributen und * Indizes. Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die * neuen Felder; ohne Schema antwortet Appwrite mit "Unknown attribute"). * scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus). * Usage: * node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken) * node scripts/feature-setup.mjs (Fehlendes anlegen) * * config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die * Attributlisten, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen. */ import { pathToFileURL } from 'node:url' /** * Neue Attribute von portalFeatures (alle optional): * - disabledReason: 'team' | 'abo_ende' | 'abo_ende_vor_einrichtung' | 'kunde_geloescht' (laengster Wert 24 Zeichen) * - aboEndeMailAt: Zeitpunkt der versandten Mail "Terminplaner beendet" (Marker, nach dem Versand gesetzt) * - zahlungMeldung: '' | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' | 'gemeldet' (Marker einer Zahlung ohne Ticket) */ export const PORTAL_FEATURE_ATTRIBUTES = [ { type: 'string', key: 'disabledReason', size: 32, required: false }, { type: 'datetime', key: 'aboEndeMailAt', required: false }, { type: 'string', key: 'zahlungMeldung', size: 24, required: false }, ] const str = (key, size) => ({ key, type: 'string', size, required: false }) /** * Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key, wie analyseConsents): * - fassungen: JSON mit den Fassungen der gezeigten Texte; texte: JSON mit den gezeigten Texten (Appwrite macht aus * dieser Groesse eine TEXT-Spalte; 20000 Zeichen genuegen fuer Zustimmung, Haken und Belehrung) * - textHash: SHA-256 (hex) ueber die gespeicherten Texte; stripeSessionId: Stripe-Sitzung, zu der der Nachweis gehoert * - ip, userAgent, konto: Herkunft der Zustimmung; acceptedAt: Zeitpunkt des Klicks (Anker der Widerrufsfrist) */ export const CONSENT_COLLECTION_NAME = 'featureConsents' export const FEATURE_CONSENT_ATTRIBUTES = [ str('customerId', 36), str('projectId', 36), str('featureKey', 16), str('stripeSessionId', 128), { key: 'acceptedAt', type: 'datetime', required: false }, str('fassungen', 1000), str('texte', 20000), str('textHash', 64), str('ip', 64), str('userAgent', 300), str('konto', 255), ] export const FEATURE_CONSENT_INDEXES = [ { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, { key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] }, // Loeschfristen (services/retention.js): alle Nachweise eines Kunden, drei Jahre nach Ablauf des Jahres, in dem sein letztes Abo endet { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, ] /** true, wenn das Ergebnis von main() Luecken meldet (Exit-Code 1 im Pruefmodus) */ export function hatLuecken({ missing = [], missingCollection = false, missingIndexes = [] } = {}) { return missing.length > 0 || missingCollection || missingIndexes.length > 0 } export async function main({ check = false, client, log = console.log } = {}) { const { config } = await import('../server/config.js') let api = client if (!api) { const { createAppwriteClient } = await import('./analyse-setup.mjs') api = createAppwriteClient() } const featuresId = config.collections.portalFeatures const consentsId = config.collections.featureConsents const missing = [] // Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen) async function syncAttributes(collectionId, attributes, exists = true) { const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set() for (const attr of attributes) { if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`) else if (check) log(`- ${collectionId}.${attr.key} FEHLT`) } return attributes.filter((a) => !have.has(a.key)) } const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES) featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId })) const consentsExist = await api.collectionExists(consentsId) if (!consentsExist && check) log(`- ${consentsId} FEHLT`) const consentAbsent = await syncAttributes(consentsId, FEATURE_CONSENT_ATTRIBUTES, consentsExist) consentAbsent.forEach((a) => missing.push({ ...a, collection: consentsId })) const haveIndexes = consentsExist ? new Set((await api.listIndexes(consentsId)).map((i) => i.key)) : new Set() const indexAbsent = FEATURE_CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key)) if (check) indexAbsent.forEach((i) => log(`- ${consentsId}.${i.key} FEHLT`)) const result = { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent } if (check) { const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1) log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') return result } for (const attr of featureAbsent) { await api.createAttribute(featuresId, attr) log(`+ Attribut ${featuresId}.${attr.key}`) } if (!consentsExist) { await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME) log(`+ Collection ${consentsId}`) } for (const attr of consentAbsent) { await api.createAttribute(consentsId, attr) log(`+ Attribut ${consentsId}.${attr.key}`) } for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key) for (const attr of consentAbsent) await api.waitForAttribute(consentsId, attr.key) // Indizes erst, wenn ihre Attribute verfuegbar sind for (const index of indexAbsent) { await api.createIndex(consentsId, index) log(`+ Index ${consentsId}.${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((result) => process.exit(check && hatLuecken(result) ? 1 : 0)) .catch((e) => { console.error(e.message) process.exit(1) }) }