Die Wahl des Live-Hosts steht jetzt nur noch in liveHost() (kontext.js); websiteUrl() und der Betriebsvorschlag des Fragebogens (routes/termine.js) nutzen dieselbe Funktion. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
343 lines
18 KiB
JavaScript
343 lines
18 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
|
|
import { analyseHosts } from '../analyse.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { findActiveBySiteKey } from '../siteFeatures.js'
|
|
import { fehlendePflichtangaben } from './einstellungen.js'
|
|
import { leerFragebogen, normalisiereFragebogen } from './fragebogen.js'
|
|
import { termineAus } from './schalter.js'
|
|
import { pruefeNow, pruefeText } from './util.js'
|
|
|
|
/**
|
|
* Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und
|
|
* Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
|
|
*
|
|
* ctx = { feature, project, einstellung, version, fragebogen }
|
|
* feature portalFeatures-Dokument (featureKey 'termine')
|
|
* project websiteProjects-Dokument oder null (Projekt fehlt)
|
|
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
|
|
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
|
|
* fragebogen Bestell-Fragebogen (TP2), immer normalisiert; fehlt er oder ist er unlesbar (Logzeile), der leere (leerFragebogen)
|
|
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
|
|
*
|
|
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
|
|
* Einstellung, in der die Pflichtangaben fuer Gaeste stehen (fehlendePflichtangaben, Ruling R41: ein Entwurf aus einer
|
|
* Vorlage ist noch nicht buchbar); Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
|
|
* gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung
|
|
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
|
|
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
|
|
* ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0).
|
|
* - kontextFuerPortal (Portal, Entwurf und Bestellung): das uebergebene portalFeatures-Dokument in jedem Zustand (off, ordered,
|
|
* setup, active, disabled) ohne Modus- und Zustandspruefung (die macht der Aufrufer), ohne Speicher.
|
|
* Der Fragebogen steht im selben Dokument wie die Einstellung (termineConfig.fragebogen, JSON-Text) und gehoert nur dem
|
|
* Entwurf: speichereFragebogen aendert ihn ohne die Version der Einstellung anzufassen (der Fragebogen-Editor und der
|
|
* Einstellungs-Editor stoeren sich so nicht), beide Schreibwege laufen unter derselben Sperre termine-cfg:<featureId>.
|
|
* Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen.
|
|
*/
|
|
|
|
const FUNKTION = 'termine'
|
|
const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
|
const CACHE_MS = 30 * 1000
|
|
const CACHE_MAX = 10000
|
|
|
|
const cache = new Map()
|
|
/** Wird mit jedem Speichern erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
|
|
let generation = 0
|
|
|
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
|
|
|
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
|
|
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
|
|
|
|
/** Dokument lesen; fehlt es (404 "Dokument fehlt"), ist das Ergebnis null. Fehlt Collection oder Datenbank, wird der Fehler weitergereicht. */
|
|
export async function holeOderNull(collectionId, id) {
|
|
try {
|
|
return await getDocument(collectionId, id)
|
|
} catch (err) {
|
|
if (dokumentFehlt(err)) return null
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* { einstellung, version } aus einem termineConfig-Dokument. Unlesbares JSON (mit Log) ergibt einstellung null, die Version
|
|
* des Dokuments bleibt: wer die kaputte Einstellung ueberschreiben will, speichert mit genau dieser Version.
|
|
*/
|
|
function liesDokument(doc) {
|
|
const version = Number.isInteger(doc.version) ? doc.version : 0
|
|
try {
|
|
const einstellung = JSON.parse(doc.einstellung)
|
|
if (!istObjekt(einstellung)) throw new Error('kein Objekt')
|
|
return { einstellung, version }
|
|
} catch (err) {
|
|
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
|
|
return { einstellung: null, version }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Der Fragebogen aus einem termineConfig-Dokument, normalisiert. Fehlt das Attribut (Altbestand, noch nie gespeichert), ist es
|
|
* der leere Fragebogen ohne Meldung; kaputtes JSON oder ein Inhalt, den die Normalisierung ablehnt, ergibt ebenfalls den leeren
|
|
* mit genau einer Logzeile (Dokument-ID und Grund) und wirft nie: der Fragebogen darf Einstellung und Kontext nicht blockieren.
|
|
*/
|
|
function fragebogenAus(doc) {
|
|
const roh = doc.fragebogen
|
|
if (roh === undefined || roh === null || roh === '') return leerFragebogen()
|
|
try {
|
|
const erg = normalisiereFragebogen(JSON.parse(roh))
|
|
if (erg.fehler) throw new Error(`ungueltige Angaben (${Object.keys(erg.fehler).join(', ')})`)
|
|
return erg.fragebogen
|
|
} catch (err) {
|
|
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
|
|
return leerFragebogen()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die gespeicherte Einstellung einer Funktion samt Fragebogen.
|
|
* @param {string} featureId ID des portalFeatures-Dokuments
|
|
* @returns {Promise<{ einstellung: object, version: number, fragebogen: object }|null>} null, wenn es noch keine (lesbare)
|
|
* Einstellung gibt; fragebogen ist immer normalisiert (leer, wenn nichts oder nichts Lesbares gespeichert ist)
|
|
*/
|
|
export async function ladeEinstellung(featureId) {
|
|
pruefeText(featureId, 'featureId')
|
|
const doc = await holeOderNull(config.collections.termineConfig, featureId)
|
|
const gelesen = doc ? liesDokument(doc) : null
|
|
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
|
|
}
|
|
|
|
async function baueKontext(feature) {
|
|
const [project, doc] = await Promise.all([
|
|
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
|
|
holeOderNull(config.collections.termineConfig, feature.$id),
|
|
])
|
|
const gelesen = doc ? liesDokument(doc) : null
|
|
return {
|
|
feature,
|
|
project,
|
|
einstellung: gelesen?.einstellung ?? null,
|
|
version: gelesen?.version ?? 0,
|
|
fragebogen: doc ? fragebogenAus(doc) : leerFragebogen(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kontext fuer die oeffentliche Schnittstelle: aktive Funktion 'termine' mit diesem siteKey und gespeicherter Einstellung.
|
|
* @param {string} siteKey
|
|
* @param {Date} [now] Zeitpunkt fuer den 30-Sekunden-Speicher
|
|
* @returns {Promise<object|null>} null bei Modus off, ungueltigem oder unbekanntem siteKey, anderem Zustand als active
|
|
* und fehlender Einstellung
|
|
*/
|
|
export async function kontextPerSiteKey(siteKey, now = new Date()) {
|
|
if (termineAus()) return null
|
|
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
|
|
const nowMs = pruefeNow(now, 'kontextPerSiteKey')
|
|
const gemerkt = cache.get(siteKey)
|
|
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
|
|
|
|
const stand = generation
|
|
const feature = await findActiveBySiteKey(siteKey, FUNKTION)
|
|
let ctx = feature ? await baueKontext(feature) : null
|
|
if (ctx && (!ctx.einstellung || fehlendePflichtangaben(ctx.einstellung).length > 0)) ctx = null
|
|
if (stand === generation) merke(siteKey, ctx, nowMs)
|
|
return ctx
|
|
}
|
|
|
|
function merke(siteKey, ctx, nowMs) {
|
|
if (cache.size >= CACHE_MAX) {
|
|
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
|
|
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
|
|
}
|
|
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
|
|
}
|
|
|
|
/**
|
|
* Kontext fuer Portal und Gaesteseiten (Token): Funktion 'termine' im Zustand active oder disabled, ohne Speicher.
|
|
* @param {string} featureId ID des portalFeatures-Dokuments
|
|
* @returns {Promise<object|null>} null bei Modus off, unbekannter ID, anderer Funktion oder anderem Zustand
|
|
*/
|
|
export async function kontextPerFeature(featureId) {
|
|
if (termineAus()) return null
|
|
if (typeof featureId !== 'string' || featureId === '') return null
|
|
const feature = await holeOderNull(config.collections.portalFeatures, featureId)
|
|
if (!feature || feature.featureKey !== FUNKTION || !['active', 'disabled'].includes(feature.state)) return null
|
|
return baueKontext(feature)
|
|
}
|
|
|
|
/**
|
|
* Kontext fuer das Portal (Entwurf, Bestellung, Einbau): das uebergebene portalFeatures-Dokument in jedem Zustand, ohne Speicher.
|
|
* Es gibt keine Modus- und keine Zustandspruefung und keinen Abgleich mit der Datenbank: das Dokument ist das, das der Aufrufer
|
|
* schon (frisch) gelesen und geprueft hat. Oeffentlich ist dieser Kontext nie, dafuer gibt es kontextPerSiteKey.
|
|
* @param {{ $id: string, projectId?: string }} feature portalFeatures-Dokument
|
|
* @returns {Promise<object>} ctx; ohne termineConfig-Dokument einstellung null, version 0, leerer Fragebogen
|
|
*/
|
|
export async function kontextFuerPortal(feature) {
|
|
pruefeText(feature?.$id, 'feature.$id')
|
|
return baueKontext(feature)
|
|
}
|
|
|
|
const veraltet = () =>
|
|
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
|
|
status: 409,
|
|
code: 'EINSTELLUNG_VERALTET',
|
|
})
|
|
|
|
const keineEinstellung = () =>
|
|
Object.assign(new Error('Es gibt noch keinen Entwurf für den Terminplaner. Bitte wähle zuerst eine Branche.'), {
|
|
status: 409,
|
|
code: 'keine_einstellung',
|
|
})
|
|
|
|
/** Eingabe normalisieren und als JSON-Text fuer termineConfig.fragebogen bereitstellen; ungueltig => 400 mit err.fehler. */
|
|
function pruefeFragebogen(eingabe) {
|
|
const erg = normalisiereFragebogen(eingabe)
|
|
if (erg.fehler) throw Object.assign(new Error('Der Fragebogen ist ungültig.'), { status: 400, fehler: erg.fehler })
|
|
return { fragebogen: erg.fragebogen, json: JSON.stringify(erg.fragebogen) }
|
|
}
|
|
|
|
/**
|
|
* Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die
|
|
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
|
|
* { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre
|
|
* termine-cfg:<featureId>. Danach ist der Speicher von kontextPerSiteKey leer.
|
|
* Statt der Einstellung darf eine Funktion `async (bisher) => einstellung` uebergeben werden (Ruling R40): sie laeuft
|
|
* INNERHALB der Sperre nach der Versionspruefung, bekommt die gespeicherte Einstellung (Objekt) oder null und liefert die
|
|
* zu speichernde; sie darf werfen (der Fehler geht unveraendert durch, es wird nichts geschrieben). So laufen Pruefungen,
|
|
* die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie
|
|
* das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand
|
|
* aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch.
|
|
* Mit der Option `fragebogen` (TP2: Neuanlage mit Branche, "Vorlage ersetzen" mit zurueckgesetzten Bestaetigungen) schreibt
|
|
* derselbe Vorgang unter derselben Sperre auch den Fragebogen: entweder die Eingabe (Objekt, wird normalisiert) oder eine
|
|
* Funktion `(bisher) => Eingabe` (bisher = gespeicherter Fragebogen, leer wenn keiner da ist), die wie die Einstellungs-Funktion
|
|
* nach der Versionspruefung laeuft, sodass ein Einbau-Wunsch, der zwischendurch gespeichert wurde, nicht verloren geht. Ist sie
|
|
* ungueltig, gibt es { status: 400, fehler } und es wird nichts geschrieben (weder Einstellung noch Version noch Dokument).
|
|
* Ohne die Option bleibt der gespeicherte Fragebogen unberuehrt.
|
|
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
|
|
* @param {object|((bisher: object|null) => object|Promise<object>)} einstellungOderFn
|
|
* @param {{ version: number|null, by?: string, now?: Date, fragebogen?: object|((bisher: object) => object|Promise<object>) }} optionen
|
|
* @returns {Promise<{ einstellung: object, version: number, fragebogen?: object }>} wie gespeichert, mit der neuen Version;
|
|
* fragebogen (normalisiert) nur, wenn die Option gesetzt war
|
|
*/
|
|
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date(), fragebogen } = {}) {
|
|
pruefeText(feature?.$id, 'feature.$id')
|
|
const alsFunktion = typeof einstellungOderFn === 'function'
|
|
if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt')
|
|
const nowMs = pruefeNow(now, 'speichereEinstellung')
|
|
|
|
return withLocks([`termine-cfg:${feature.$id}`], async () => {
|
|
const col = config.collections.termineConfig
|
|
const vorhanden = await holeOderNull(col, feature.$id)
|
|
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
|
|
if (version !== null && version !== aktuell) throw veraltet()
|
|
|
|
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? liesDokument(vorhanden).einstellung : null) : einstellungOderFn
|
|
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
|
|
const json = JSON.stringify(einstellung)
|
|
|
|
// der Fragebogen wird vor dem ersten Schreibzugriff geprueft: ein ungueltiger laesst auch die Einstellung ungespeichert
|
|
let neuerFragebogen
|
|
if (fragebogen !== undefined) {
|
|
const eingabe = typeof fragebogen === 'function' ? await fragebogen(vorhanden ? fragebogenAus(vorhanden) : leerFragebogen()) : fragebogen
|
|
neuerFragebogen = pruefeFragebogen(eingabe)
|
|
}
|
|
|
|
const felder = {
|
|
customerId: feature.customerId,
|
|
projectId: feature.projectId,
|
|
einstellung: json,
|
|
version: aktuell + 1,
|
|
updatedAt: new Date(nowMs).toISOString(),
|
|
updatedBy: String(by ?? '').slice(0, 100),
|
|
...(neuerFragebogen ? { fragebogen: neuerFragebogen.json } : {}),
|
|
}
|
|
try {
|
|
if (vorhanden) await updateDocument(col, feature.$id, felder)
|
|
else await createDocument(col, felder, feature.$id)
|
|
} finally {
|
|
// auch bei einem Fehler: unklar, was in der Datenbank steht
|
|
generation += 1
|
|
cache.clear()
|
|
}
|
|
return { einstellung: JSON.parse(json), version: aktuell + 1, ...(neuerFragebogen ? { fragebogen: neuerFragebogen.fragebogen } : {}) }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Speichert den Fragebogen einer Funktion (die Eingaben des Kunden im Bestell-Fragebogen). Gleiche Sperre wie
|
|
* speichereEinstellung (termine-cfg:<featureId>); `aendere(bisher)` laeuft darin, bekommt den gespeicherten Fragebogen
|
|
* (leer, wenn keiner gespeichert oder lesbar ist) und liefert die zu speichernde Eingabe, die normalisiert wird. Sie darf werfen
|
|
* (der Fehler geht unveraendert durch, es wird nichts geschrieben). Eine ungueltige Eingabe wirft { status: 400, fehler }.
|
|
* Geschrieben werden nur fragebogen, updatedAt und updatedBy: `version` und `einstellung` bleiben unberuehrt, ein offener
|
|
* Einstellungs-Editor mit der gelesenen Version kann also weiter speichern. Gibt es noch kein termineConfig-Dokument, wird keins
|
|
* angelegt, sondern { status: 409, code: 'keine_einstellung' } geworfen. Danach ist der Speicher von kontextPerSiteKey leer.
|
|
* @param {{ $id: string }} feature portalFeatures-Dokument
|
|
* @param {(bisher: object) => object|Promise<object>} aendere
|
|
* @param {{ by?: string, now?: Date }} optionen
|
|
* @returns {Promise<{ fragebogen: object }>} der gespeicherte, normalisierte Fragebogen
|
|
*/
|
|
export async function speichereFragebogen(feature, aendere, { by = '', now = new Date() } = {}) {
|
|
pruefeText(feature?.$id, 'feature.$id')
|
|
if (typeof aendere !== 'function') throw new TypeError('aendere fehlt')
|
|
const nowMs = pruefeNow(now, 'speichereFragebogen')
|
|
|
|
return withLocks([`termine-cfg:${feature.$id}`], async () => {
|
|
const col = config.collections.termineConfig
|
|
const vorhanden = await holeOderNull(col, feature.$id)
|
|
if (!vorhanden) throw keineEinstellung()
|
|
|
|
const { fragebogen, json } = pruefeFragebogen(await aendere(fragebogenAus(vorhanden)))
|
|
try {
|
|
await updateDocument(col, feature.$id, {
|
|
fragebogen: json,
|
|
updatedAt: new Date(nowMs).toISOString(),
|
|
updatedBy: String(by ?? '').slice(0, 100),
|
|
})
|
|
} finally {
|
|
// auch bei einem Fehler: unklar, was in der Datenbank steht
|
|
generation += 1
|
|
cache.clear()
|
|
}
|
|
return { fragebogen }
|
|
})
|
|
}
|
|
|
|
/** Vorschau-Host des Projekts (<subdomain>.project.webklar.com, klein) oder null; gleiche Schreibweise wie in analyseHosts. */
|
|
function vorschauHost(project) {
|
|
return project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
|
|
}
|
|
|
|
/**
|
|
* Host der echten (Live-)Website eines Projekts: nur wenn project.liveDomain gesetzt ist, der erste gueltige Host aus
|
|
* analyseHosts(project), der nicht die Vorschau-Adresse <subdomain>.project.webklar.com ist; sonst undefined.
|
|
* Einzige Quelle fuer websiteUrl() und den Betriebsvorschlag des Fragebogens (routes/termine.js).
|
|
*/
|
|
export function liveHost(project) {
|
|
if (!project?.liveDomain) return undefined
|
|
const vorschau = vorschauHost(project)
|
|
return analyseHosts(project).find((host) => host !== vorschau)
|
|
}
|
|
|
|
/**
|
|
* Adresse der Website des Betriebs (Link auf Gaesteseiten und im Mailfuss), immer die echte Seite statt der Vorschau:
|
|
* betrieb.websiteUrl; sonst https:// + Live-Domain des Projekts (liveHost); sonst https:// + <subdomain>.project.webklar.com;
|
|
* sonst die Portal-Adresse (config.publicBaseUrl).
|
|
*/
|
|
export function websiteUrl(ctx) {
|
|
const eigene = ctx?.einstellung?.betrieb?.websiteUrl
|
|
if (typeof eigene === 'string' && eigene !== '') return eigene
|
|
const project = ctx?.project
|
|
if (project) {
|
|
// analyseHosts: [<subdomain>.project.webklar.com, Live-Domain], ungueltige und doppelte Hosts fehlen
|
|
const vorschau = vorschauHost(project)
|
|
const host = liveHost(project) ?? analyseHosts(project).find((h) => h === vorschau)
|
|
if (host) return `https://${host}`
|
|
}
|
|
return config.publicBaseUrl
|
|
}
|
|
|
|
/** Leert den Speicher von kontextPerSiteKey (Tests). */
|
|
export function resetKontextForTests() {
|
|
generation += 1
|
|
cache.clear()
|
|
}
|