180 lines
7.5 KiB
JavaScript
180 lines
7.5 KiB
JavaScript
#!/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)
|
|
})
|
|
}
|