#!/usr/bin/env node /** * Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an und die * Collection analyseConsents (Einwilligungs-Historie, nur Server-Key). Idempotent: Vorhandenes * wird uebersprungen. Vor dem Einschalten der Analyse (Livegang) ausfuehren. * Usage: * node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen) * node scripts/analyse-setup.mjs (Fehlendes anlegen) */ import { pathToFileURL } from 'node:url' import { config } from '../server/config.js' export const ANALYSE_ATTRIBUTES = [ { key: 'accentColor', type: 'string', size: 7, required: false }, { key: 'avvVersion', type: 'string', size: 20, required: false }, { key: 'avvAcceptedAt', type: 'string', size: 40, required: false }, { key: 'avvAcceptedBy', type: 'string', size: 100, required: false }, { key: 'activatedAt', type: 'string', size: 40, required: false }, { key: 'offSince', type: 'string', size: 40, required: false }, { key: 'activatedMailAt', type: 'string', size: 40, required: false }, { key: 'privacyTicketId', type: 'string', size: 40, required: false }, { key: 'avvConsentDate', type: 'string', size: 10, required: false }, { key: 'avvConsentChannel', type: 'string', size: 10, required: false }, { key: 'removalTicketId', type: 'string', size: 40, required: false }, { key: 'publishTicketId', type: 'string', size: 40, required: false }, // Zielgruppen-Bestaetigung (nicht an Kinder unter 16): bestaetigt + Fassung des Bestaetigungstextes { key: 'audienceConfirmed', type: 'boolean', required: false }, { key: 'audienceTextVersion', type: 'string', size: 20, required: false }, ] const str = (key, size) => ({ key, type: 'string', size, required: false }) /** Einwilligungs-Historie: nur anfuegen, nie aendern; kein Client-Zugriff. */ export const CONSENT_COLLECTION_NAME = 'analyseConsents' export const CONSENT_ATTRIBUTES = [ str('customerId', 36), str('projectId', 36), str('siteKey', 40), str('event', 8), str('avvVersion', 20), str('via', 8), str('acceptedAt', 40), str('acceptedBy', 100), str('textformDate', 10), str('textformChannel', 10), { key: 'audienceConfirmed', type: 'boolean', required: false }, str('audienceTextVersion', 20), ] export const CONSENT_INDEXES = [{ key: 'projectId_idx', type: 'key', attributes: ['projectId'] }] /** Echter Appwrite-Client (REST); jede Methode nimmt die Collection-ID als ersten Parameter. */ export function createAppwriteClient() { const { endpoint, projectId, databaseId, apiKey } = config.appwrite const db = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections` async function aw(path, { method = 'GET', body } = {}) { const response = await fetch(`${db}${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Appwrite-Project': projectId, 'X-Appwrite-Key': apiKey, }, body: body ? JSON.stringify(body) : undefined, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const err = new Error(data.message || `Appwrite ${response.status}`) err.status = response.status throw err } return data } // Ohne limit liefert Appwrite nur 25 Eintraege const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] })) return { async collectionExists(id) { try { await aw(`/${id}`) return true } catch (err) { if (err.status === 404) return false throw err } }, /** Legt die Collection ohne Rechte an (nur Server-Key); true = neu angelegt. */ async ensureCollection(id, name) { if (await this.collectionExists(id)) return false await aw('', { method: 'POST', body: { collectionId: id, name, permissions: [], documentSecurity: false } }) return true }, async listAttributes(id) { return (await aw(`/${id}/attributes?queries[]=${q}`)).attributes || [] }, async createAttribute(id, { type, ...body }) { await aw(`/${id}/attributes/${type}`, { method: 'POST', body }) }, async waitForAttribute(id, key, maxSec = 30) { for (let i = 0; i < maxSec; i += 1) { const attr = await aw(`/${id}/attributes/${key}`).catch(() => null) if (attr?.status === 'available') return await new Promise((r) => setTimeout(r, 1000)) } throw new Error(`Attribut ${key} wurde nicht verfuegbar`) }, async listIndexes(id) { return (await aw(`/${id}/indexes?queries[]=${q}`)).indexes || [] }, async createIndex(id, index) { await aw(`/${id}/indexes`, { method: 'POST', body: index }) }, } } export async function main({ check = false, client, log = console.log } = {}) { const api = client || createAppwriteClient() const featuresId = config.collections.portalFeatures const consentsId = config.collections.analyseConsents const missing = [] // Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen) async function syncAttributes(collectionId, attributes, exists) { const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set() const absent = attributes.filter((a) => !have.has(a.key)) for (const attr of attributes) { if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`) else if (check) log(`- ${collectionId}.${attr.key} FEHLT`) } return absent } const featureAbsent = await syncAttributes(featuresId, ANALYSE_ATTRIBUTES, true) featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId })) const consentsExist = await api.collectionExists(consentsId) if (!consentsExist) log(`${check ? '-' : '+'} Collection ${consentsId}${check ? ' FEHLT' : ''}`) const consentAbsent = await syncAttributes(consentsId, 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 = CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key)) if (check) indexAbsent.forEach((i) => log(`- Index ${consentsId}.${i.key} FEHLT`)) if (check) { const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1) log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.') return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent } } for (const attr of featureAbsent) { await api.createAttribute(featuresId, attr) log(`+ Attribut ${featuresId}.${attr.key}`) } if (!consentsExist) await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME) 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) for (const index of indexAbsent) { await api.createIndex(consentsId, index) log(`+ Index ${consentsId}.${index.key}`) } log('Fertig.') return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent } } 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, missingCollection, missingIndexes }) => process.exit(check && (missing.length || missingCollection || missingIndexes.length) ? 1 : 0)) .catch((e) => { console.error(e.message) process.exit(1) }) }