From 30d40ad6cefd61f5cdbf6250416b1da8d0b64bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:15:25 +0000 Subject: [PATCH] Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/kontext.js | 194 ++++++ server/services/termine/mails.js | 536 +++++++++++++++++ test/termineKontext.test.js | 926 +++++++++++++++++++++++++++++ 3 files changed, 1656 insertions(+) create mode 100644 server/services/termine/kontext.js create mode 100644 server/services/termine/mails.js create mode 100644 test/termineKontext.test.js diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js new file mode 100644 index 0000000..c6ebeb3 --- /dev/null +++ b/server/services/termine/kontext.js @@ -0,0 +1,194 @@ +import { config } from '../../config.js' +import { Query, createDocument, getDocument, listDocuments, updateDocument } from '../appwriteAdmin.js' +import { analyseHosts } from '../analyse.js' +import { withLocks } from '../keyedLock.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 } + * 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) + * 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; 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). + * 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' + +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 gilt als nicht gespeichert (mit Log). */ +function liesEinstellung(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 null + } +} + +/** + * Die gespeicherte Einstellung einer Funktion. + * @param {string} featureId ID des portalFeatures-Dokuments + * @returns {Promise<{ einstellung: object, version: number }|null>} null, wenn es noch keine (lesbare) gibt + */ +export async function ladeEinstellung(featureId) { + pruefeText(featureId, 'featureId') + const doc = await holeOderNull(config.collections.termineConfig, featureId) + return doc ? liesEinstellung(doc) : null +} + +async function baueKontext(feature) { + const [project, gespeichert] = await Promise.all([ + feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null, + ladeEinstellung(feature.$id), + ]) + return { feature, project, einstellung: gespeichert?.einstellung ?? null, version: gespeichert?.version ?? 0 } +} + +/** + * 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} 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 listDocuments(config.collections.portalFeatures, [ + Query.equal('siteKey', siteKey), + Query.equal('featureKey', FUNKTION), + Query.equal('state', 'active'), + Query.limit(1), + ]) + let ctx = feature ? await baueKontext(feature) : null + if (ctx && !ctx.einstellung) 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} 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) +} + +const veraltet = () => + Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), { + status: 409, + code: 'EINSTELLUNG_VERALTET', + }) + +/** + * 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:. Danach ist der Speicher von kontextPerSiteKey leer. + * @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument + * @param {object} einstellung + * @param {{ version: number, by?: string, now?: Date }} optionen + * @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version + */ +export async function speichereEinstellung(feature, einstellung, { version, by = '', now = new Date() } = {}) { + pruefeText(feature?.$id, 'feature.$id') + if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt') + const nowMs = pruefeNow(now, 'speichereEinstellung') + const json = JSON.stringify(einstellung) + + 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 !== aktuell) throw veraltet() + + const felder = { + customerId: feature.customerId, + projectId: feature.projectId, + einstellung: json, + version: aktuell + 1, + updatedAt: new Date(nowMs).toISOString(), + updatedBy: String(by ?? '').slice(0, 100), + } + 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 } + }) +} + +/** + * Adresse der Website des Betriebs (Link auf Gaesteseiten): betrieb.websiteUrl oder https:// + erster gespeicherter Host + * des Projekts (analyseHosts); sonst null. + */ +export function websiteUrl(ctx) { + const eigene = ctx?.einstellung?.betrieb?.websiteUrl + if (typeof eigene === 'string' && eigene !== '') return eigene + const [host] = ctx?.project ? analyseHosts(ctx.project) : [] + return host ? `https://${host}` : null +} + +/** Leert den Speicher von kontextPerSiteKey (Tests). */ +export function resetKontextForTests() { + generation += 1 + cache.clear() +} diff --git a/server/services/termine/mails.js b/server/services/termine/mails.js new file mode 100644 index 0000000..ff54d12 --- /dev/null +++ b/server/services/termine/mails.js @@ -0,0 +1,536 @@ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { preisHinweis, preisText, zahlungText } from './einstellungen.js' +import { icsTermin } from './ics.js' +import { websiteUrl } from './kontext.js' +import { einreihen } from './outbox.js' +import { widerrufsbelehrung } from './rechtstexte.js' +import { buchungsNummer, verwaltungsToken } from './token.js' +import { pruefeNow, zeitMs } from './util.js' +import { datumKurz, datumLang, uhrzeit } from './zeit.js' + +/** + * Mails des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6): Betreffzeilen, Werte und + * Bloecke fuer die Vorlagen G01-G08 (an Gaeste) und K20/K21 (an den Betrieb) und das Einreihen in die Warteschlange. + * `art` ist immer die Vorlagen-ID ('G01' ... 'K21'); sie steht auch im Feld `art` des Outbox-Eintrags. + * + * Regeln fuer alle Mails: + * - Genau ein Anrede-Block (sie oder du) je G-Mail, nach einstellung.anrede; K20/K21 sind immer Du-Form ohne diese Bloecke. + * - Leere freiwillige Werte erscheinen als "–". Links auf die Website des Betriebs (Impressum, Datenschutz) fallen auf + * websiteUrl(ctx) und zuletzt auf die Portal-Adresse zurueck (ein href darf nicht "–" sein). + * - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer. + * - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse); + * in keinem Betreff steht ein Gastname. + * - Verwaltungslink `${publicBaseUrl}/termin.html#m=` (G02, G03, G05, G07), Bestaetigungslink `#v=` + * (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`. + * + * `buchung` ist das Buchungsdokument; fuer G07 und alle Mails nach einer Aenderung der Zustand NACH der Aenderung. + * Ohne Buchung (null oder nur Gastangaben) gehen nur G01 `zu_viele`, G08 `nicht_zugeordnet` und K21 `nicht_zugeordnet`. + * + * extra je Vorlage (fall = Variante der Vorlage; fehlt sie dort, wo sie Pflicht ist, wirft werteFuer): + * G01 fall 'bestaetigen' (Vorgabe) | 'zu_viele'; verifyToken (nur bei 'bestaetigen'); an (Adresse, wenn keine Buchung existiert) + * G02 anfrageAngenommen: true, wenn der Betrieb eine Anfrage angenommen hat + * G04 nachricht (optionale Nachricht des Betriebs) + * G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1) + * G07 altStart (bisheriger Beginn, Pflicht) + * G08 fall 'zugeordnet' | 'nach_frist' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (bei 'nicht_zugeordnet') + * K20 fall (= block) 'anfrage' oder nichts + * K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' (Pflicht); altStart (verschoben); + * eingang (widerrufen, nach_frist, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail, + * gastTelefon, gastNachricht } aus der Erklaerung + * K20/K21 kundeName (Anrede des Kunden; sonst Name aus dem Kundendokument, zuletzt der Betriebsname) + * Fuer Mails ohne Buchung gibt extra.bookingId den Bezug im Outbox-Eintrag an (Vorgabe '-'). + */ + +const LEER = '–' +const OHNE_BUCHUNG = '-' +const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08'] +const BETRIEBS_ARTEN = ['K20', 'K21'] +const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' } +const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ + +function einfrieren(wert) { + if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) { + Object.freeze(wert) + Object.values(wert).forEach(einfrieren) + } + return wert +} + +/** + * Betreffzeilen: BETREFFE[art][anrede][fall] mit {betrieb}, {leistung}, {datum} (TT.MM.JJJJ) und {zeit} (HH:MM). + * K20/K21 stehen in der Du-Form (wie K15-K19). Kein Betreff enthaelt einen Gastnamen. + */ +export const BETREFFE = einfrieren({ + G01: { + sie: { bestaetigen: 'Bitte bestätigen Sie Ihren Termin bei {betrieb}', zu_viele: 'Ihr Termin bei {betrieb} konnte nicht vorgemerkt werden' }, + du: { bestaetigen: 'Bitte bestätige deinen Termin bei {betrieb}', zu_viele: 'Dein Termin bei {betrieb} konnte nicht vorgemerkt werden' }, + }, + G02: { sie: { standard: 'Ihr Termin bei {betrieb} am {datum}' }, du: { standard: 'Dein Termin bei {betrieb} am {datum}' } }, + G03: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } }, + G04: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } }, + G05: { + sie: { standard: 'Erinnerung: Ihr Termin am {datum} um {zeit}' }, + du: { standard: 'Erinnerung: Dein Termin am {datum} um {zeit}' }, + }, + G06: { sie: { standard: 'Ihr Termin am {datum} wurde abgesagt' }, du: { standard: 'Dein Termin am {datum} wurde abgesagt' } }, + G07: { sie: { standard: 'Ihr Termin wurde verschoben' }, du: { standard: 'Dein Termin wurde verschoben' } }, + G08: { sie: { standard: 'Eingangsbestätigung Ihres Widerrufs' }, du: { standard: 'Eingangsbestätigung deines Widerrufs' } }, + K20: { + du: { + buchung: 'Neue Buchung: {leistung} am {datum} um {zeit}', + anfrage: 'Neue Terminanfrage: {leistung} am {datum} um {zeit}', + }, + }, + K21: { + du: { + abgesagt: 'Termin abgesagt: {leistung} am {datum} um {zeit}', + verschoben: 'Termin verschoben: {leistung}, jetzt am {datum} um {zeit}', + widerrufen: 'Termin widerrufen: {leistung} am {datum} um {zeit}', + nach_frist: 'Widerruf nach Ablauf der Frist: {leistung} am {datum} um {zeit}', + nicht_zugeordnet: 'Widerruf ohne passende Buchung', + }, + }, +}) + +function betreff(art, anrede, fall, variablen) { + const muster = BETREFFE[art]?.[anrede]?.[fall] + if (!muster) throw new TypeError(`Kein Betreff für ${art}/${anrede}/${fall}`) + return muster.replace(/\{(\w+)\}/g, (_, name) => { + if (!Object.hasOwn(variablen, name)) throw new TypeError(`Betreff ${art}: Wert für {${name}} fehlt`) + return variablen[name] + }) +} + +// ---------------------------------------------------------------- Bausteine + +const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' +/** Text oder "–". */ +const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER) + +function pflichtText(wert, name) { + if (!hatText(wert)) throw new TypeError(`${name} fehlt`) + return wert.trim() +} + +function pflichtDatum(wert, name) { + const ms = zeitMs(wert) + if (!Number.isFinite(ms)) throw new TypeError(`${name} fehlt oder ist kein Zeitpunkt`) + return new Date(ms) +} + +function wahlFall(fall, erlaubt, name) { + if (!erlaubt.includes(fall)) throw new TypeError(`${name}: fall muss ${erlaubt.join(', ')} sein`) + return fall +} + +const verwaltungsLink = (buchung) => `${config.publicBaseUrl}/termin.html#m=${verwaltungsToken(pflichtText(buchung?.$id, 'buchung.$id'))}` +const portalLink = () => `${config.publicBaseUrl}/dashboard.html#termine` + +function bestaetigungsLink(token) { + if (typeof token !== 'string' || !VERIFY_TOKEN_RE.test(token)) throw new TypeError('extra.verifyToken fehlt oder ist ungültig') + return `${config.publicBaseUrl}/termin.html#v=${token}` +} + +/** Werte des Fusses und Kopfes aller Gaestemails. */ +function betriebsWerte(ctx) { + const e = ctx.einstellung + const b = e.betrieb + const seite = websiteUrl(ctx) || config.publicBaseUrl + return { + farbe: e.farbe, + betrieb_name: b.name, + betrieb_adresse: oderLeer(b.adresse), + betrieb_telefon: oderLeer(b.telefon), + betrieb_email: b.email, + impressum_url: b.impressumUrl || seite, + datenschutz_url: b.datenschutzUrl || seite, + } +} + +/** leistung, datum ("Montag, 12.10.2026"), zeit ("10:00") und die Kurzformen fuer Betreffzeilen. */ +function terminWerte(buchung) { + const start = pflichtDatum(buchung?.startsAt, 'buchung.startsAt') + return { + leistung: pflichtText(buchung.leistungName, 'buchung.leistungName'), + datum: datumLang(start), + zeit: uhrzeit(start), + kurz: { datum: datumKurz(start), zeit: uhrzeit(start) }, + } +} + +/** "45 Minuten", "1 Stunde", "1 Stunde 30 Minuten". */ +function dauerText(buchung) { + const minuten = Math.round((pflichtDatum(buchung?.endsAt, 'buchung.endsAt') - pflichtDatum(buchung?.startsAt, 'buchung.startsAt')) / 60000) + const stunden = Math.floor(minuten / 60) + const rest = minuten % 60 + return [stunden ? `${stunden} ${stunden === 1 ? 'Stunde' : 'Stunden'}` : '', rest || !stunden ? `${rest} ${rest === 1 ? 'Minute' : 'Minuten'}` : ''] + .filter(Boolean) + .join(' ') +} + +/** Person oder Platz zeigen die Gaestemails nur, wenn der Betrieb mehrere Ressourcen hat. */ +const personWert = (e, buchung) => (hatText(buchung?.ressourceName) && (e.ressourcen || []).length > 1 ? buchung.ressourceName.trim() : '') + +/** Bei Gast-Datum "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */ +const eingangText = (eingang) => { + const d = pflichtDatum(eingang, 'extra.eingang') + return `${datumKurz(d)}, ${uhrzeit(d)} Uhr` +} + +/** + * Welcher Hinweis zum Widerruf gehoert in G02? 'widerruf' (Verbraucherbuchung), 'kein_widerruf' (Freizeitleistung mit + * festem Termin fuer Verbraucher, § 312g Abs. 2 Nr. 9 BGB) oder null (Unternehmer, Handbuchung). + */ +function widerrufFall(e, buchung) { + if (buchung.quelle === 'manuell') return null + if (buchung.verbraucher === true) return 'widerruf' + if (e.recht?.freizeit === true && e.recht.gaesteSind !== 'unternehmer') return 'kein_widerruf' + return null +} + +// ---------------------------------------------------------------- Werte je Vorlage + +function gastG01({ ctx, anrede, buchung, extra }) { + const b = ctx.einstellung.betrieb + const fall = wahlFall(extra.fall ?? 'bestaetigen', ['bestaetigen', 'zu_viele'], 'G01') + const values = { ...betriebsWerte(ctx), leistung: LEER, datum: LEER, zeit: LEER, link: LEER } + if (fall === 'bestaetigen') { + const t = terminWerte(buchung) + Object.assign(values, { leistung: t.leistung, datum: t.datum, zeit: t.zeit, link: bestaetigungsLink(extra.verifyToken) }) + } + values.betreff = betreff('G01', anrede, fall, { betrieb: b.name }) + return { values, blocks: { [anrede]: true, [fall]: true } } +} + +function gastG02({ ctx, anrede, buchung, extra }) { + const e = ctx.einstellung + const t = terminWerte(buchung) + const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent }) + const person = personWert(e, buchung) + const storno = e.storno?.text + const fall = widerrufFall(e, buchung) + const link = verwaltungsLink(buchung) + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + person: oderLeer(person), + datum: t.datum, + zeit: t.zeit, + dauer: dauerText(buchung), + preis: preis ? `${preis} (${preisHinweis(e)})` : LEER, + zahlung: preis ? zahlungText(e) : LEER, + nummer: buchungsNummer(buchung.$id), + link, + ort: oderLeer(e.betrieb.adresse), + storno_text: oderLeer(storno), + // wirft, wenn Anschrift oder Telefon fehlen: eine unvollstaendige Belehrung geht nie raus + widerrufsbelehrung: fall === 'widerruf' ? widerrufsbelehrung(e.betrieb) : LEER, + widerruf_link: link, + betreff: betreff('G02', anrede, 'standard', { betrieb: e.betrieb.name, datum: t.kurz.datum }), + } + const blocks = { [anrede]: true } + if (preis) blocks.preis = true + if (person) blocks.person = true + if (hatText(storno)) blocks.storno = true + if (fall) blocks[fall] = true + if (extra.anfrageAngenommen === true) blocks.anfrage_angenommen = true + return { values, blocks } +} + +function gastG03({ ctx, anrede, buchung }) { + const t = terminWerte(buchung) + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + datum: t.datum, + zeit: t.zeit, + link: verwaltungsLink(buchung), + betreff: betreff('G03', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }), + } + return { values, blocks: { [anrede]: true } } +} + +function gastG04({ ctx, anrede, buchung, extra }) { + const t = terminWerte(buchung) + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + datum: t.datum, + zeit: t.zeit, + nachricht: oderLeer(extra.nachricht), + betreff: betreff('G04', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }), + } + return { values, blocks: { [anrede]: true, ...(hatText(extra.nachricht) ? { nachricht: true } : {}) } } +} + +function gastG05({ ctx, anrede, buchung }) { + const t = terminWerte(buchung) + const person = personWert(ctx.einstellung, buchung) + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + person: oderLeer(person), + datum: t.datum, + zeit: t.zeit, + link: verwaltungsLink(buchung), + betreff: betreff('G05', anrede, 'standard', { datum: t.kurz.datum, zeit: t.kurz.zeit }), + } + return { values, blocks: { [anrede]: true, ...(person ? { person: true } : {}) } } +} + +function gastG06({ ctx, anrede, buchung, extra }) { + const b = ctx.einstellung.betrieb + const von = wahlFall(extra.von, ['gast', 'betrieb', 'system'], 'G06 extra.von') + const t = terminWerte(buchung) + // Grund und Nachricht kommen vom Betrieb; sagt der Gast selbst ab, gibt es beides nicht + const grund = von === 'gast' ? '' : extra.grund + const nachricht = von === 'gast' ? '' : extra.nachricht + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + datum: t.datum, + zeit: t.zeit, + // anredeneutral, weil der Wert allein in der Zeile "Abgesagt von" steht + absage_von: von === 'gast' ? (anrede === 'du' ? 'Du selbst' : 'Sie selbst') : b.name, + grund: oderLeer(grund), + nachricht: oderLeer(nachricht), + betreff: betreff('G06', anrede, 'standard', { datum: t.kurz.datum }), + } + const blocks = { [anrede]: true } + if (hatText(grund)) blocks.grund = true + if (hatText(nachricht)) blocks.nachricht = true + return { values, blocks } +} + +function gastG07({ ctx, anrede, buchung, extra }) { + const t = terminWerte(buchung) + const alt = pflichtDatum(extra.altStart, 'extra.altStart') + const values = { + ...betriebsWerte(ctx), + gast_name: oderLeer(buchung.gastName), + leistung: t.leistung, + alt_datum: datumLang(alt), + alt_zeit: uhrzeit(alt), + datum: t.datum, + zeit: t.zeit, + link: verwaltungsLink(buchung), + betreff: betreff('G07', anrede, 'standard', {}), + } + return { values, blocks: { [anrede]: true } } +} + +function gastG08({ ctx, anrede, buchung, extra }) { + const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'nicht_zugeordnet'], 'G08') + const zugeordnet = fall !== 'nicht_zugeordnet' + const values = { + ...betriebsWerte(ctx), + eingang: eingangText(extra.eingang), + // die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes + nummer: zugeordnet ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER, + betreff: betreff('G08', anrede, 'standard', {}), + } + return { values, blocks: { [anrede]: true, ...(zugeordnet ? { [fall]: true } : {}) } } +} + +const GAST_BAUER = { + G01: gastG01, + G02: gastG02, + G03: gastG03, + G04: gastG04, + G05: gastG05, + G06: gastG06, + G07: gastG07, + G08: gastG08, +} + +/** Gemeinsame Gastangaben und Termin der Betriebsmails K20/K21 (Werte, die die Vorlage immer erwartet). */ +function betriebsMailWerte(ctx, buchung, extra) { + const e = ctx.einstellung + return { + kunde_name: oderLeer(extra.kundeName ?? e.betrieb.name), + gast_name: oderLeer(buchung?.gastName), + gast_email: oderLeer(buchung?.gastEmail), + gast_telefon: oderLeer(buchung?.gastTelefon), + gast_nachricht: oderLeer(buchung?.gastNachricht), + portal_link: portalLink(), + } +} + +function betriebK20({ ctx, buchung, extra }) { + const fall = wahlFall(extra.fall ?? 'buchung', ['buchung', 'anfrage'], 'K20') + const t = terminWerte(buchung) + const values = { + ...betriebsMailWerte(ctx, buchung, extra), + leistung: t.leistung, + person: oderLeer(buchung.ressourceName), + datum: t.datum, + zeit: t.zeit, + betreff: betreff('K20', 'du', fall, { leistung: t.leistung, datum: t.kurz.datum, zeit: t.kurz.zeit }), + } + return { values, blocks: fall === 'anfrage' ? { anfrage: true } : {} } +} + +function betriebK21({ ctx, buchung, extra }) { + const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist'], 'K21') + const values = { + ...betriebsMailWerte(ctx, buchung, extra), + leistung: LEER, + person: LEER, + datum: LEER, + zeit: LEER, + alt_datum: LEER, + alt_zeit: LEER, + eingang: LEER, + } + let kurz = { datum: LEER, zeit: LEER } + if (fall !== 'nicht_zugeordnet') { + const t = terminWerte(buchung) + Object.assign(values, { leistung: t.leistung, person: oderLeer(buchung.ressourceName), datum: t.datum, zeit: t.zeit }) + kurz = t.kurz + } + if (fall === 'verschoben') { + const alt = pflichtDatum(extra.altStart, 'extra.altStart') + Object.assign(values, { alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt) }) + } + if (['widerrufen', 'nach_frist', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang) + values.betreff = betreff('K21', 'du', fall, { leistung: values.leistung, datum: kurz.datum, zeit: kurz.zeit }) + return { values, blocks: { [fall]: true } } +} + +/** + * Betreff, Werte und Bloecke einer Mail (Vorlage `art`), bereit fuer renderMail/einreihen. Siehe Kopfkommentar zu extra. + * @param {string} art Vorlagen-ID ('G01' ... 'G08', 'K20', 'K21') + * @param {{ einstellung: object, project?: object|null, feature?: object }} ctx + * @param {object|null} buchung + * @param {object} [extra] + * @returns {{ values: Record, blocks: Record }} values.betreff ist der Betreff + */ +export function werteFuer(art, ctx, buchung, extra = {}) { + const e = ctx?.einstellung + if (!e?.betrieb) throw new TypeError('werteFuer: ctx.einstellung fehlt') + const anrede = e.anrede === 'du' ? 'du' : 'sie' + const argumente = { ctx, anrede, buchung, extra: extra ?? {} } + if (GAST_BAUER[art]) return GAST_BAUER[art](argumente) + if (art === 'K20') return betriebK20(argumente) + if (art === 'K21') return betriebK21(argumente) + throw new TypeError(`werteFuer: unbekannte Art ${String(art).slice(0, 20)}`) +} + +// ---------------------------------------------------------------- Kalenderdateien + +/** Kalenderdatei fuer G02/G07 (REQUEST), G06 (CANCEL) und K20 (PUBLISH, ohne Gast); sonst null. */ +function kalenderDatei(art, ctx, buchung, extra, jetzt) { + const methode = ICS_METHODE[art] + if (!methode) return null + const b = ctx.einstellung.betrieb + const nummer = buchungsNummer(buchung.$id) + const aktuell = Number.isInteger(buchung.icsSequenz) ? buchung.icsSequenz : 0 + // CANCEL traegt eine hoehere Sequenz als die zuletzt gesendete Einladung + const sequenz = Number.isInteger(extra.sequenz) ? extra.sequenz : aktuell + (methode === 'CANCEL' ? 1 : 0) + return { + methode, + datei: icsTermin({ + uid: `${buchung.$id}@termine.webklar.com`, + sequenz, + methode, + start: buchung.startsAt, + ende: buchung.endsAt, + titel: art === 'K20' ? `${buchung.leistungName}: ${oderLeer(buchung.gastName)}` : `${buchung.leistungName} – ${b.name}`, + ort: b.adresse, + beschreibung: `Buchungsnummer: ${nummer}`, + organisator: { name: b.name, email: b.email }, + teilnehmer: methode === 'PUBLISH' ? null : { name: buchung.gastName, email: buchung.gastEmail }, + jetzt, + }), + } +} + +// ---------------------------------------------------------------- Einreihen + +/** + * Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist + * extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL). + * @param {string} art 'G01' ... 'G08' + * @param {object} ctx + * @param {object|null} buchung + * @param {object} [extra] siehe Kopfkommentar + * @param {Date} [now] + * @returns {Promise} der Outbox-Eintrag (Status wartet; der erste Sendeversuch laeuft im Hintergrund) + */ +export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) { + if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`) + pruefeNow(now, 'anGast') + extra = extra ?? {} + const { values, blocks } = werteFuer(art, ctx, buchung, extra) + const b = ctx.einstellung.betrieb + const ics = kalenderDatei(art, ctx, buchung, extra, now) + return einreihen( + { + featureId: ctx.feature?.$id, + bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG, + art, + an: hatText(extra.an) ? extra.an : buchung?.gastEmail, + fromName: b.name, + replyTo: b.email, + template: art, + values, + blocks, + ics: ics?.datei ?? null, + icsMethod: ics?.methode ?? 'REQUEST', + }, + now, + ) +} + +/** Name des Kunden fuer die Anrede in K20/K21; ohne lesbares Kundendokument der Betriebsname. */ +async function kundenName(ctx) { + try { + const kunde = await getDocument(config.collections.customers, ctx.feature.customerId) + return kunde?.name || kunde?.email || ctx.einstellung.betrieb.name + } catch (err) { + if (err?.status !== 404) console.warn(`[termine-mails] Kunde ${ctx.feature?.customerId} nicht lesbar: ${err.message}`) + return ctx.einstellung.betrieb.name + } +} + +/** + * Reiht eine Mail an den Betrieb ein (K20/K21, Absender "WEBklar", an betrieb.email, nie BCC). K20 traegt einen + * Kalendereintrag (PUBLISH), ausser bei einer unbestaetigten Anfrage; K21 hat keinen. + * @param {string} art 'K20' | 'K21' + * @param {object} ctx + * @param {object|null} buchung + * @param {{ block?: string|null, extra?: object }} [optionen] block = Variante der Vorlage (K20 'anfrage'; K21 Pflicht) + * @param {Date} [now] + * @returns {Promise} der Outbox-Eintrag + */ +export async function anBetrieb(art, ctx, buchung, { block = null, extra = {} } = {}, now = new Date()) { + if (!BETRIEBS_ARTEN.includes(art)) throw new TypeError(`anBetrieb: ${String(art).slice(0, 20)} ist keine Betriebsmail`) + pruefeNow(now, 'anBetrieb') + extra = extra ?? {} + const kundeName = hatText(extra.kundeName) ? extra.kundeName : await kundenName(ctx) + const zusatz = { ...extra, kundeName, ...(block ? { fall: block } : {}) } + const { values, blocks } = werteFuer(art, ctx, buchung, zusatz) + const ics = art === 'K20' && block !== 'anfrage' ? kalenderDatei(art, ctx, buchung, zusatz, now) : null + return einreihen( + { + featureId: ctx.feature?.$id, + bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG, + art, + an: ctx.einstellung.betrieb.email, + fromName: 'WEBklar', + replyTo: null, + template: art, + values, + blocks, + ics: ics?.datei ?? null, + icsMethod: ics?.methode ?? 'PUBLISH', + }, + now, + ) +} diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js new file mode 100644 index 0000000..d013fdd --- /dev/null +++ b/test/termineKontext.test.js @@ -0,0 +1,926 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Kontext, Einstellungs-Speicher und Mail-Werte des Terminplaners. Nachbau von Appwrite mit Schema fuer die Termine- +// Collections und portalFeatures, Standard-Limit 25 wie in Produktion; Mails landen in der Senke. Feste Zeiten per Parameter. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + termineConfig: schemaKeys('termineConfig'), + termineOutbox: schemaKeys('termineOutbox'), + }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { renderMail, loadTemplate } = await import('../server/services/mailTemplates.js') +const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { buchungsNummer, verwaltungsToken } = await import('../server/services/termine/token.js') +const K = await import('../server/services/termine/kontext.js') +const M = await import('../server/services/termine/mails.js') +const { ladeEinstellung, speichereEinstellung, kontextPerSiteKey, kontextPerFeature, websiteUrl, resetKontextForTests } = K +const { BETREFFE, werteFuer, anGast, anBetrieb } = M +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const T0 = new Date('2026-10-05T12:00:00.000Z') +const SEKUNDEN = (n, von = T0) => new Date(von.getTime() + n * 1000) + +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_termine' +const SITE_KEY = 'abcdefghijklmnop' +const BUCHUNG_ID = '0b7e7a2c-1f0e-4b51-9a8f-0123456789ab' +const TOKEN = 'A'.repeat(43) +const R1 = 'r_aaaaaaa1' +const R2 = 'r_aaaaaaa2' +const S1 = 's_bbbbbbb1' +const ABSENDER_VORHER = config.smtp.from +const MODUS_VORHER = config.termine.mode +const BASIS_VORHER = config.publicBaseUrl + +const feature = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + featureKey: 'termine', + state: 'active', + enabled: true, + siteKey: SITE_KEY, + ...over, +}) + +const eingabe = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, + { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, + ], + leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, preisArt: 'fest', preisCent: 4500, ressourcen: [R1, R2] }], + storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, + ...over, +}) +const einstellung = (over) => { + const erg = normalisiereEinstellung(eingabe(over), { now: JETZT }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} +const betriebMit = (felder) => ({ ...eingabe().betrieb, ...felder }) + +const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }) => ({ + feature: feature(), + project: projekt, + einstellung: einstellung(over), + version: 1, +}) + +const buchung = (over = {}) => ({ + $id: BUCHUNG_ID, + featureId: FEATURE, + leistungId: S1, + leistungName: 'Damenschnitt', + ressourceId: R1, + ressourceName: 'Anna', + startsAt: '2026-10-12T08:00:00.000+00:00', // Berlin: Montag, 12.10.2026, 10:00 + endsAt: '2026-10-12T08:45:00.000+00:00', + status: 'bestaetigt', + quelle: 'online', + gastName: 'Max Mustermann', + gastEmail: 'max@example.com', + gastTelefon: '0171 1234567', + gastNachricht: 'Bitte ohne Föhnen', + preisCent: 4500, + preisArt: 'fest', + buchungsart: 'sofort', + verbraucher: true, + icsSequenz: 0, + ...over, +}) +const ALT_START = '2026-10-12T07:00:00.000+00:00' // 09:00 Berlin +const EINGANG = new Date('2026-10-05T08:07:00.000Z') // 10:07 Berlin +const ERKLAERUNG = { gastName: 'Erika Beispiel', gastEmail: 'erika@example.com', gastTelefon: '', gastNachricht: 'Ich widerrufe.' } + +async function leere() { + for (const collection of ['portalFeatures', 'termineConfig', 'termineOutbox', 'websiteProjects', 'customers']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } +} +let sink +let log +beforeEach(async () => { + await leere() + resetKontextForTests() + resetOutboxForTests() + config.termine.mode = 'pilot' + config.smtp.from = 'noreply@webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.termine.mode = MODUS_VORHER + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest. */ +async function zaehleAnfragen(fn) { + const echt = globalThis.fetch + let n = 0 + globalThis.fetch = (url, optionen) => { + n += 1 + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } + return n +} + +/** Alle Lese-Anfragen an die Collection scheitern wie in Appwrite mit 404 collection_not_found. */ +async function mitFehlenderCollection(collection, fn) { + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') { + const body = { message: 'Collection with the requested ID could not be found.', code: 404, type: 'collection_not_found' } + return new Response(JSON.stringify(body), { status: 404, headers: { 'content-type': 'application/json' } }) + } + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } +} + +/** Kalenderdatei ohne die Zeilenfaltung (CRLF + Leerzeichen). */ +const entfalte = (ics) => ics.replace(/\r\n /g, '') + +const seedFeature = (over) => aw.seed('portalFeatures', feature(over)) +const seedProjekt = (over = {}) => aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon', ...over }) +const gespeichert = () => aw.docs('termineConfig').get(FEATURE) + +// ---------------------------------------------------------------- Einstellungs-Speicher + +test('speichereEinstellung: erstes Speichern legt das Dokument unter der Funktions-ID an, Laden liefert Einstellung und Version', async () => { + assert.equal(await ladeEinstellung(FEATURE), null, 'noch nichts gespeichert') + const e = einstellung() + const erg = await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT }) + assert.equal(erg.version, 1) + assert.deepEqual(erg.einstellung, e) + + const doc = gespeichert() + assert.equal(doc.customerId, KUNDE) + assert.equal(doc.projectId, PROJEKT) + assert.equal(doc.version, 1) + assert.equal(doc.updatedBy, 'kunde:c_kunde01') + assert.equal(new Date(doc.updatedAt).getTime(), JETZT.getTime()) + assert.equal(typeof doc.einstellung, 'string', 'JSON-Text im Attribut einstellung') + assert.deepEqual(JSON.parse(doc.einstellung), e) + assert.equal(Object.hasOwn(doc, 'data'), false, 'nie ein Attribut data') + assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1 }) +}) + +test('speichereEinstellung: zweites Speichern mit der gelesenen Version zaehlt hoch und ersetzt die Einstellung', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + const neu = einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }) + const erg = await speichereEinstellung(feature(), neu, { version: 1, by: 'x'.repeat(300) }) + assert.equal(erg.version, 2) + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 2) + assert.equal(geladen.einstellung.betrieb.name, 'Salon Neu') + assert.equal(gespeichert().updatedBy.length, 100, 'updatedBy auf 100 Zeichen gekuerzt') +}) + +test('speichereEinstellung: veraltete Version => 409 EINSTELLUNG_VERALTET, gespeichert bleibt der Stand des Ersten', async () => { + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Erster' }) }), { version: 0 }) + const veraltet = (err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET' && /neu laden/i.test(err.message) + await assert.rejects(() => speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zweiter' }) }), { version: 0 }), veraltet) + await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 7 }), veraltet, 'auch eine zu neue Version') + await assert.rejects(() => speichereEinstellung(feature(), einstellung(), {}), veraltet, 'ohne Version') + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 1) + assert.equal(geladen.einstellung.betrieb.name, 'Erster') +}) + +test('speichereEinstellung: zwei gleichzeitige Speicherungen derselben Version => genau eine gewinnt', async () => { + const [a, b] = await Promise.allSettled([ + speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'A' }) }), { version: 0 }), + speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'B' }) }), { version: 0 }), + ]) + assert.deepEqual([a.status, b.status].sort(), ['fulfilled', 'rejected']) + const verlierer = a.status === 'rejected' ? a : b + assert.equal(verlierer.reason.status, 409) + assert.equal((await ladeEinstellung(FEATURE)).version, 1) +}) + +test('speichereEinstellung: Eingaben ohne Funktion oder ohne Einstellung werfen TypeError und schreiben nichts', async () => { + await assert.rejects(() => speichereEinstellung({}, einstellung(), { version: 0 }), TypeError) + await assert.rejects(() => speichereEinstellung(feature(), null, { version: 0 }), TypeError) + await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0, now: 'gestern' }), TypeError) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); fehlende Collection ist ein Fehler, kein "nichts"', async () => { + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3 }) + assert.equal(await ladeEinstellung(FEATURE), null) + assert.equal(log.error.mock.callCount(), 1) + // ueberschreiben darf man sie mit der gespeicherten Version + const erg = await speichereEinstellung(feature(), einstellung(), { version: 3 }) + assert.equal(erg.version, 4) + + seedFeature() + await mitFehlenderCollection('termineConfig', async () => { + await assert.rejects(() => ladeEinstellung(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found') + await assert.rejects(() => kontextPerFeature(FEATURE), (err) => err.type === 'collection_not_found') + await assert.rejects(() => kontextPerSiteKey(SITE_KEY, T0), (err) => err.type === 'collection_not_found') + }) +}) + +// ---------------------------------------------------------------- Kontext + +test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, project, einstellung, version }', async () => { + seedFeature() + seedProjekt() + const e = einstellung() + await speichereEinstellung(feature(), e, { version: 0 }) + const ctx = await kontextPerSiteKey(SITE_KEY, T0) + assert.equal(ctx.feature.$id, FEATURE) + assert.equal(ctx.feature.siteKey, SITE_KEY) + assert.equal(ctx.project.subdomain, 'salon') + assert.deepEqual(ctx.einstellung, e) + assert.equal(ctx.version, 1) +}) + +test('kontextPerSiteKey: Modus off => null ohne Anfrage; ungueltiger siteKey => null ohne Anfrage', async () => { + seedFeature() + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + config.termine.mode = 'off' + assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null)), 0) + config.termine.mode = 'on' + for (const schlecht of ['', 'zu-kurz', 'ABCDEFGHIJKLMNOP', 'abcdefghijklmno1', 'abcdefghijklmnopq', null, undefined, 42, {}]) { + assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(schlecht, T0), null, String(schlecht))), 0, String(schlecht)) + } + assert.ok((await kontextPerSiteKey(SITE_KEY, T0)).einstellung, 'bei on und gueltigem Schluessel gibt es den ctx') +}) + +test('kontextPerSiteKey: unbekannter Schluessel, andere Funktion, Zustand ungleich active und fehlende Einstellung => null', async () => { + assert.equal(await kontextPerSiteKey('bcdefghijklmnopq', T0), null, 'unbekannter Schluessel') + + seedFeature({ featureKey: 'news' }) + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'siteKey gehoert zu einer anderen Funktion') + await deleteDocument('portalFeatures', FEATURE) + + for (const state of ['off', 'ordered', 'setup', 'disabled']) { + resetKontextForTests() + seedFeature({ state }) + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, state) + await deleteDocument('portalFeatures', FEATURE) + } + + resetKontextForTests() + await deleteDocument('termineConfig', FEATURE) + seedFeature() + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'aktiv, aber noch nie eingestellt: fuer Gaeste nicht aktiv') +}) + +test('kontextPerSiteKey: 30 Sekunden im Speicher je siteKey (auch "unbekannt"), danach neue Abfrage', async () => { + seedFeature() + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + const erste = await kontextPerSiteKey(SITE_KEY, T0) + assert.ok(erste) + assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(29)), erste)), 0, 'nach 29 s aus dem Speicher') + assert.ok((await zaehleAnfragen(async () => assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(31))))) > 0, 'nach 31 s neu gelesen') + + // ein unbekannter Schluessel wird ebenfalls gemerkt (ein Besucher mit zufaelligen Schluesseln belastet Appwrite nicht) + const fremd = 'cdefghijklmnopqr' + assert.equal(await kontextPerSiteKey(fremd, T0), null) + seedFeature({ $id: 'f_p_fremd_termine', siteKey: fremd }) + assert.equal(await kontextPerSiteKey(fremd, SEKUNDEN(10)), null, 'noch gemerkt') +}) + +test('speichereEinstellung leert den Speicher: der naechste kontextPerSiteKey sieht die neue Einstellung sofort', async () => { + seedFeature() + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Alt' }) }), { version: 0 }) + assert.equal((await kontextPerSiteKey(SITE_KEY, T0)).einstellung.betrieb.name, 'Alt') + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 }) + const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)) + assert.equal(ctx.einstellung.betrieb.name, 'Neu') + assert.equal(ctx.version, 2) +}) + +test('kontextPerSiteKey: eine Abfrage, die waehrend eines Speicherns lief, merkt ihr altes Ergebnis nicht', async () => { + seedFeature() + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Alt' }) }), { version: 0 }) + resetKontextForTests() + + const echt = globalThis.fetch + let freigeben + const gate = new Promise((resolve) => (freigeben = resolve)) + let gesperrt = false + globalThis.fetch = async (url, optionen) => { + const antwort = await echt(url, optionen) // der Leser hat den alten Stand schon + if (!gesperrt && /\/termineConfig\/documents\//.test(String(url)) && (optionen?.method ?? 'GET') === 'GET') { + gesperrt = true + await gate + } + return antwort + } + try { + const lesen = kontextPerSiteKey(SITE_KEY, T0) + while (!gesperrt) await new Promise((resolve) => setTimeout(resolve, 5)) + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 }) + freigeben() + assert.equal((await lesen).einstellung.betrieb.name, 'Alt', 'der laufende Aufruf liefert, was er gelesen hat') + } finally { + globalThis.fetch = echt + freigeben() + } + assert.equal((await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1))).einstellung.betrieb.name, 'Neu', 'aber nichts Veraltetes im Speicher') +}) + +test('kontextPerFeature: active und disabled liefern ctx, auch ohne Einstellung (einstellung null, version 0); sonst null', async () => { + seedProjekt() + seedFeature() + const ohne = await kontextPerFeature(FEATURE) + assert.equal(ohne.einstellung, null) + assert.equal(ohne.version, 0) + assert.equal(ohne.project.$id, PROJEKT) + + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + assert.equal((await kontextPerFeature(FEATURE)).version, 1) + + await deleteDocument('portalFeatures', FEATURE) + seedFeature({ state: 'disabled' }) + const aus = await kontextPerFeature(FEATURE) + assert.equal(aus.feature.state, 'disabled', 'abgeschaltete Funktion bleibt fuer Absagen und Widerruf erreichbar') + assert.equal(aus.einstellung.betrieb.name, 'Salon Müller') + + for (const over of [{ state: 'off' }, { state: 'setup' }, { featureKey: 'news' }]) { + await deleteDocument('portalFeatures', FEATURE) + seedFeature(over) + assert.equal(await kontextPerFeature(FEATURE), null, JSON.stringify(over)) + } + assert.equal(await kontextPerFeature('f_gibt_es_nicht'), null) + assert.equal(await kontextPerFeature(''), null) + assert.equal(await kontextPerFeature(undefined), null) +}) + +test('kontextPerFeature: Modus off => null ohne Anfrage; fehlendes Projekt => project null', async () => { + seedFeature() + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + assert.equal((await kontextPerFeature(FEATURE)).project, null, 'kein websiteProjects-Dokument') + config.termine.mode = 'off' + assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerFeature(FEATURE), null)), 0) +}) + +test('websiteUrl: betrieb.websiteUrl, sonst https:// + erster Host des Projekts, sonst null', () => { + assert.equal(websiteUrl(ctxMit()), 'https://salon.de/') + const ohneUrl = { betrieb: betriebMit({ websiteUrl: null }) } + assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'www.salon.de' })), 'https://salon.project.webklar.com') + assert.equal(websiteUrl(ctxMit(ohneUrl, { liveDomain: 'https://www.salon.de/start' })), 'https://www.salon.de') + assert.equal(websiteUrl(ctxMit(ohneUrl, { $id: PROJEKT })), null, 'Projekt ohne Hosts') + assert.equal(websiteUrl(ctxMit(ohneUrl, null)), null, 'ohne Projekt') +}) + +// ---------------------------------------------------------------- Betreffzeilen + +const FAELLE = [ + ['G01', buchung(), { verifyToken: TOKEN }], + ['G01', null, { fall: 'zu_viele', an: 'max@example.com' }], + ['G02', buchung(), {}], + ['G02', buchung({ buchungsart: 'anfrage' }), { anfrageAngenommen: true }], + ['G03', buchung({ status: 'angefragt', buchungsart: 'anfrage' }), {}], + ['G04', buchung({ status: 'abgelehnt' }), {}], + ['G04', buchung({ status: 'abgelehnt' }), { nachricht: 'Leider ausgebucht.' }], + ['G05', buchung(), {}], + ['G06', buchung({ status: 'abgesagt' }), { von: 'gast' }], + ['G06', buchung({ status: 'abgesagt' }), { von: 'betrieb', grund: 'Krankheit', nachricht: 'Tut uns leid.' }], + ['G07', buchung({ icsSequenz: 1 }), { altStart: ALT_START }], + ['G08', buchung(), { fall: 'zugeordnet', eingang: EINGANG }], + ['G08', buchung(), { fall: 'nach_frist', eingang: EINGANG }], + ['G08', null, { fall: 'nicht_zugeordnet', eingang: EINGANG, an: 'erika@example.com' }], + ['K20', buchung(), {}], + ['K20', buchung({ status: 'angefragt' }), { fall: 'anfrage' }], + ['K21', buchung(), { fall: 'abgesagt' }], + ['K21', buchung(), { fall: 'verschoben', altStart: ALT_START }], + ['K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }], + ['K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }], + ['K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }], +] + +test('werteFuer: jede Vorlage und jeder Fall rendert in beiden Anreden ohne fehlende oder unbekannte Platzhalter und Bloecke', () => { + for (const anrede of ['sie', 'du']) { + const ctx = ctxMit({ anrede }) + for (const [art, b, extra] of FAELLE) { + const name = `${art} ${extra.fall ?? ''} ${anrede}` + const { values, blocks } = werteFuer(art, ctx, b, extra) + const mail = renderMail(art, values, blocks) + assert.deepEqual(mail.problems, [], name) + assert.equal(mail.subject, values.betreff, name) + assert.ok(mail.subject.length > 5 && mail.subject.length < 200, `${name}: ${mail.subject}`) + assert.ok(!mail.html.includes('{{') && !mail.text.includes('{{'), name) + for (const wert of Object.values(values)) assert.equal(typeof wert, 'string', name) + assert.ok(Object.keys(values).every((key) => loadTemplate(art).placeholders.includes(key)), name) + if (art.startsWith('G')) assert.deepEqual(['sie', 'du'].filter((a) => blocks[a]), [anrede], `${name}: genau eine Anrede`) + else assert.equal(blocks.sie || blocks.du, undefined, `${name}: K-Mails ohne Anrede-Bloecke`) + } + } +}) + +test('BETREFFE: jede Gastvorlage hat Sie- und Du-Fassung, K20/K21 die Du-Fassung; Du ohne Sie/Ihr, Sie ohne du/dein', () => { + for (const art of ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']) { + assert.deepEqual(Object.keys(BETREFFE[art]).sort(), ['du', 'sie'], art) + assert.deepEqual(Object.keys(BETREFFE[art].du), Object.keys(BETREFFE[art].sie), `${art}: gleiche Faelle`) + for (const muster of Object.values(BETREFFE[art].du)) assert.doesNotMatch(muster, /\b(Sie|Ihr\w*)\b/, `${art} Du: ${muster}`) + for (const muster of Object.values(BETREFFE[art].sie)) assert.doesNotMatch(muster, /\b([Dd]u|[Dd]ein\w*|[Dd]ich)\b/, `${art} Sie: ${muster}`) + } + assert.deepEqual(Object.keys(BETREFFE.K20), ['du']) + assert.deepEqual(Object.keys(BETREFFE.K21), ['du']) + assert.throws(() => (BETREFFE.G01.sie.bestaetigen = 'x'), TypeError, 'Tabelle ist eingefroren') +}) + +test('Betreff je Anrede: Sie-Form und Du-Form (G01, G02, G05)', () => { + const sie = ctxMit() + const du = ctxMit({ anrede: 'du' }) + const betreff = (art, ctx, b, extra = {}) => werteFuer(art, ctx, b, extra).values.betreff + assert.equal(betreff('G01', sie, buchung(), { verifyToken: TOKEN }), 'Bitte bestätigen Sie Ihren Termin bei Salon Müller') + assert.equal(betreff('G01', du, buchung(), { verifyToken: TOKEN }), 'Bitte bestätige deinen Termin bei Salon Müller') + assert.equal(betreff('G02', sie, buchung()), 'Ihr Termin bei Salon Müller am 12.10.2026') + assert.equal(betreff('G02', du, buchung()), 'Dein Termin bei Salon Müller am 12.10.2026') + assert.equal(betreff('G05', sie, buchung()), 'Erinnerung: Ihr Termin am 12.10.2026 um 10:00') + assert.equal(betreff('G05', du, buchung()), 'Erinnerung: Dein Termin am 12.10.2026 um 10:00') + assert.equal(betreff('G06', du, buchung(), { von: 'gast' }), 'Dein Termin am 12.10.2026 wurde abgesagt') + assert.equal(betreff('G08', du, buchung(), { fall: 'zugeordnet', eingang: EINGANG }), 'Eingangsbestätigung deines Widerrufs') + for (const [art, b, extra] of FAELLE.filter(([a]) => a.startsWith('G'))) { + assert.doesNotMatch(betreff(art, du, b, extra), /\b(Sie|Ihr\w*)\b/, `${art} Du`) + } +}) + +test('Betreffzeilen der Betriebsmails: Buchung/Anfrage und je Fall von K21; nie ein Gastname', () => { + const ctx = ctxMit() + const betreff = (art, b, extra = {}) => werteFuer(art, ctx, b, extra).values.betreff + assert.equal(betreff('K20', buchung()), 'Neue Buchung: Damenschnitt am 12.10.2026 um 10:00') + assert.equal(betreff('K20', buchung(), { fall: 'anfrage' }), 'Neue Terminanfrage: Damenschnitt am 12.10.2026 um 10:00') + assert.equal(betreff('K21', buchung(), { fall: 'abgesagt' }), 'Termin abgesagt: Damenschnitt am 12.10.2026 um 10:00') + assert.match(betreff('K21', buchung(), { fall: 'verschoben', altStart: ALT_START }), /^Termin verschoben: Damenschnitt, jetzt am 12\.10\.2026 um 10:00$/) + assert.match(betreff('K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }), /^Termin widerrufen: /) + assert.match(betreff('K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }), /^Widerruf nach Ablauf der Frist: /) + assert.equal(betreff('K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }), 'Widerruf ohne passende Buchung') + for (const [art, b, extra] of FAELLE) { + const text = betreff(art, b, extra) + assert.ok(!text.includes('Max') && !text.includes('Mustermann') && !text.includes('Erika') && !text.includes('Beispiel'), `${art}: ${text}`) + } +}) + +// ---------------------------------------------------------------- G01 und G08: kein Text des Gastes + +test('G01 und G08 enthalten nichts, was der Gast eingegeben hat (weder Name noch Telefon noch Nachricht)', () => { + const boese = buchung({ gastName: 'Mallory Eve', gastTelefon: '0999 666666', gastNachricht: 'KAUFEN-SIE-JETZT http://boese.example' }) + for (const anrede of ['sie', 'du']) { + const ctx = ctxMit({ anrede }) + for (const [art, extra] of [ + ['G01', { verifyToken: TOKEN }], + ['G08', { fall: 'zugeordnet', eingang: EINGANG }], + ['G08', { fall: 'nach_frist', eingang: EINGANG }], + ]) { + const { values, blocks } = werteFuer(art, ctx, boese, extra) + const mail = renderMail(art, values, blocks) + for (const geheim of ['Mallory', 'Eve', '0999', 'KAUFEN', 'boese.example']) { + assert.ok(!mail.html.includes(geheim) && !mail.text.includes(geheim) && !mail.subject.includes(geheim), `${art} ${anrede}: ${geheim}`) + } + } + } +}) + +test('G01: genau einer von bestaetigen und zu_viele; zu_viele ohne Link und ohne Termindaten', () => { + const ctx = ctxMit() + const normal = werteFuer('G01', ctx, buchung(), { verifyToken: TOKEN }) + assert.deepEqual(normal.blocks, { sie: true, bestaetigen: true }) + assert.equal(normal.values.link, `${config.publicBaseUrl}/termin.html#v=${TOKEN}`) + assert.equal(normal.values.leistung, 'Damenschnitt') + assert.equal(normal.values.datum, 'Montag, 12.10.2026') + assert.equal(normal.values.zeit, '10:00') + + const zuViele = werteFuer('G01', ctx, null, { fall: 'zu_viele' }) + assert.deepEqual(zuViele.blocks, { sie: true, zu_viele: true }) + const mail = renderMail('G01', zuViele.values, zuViele.blocks) + assert.doesNotMatch(mail.html, /termin\.html#v=/) + assert.doesNotMatch(mail.html, /Termin bestätigen/) + assert.match(mail.html, /höchstmögliche Zahl offener Termine/) + + assert.throws(() => werteFuer('G01', ctx, buchung(), {}), TypeError, 'Bestaetigungstoken fehlt') + assert.throws(() => werteFuer('G01', ctx, buchung(), { verifyToken: 'kurz' }), TypeError, 'Bestaetigungstoken ungueltig') + assert.throws(() => werteFuer('G01', ctx, buchung(), { fall: 'beides', verifyToken: TOKEN }), TypeError) + assert.throws(() => werteFuer('G01', ctx, null, { verifyToken: TOKEN }), TypeError, 'bestaetigen braucht eine Buchung') +}) + +// ---------------------------------------------------------------- G02 + +test('G02 Verbraucherbuchung: Widerrufsblock mit Belehrung, Widerrufslink und Hinweis; kein kein_widerruf', () => { + const ctx = ctxMit() + const { values, blocks } = werteFuer('G02', ctx, buchung(), {}) + assert.equal(blocks.widerruf, true) + assert.equal(blocks.kein_widerruf, undefined) + assert.match(values.widerrufsbelehrung, /^Widerrufsbelehrung/) + assert.match(values.widerrufsbelehrung, /Salon Müller, Hauptstr\. 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon\.de/) + assert.match(values.widerrufsbelehrung, /Muster-Widerrufsformular/) + const mail = renderMail('G02', values, blocks) + assert.deepEqual(mail.problems, []) + assert.match(mail.html, /Vertrag widerrufen/) + assert.match(mail.html, /Widerrufsrecht\./) + assert.ok(mail.html.includes(`href="${values.widerruf_link}"`)) +}) + +test('G02 ohne Verbraucher: Unternehmer-Buchung hat weder widerruf noch kein_widerruf; Handbuchung auch nicht', () => { + const unternehmer = ctxMit({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) + const a = werteFuer('G02', unternehmer, buchung({ verbraucher: false }), {}) + assert.equal(a.blocks.widerruf, undefined) + assert.equal(a.blocks.kein_widerruf, undefined) + assert.equal(a.values.widerrufsbelehrung, '–') + const mail = renderMail('G02', a.values, a.blocks) + assert.deepEqual(mail.problems, []) + assert.doesNotMatch(mail.html, /Widerruf/) + + const manuell = werteFuer('G02', ctxMit(), buchung({ quelle: 'manuell', verbraucher: false }), {}) + assert.equal(manuell.blocks.widerruf, undefined) + assert.equal(manuell.blocks.kein_widerruf, undefined, 'Handbuchung: Belehrung ist Sache des Betriebs') +}) + +test('G02 Freizeitleistung mit festem Termin: kein_widerruf statt widerruf (nicht fuer reine Unternehmer-Betriebe)', () => { + for (const gaesteSind of ['verbraucher', 'gemischt']) { + const { values, blocks } = werteFuer('G02', ctxMit({ recht: { gaesteSind, freizeit: true } }), buchung({ verbraucher: false }), {}) + assert.equal(blocks.kein_widerruf, true, gaesteSind) + assert.equal(blocks.widerruf, undefined, gaesteSind) + const mail = renderMail('G02', values, blocks) + assert.match(mail.html, /kein Widerrufsrecht \(§ 312g Abs\. 2 Nr\. 9 BGB\)/) + assert.doesNotMatch(mail.html, /Vertrag widerrufen/) + } + const nurUnternehmer = werteFuer('G02', ctxMit({ recht: { gaesteSind: 'unternehmer', freizeit: true } }), buchung({ verbraucher: false }), {}) + assert.equal(nurUnternehmer.blocks.kein_widerruf, undefined) +}) + +test('G02 mit Widerrufsrecht, aber ohne Anschrift oder Telefon des Betriebs: Fehler statt unvollstaendiger Belehrung', () => { + for (const felder of [{ adresse: '' }, { telefon: '' }]) { + const ctx = ctxMit({ betrieb: betriebMit(felder) }) + assert.throws(() => werteFuer('G02', ctx, buchung(), {}), TypeError, JSON.stringify(felder)) + } + // ohne Widerrufsrecht braucht die Mail beides nicht + const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }) + assert.doesNotThrow(() => werteFuer('G02', ctx, buchung({ verbraucher: false }), {})) +}) + +test('G02: Preis- und Zahlungszeilen nur mit Preis, Person nur bei mehreren Ressourcen, Storno nur mit Text, Anfrage-Satz nur auf Wunsch', () => { + const voll = werteFuer('G02', ctxMit(), buchung(), { anfrageAngenommen: true }) + assert.deepEqual(Object.keys(voll.blocks).sort(), ['anfrage_angenommen', 'person', 'preis', 'sie', 'storno', 'widerruf']) + assert.equal(voll.values.preis, '45,00 € (inkl. USt)') + assert.equal(voll.values.zahlung, 'Bezahlung vor Ort: bar, EC-Karte') + assert.equal(voll.values.person, 'Anna') + assert.equal(voll.values.storno_text, 'Kostenlose Absage bis 24 Stunden vor dem Termin.') + assert.equal(voll.values.dauer, '45 Minuten') + assert.equal(voll.values.ort, 'Hauptstr. 1, 67722 Winnweiler') + assert.equal(voll.values.nummer, buchungsNummer(BUCHUNG_ID)) + assert.equal(voll.values.gast_name, 'Max Mustermann') + + const kleinab = werteFuer('G02', ctxMit({ betrieb: betriebMit({ kleinunternehmer: true }) }), buchung({ preisArt: 'ab', preisCent: 4500 }), {}) + assert.equal(kleinab.values.preis, 'ab 45,00 € (kein Ausweis der USt nach § 19 UStG)') + + const schlicht = werteFuer( + 'G02', + ctxMit({ ressourcen: [eingabe().ressourcen[0]], leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }], storno: { text: '' } }), + buchung({ preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', dauer: 45, endsAt: '2026-10-12T09:30:00.000+00:00' }), + {}, + ) + assert.deepEqual(Object.keys(schlicht.blocks).sort(), ['sie', 'widerruf'], 'weder Preis noch Person noch Storno noch Anfrage') + assert.equal(schlicht.values.preis, '–') + assert.equal(schlicht.values.dauer, '1 Stunde 30 Minuten') + const mail = renderMail('G02', schlicht.values, schlicht.blocks) + assert.deepEqual(mail.problems, []) + assert.doesNotMatch(mail.html, />PreisBei { + config.publicBaseUrl = 'https://portal.example.test' + const ctx = ctxMit() + const manage = `https://portal.example.test/termin.html#m=${verwaltungsToken(BUCHUNG_ID)}` + + const g01 = werteFuer('G01', ctx, buchung(), { verifyToken: TOKEN }) + assert.equal(g01.values.link, `https://portal.example.test/termin.html#v=${TOKEN}`) + assert.ok(renderMail('G01', g01.values, g01.blocks).html.includes(`href="${g01.values.link}"`)) + + const g02 = werteFuer('G02', ctx, buchung(), {}) + assert.equal(g02.values.link, manage) + assert.equal(g02.values.widerruf_link, manage) + for (const art of ['G03', 'G05', 'G07']) { + assert.equal(werteFuer(art, ctx, buchung(), { altStart: ALT_START }).values.link, manage, art) + } + + for (const [art, extra] of [['K20', {}], ['K21', { fall: 'abgesagt' }]]) { + const { values, blocks } = werteFuer(art, ctx, buchung(), extra) + assert.equal(values.portal_link, 'https://portal.example.test/dashboard.html#termine', art) + assert.ok(renderMail(art, values, blocks).html.includes('href="https://portal.example.test/dashboard.html#termine"'), art) + } + assert.doesNotMatch(JSON.stringify(g01) + JSON.stringify(g02), /project\.webklar\.com/) +}) + +test('Impressum und Datenschutz im Fuss: Angaben des Betriebs, sonst dessen Website, sonst das Portal (nie "–" als href)', () => { + const mitAngaben = werteFuer('G03', ctxMit(), buchung(), {}).values + assert.equal(mitAngaben.impressum_url, 'https://salon.de/impressum') + assert.equal(mitAngaben.datenschutz_url, 'https://salon.de/datenschutz') + + const ohneLinks = { impressumUrl: null, datenschutzUrl: null } + const web = werteFuer('G03', ctxMit({ betrieb: betriebMit(ohneLinks) }), buchung(), {}).values + assert.equal(web.impressum_url, 'https://salon.de/') + assert.equal(web.datenschutz_url, 'https://salon.de/') + + const nackt = werteFuer('G03', ctxMit({ betrieb: betriebMit({ ...ohneLinks, websiteUrl: null }) }, null), buchung(), {}).values + assert.equal(nackt.impressum_url, config.publicBaseUrl) + assert.equal(nackt.datenschutz_url, config.publicBaseUrl) + + const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }), buchung(), {}).values + assert.equal(leer.betrieb_telefon, '–') + assert.equal(leer.betrieb_adresse, '–') + assert.equal(mitAngaben.farbe, '#7c3aed') +}) + +// ---------------------------------------------------------------- G03 bis G08 + +test('G04: Block nachricht nur mit Nachricht des Betriebs', () => { + const ctx = ctxMit() + assert.deepEqual(werteFuer('G04', ctx, buchung(), {}).blocks, { sie: true }) + const mit = werteFuer('G04', ctx, buchung(), { nachricht: 'Leider ausgebucht.' }) + assert.deepEqual(mit.blocks, { sie: true, nachricht: true }) + assert.match(renderMail('G04', mit.values, mit.blocks).html, /Leider ausgebucht\./) + assert.deepEqual(werteFuer('G04', ctx, buchung(), { nachricht: ' ' }).blocks, { sie: true }) +}) + +test('G05: Person nur bei mehreren Ressourcen', () => { + assert.equal(werteFuer('G05', ctxMit(), buchung(), {}).blocks.person, true) + const einzel = ctxMit({ ressourcen: [eingabe().ressourcen[0]], leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }] }) + assert.equal(werteFuer('G05', einzel, buchung(), {}).blocks.person, undefined) +}) + +test('G06: absage_von ist anredeneutral (Sie selbst / Du selbst / Betriebsname); Grund und Nachricht nur bei Absage durch den Betrieb', () => { + const sie = ctxMit() + const du = ctxMit({ anrede: 'du' }) + const extra = { grund: 'Krankheit', nachricht: 'Wir melden uns.' } + const vonGast = werteFuer('G06', sie, buchung(), { von: 'gast', ...extra }) + assert.equal(vonGast.values.absage_von, 'Sie selbst') + assert.deepEqual(vonGast.blocks, { sie: true }, 'Grund/Nachricht des Betriebs gehoeren nicht zu einer Absage des Gastes') + assert.equal(werteFuer('G06', du, buchung(), { von: 'gast' }).values.absage_von, 'Du selbst') + + for (const von of ['betrieb', 'system']) { + const erg = werteFuer('G06', du, buchung(), { von, ...extra }) + assert.equal(erg.values.absage_von, 'Salon Müller', von) + assert.deepEqual(erg.blocks, { du: true, grund: true, nachricht: true }, von) + assert.equal(erg.values.grund, 'Krankheit') + const mail = renderMail('G06', erg.values, erg.blocks) + assert.match(mail.html, /Krankheit/) + assert.match(mail.html, /Wir melden uns\./) + } + assert.deepEqual(werteFuer('G06', sie, buchung(), { von: 'betrieb', grund: 'Krankheit' }).blocks, { sie: true, grund: true }) + assert.deepEqual(werteFuer('G06', sie, buchung(), { von: 'betrieb', nachricht: 'Hallo' }).blocks, { sie: true, nachricht: true }) + assert.throws(() => werteFuer('G06', sie, buchung(), {}), TypeError, 'von ist Pflicht') + assert.throws(() => werteFuer('G06', sie, buchung(), { von: 'jemand' }), TypeError) +}) + +test('G07: alte und neue Zeit in Berliner Zeit', () => { + const { values } = werteFuer('G07', ctxMit(), buchung({ icsSequenz: 1 }), { altStart: ALT_START }) + assert.deepEqual([values.alt_datum, values.alt_zeit, values.datum, values.zeit], ['Montag, 12.10.2026', '09:00', 'Montag, 12.10.2026', '10:00']) + assert.throws(() => werteFuer('G07', ctxMit(), buchung(), {}), TypeError, 'altStart fehlt') +}) + +test('G08: Eingang als "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit), Nummer nur der gefundenen Buchung, hoechstens ein Fallblock', () => { + const ctx = ctxMit() + const zugeordnet = werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet', eingang: EINGANG }) + assert.equal(zugeordnet.values.eingang, '05.10.2026, 10:07 Uhr') + assert.equal(zugeordnet.values.nummer, buchungsNummer(BUCHUNG_ID)) + assert.deepEqual(zugeordnet.blocks, { sie: true, zugeordnet: true }) + assert.match(renderMail('G08', zugeordnet.values, zugeordnet.blocks).html, new RegExp(`Buchung ${buchungsNummer(BUCHUNG_ID)}`)) + + const spaet = werteFuer('G08', ctx, buchung(), { fall: 'nach_frist', eingang: EINGANG }) + assert.deepEqual(spaet.blocks, { sie: true, nach_frist: true }) + + const keine = werteFuer('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: EINGANG }) + assert.deepEqual(keine.blocks, { sie: true }, 'weder zugeordnet noch nach_frist') + assert.equal(keine.values.nummer, '–') + + // Nummer aus der Buchung, nie aus einer Eingabe des Gastes + assert.throws(() => werteFuer('G08', ctx, null, { fall: 'zugeordnet', eingang: EINGANG }), TypeError) + assert.throws(() => werteFuer('G08', ctx, buchung(), { eingang: EINGANG }), TypeError, 'fall ist Pflicht') + assert.throws(() => werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet' }), TypeError, 'eingang ist Pflicht') + assert.throws(() => werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet', eingang: 'heute' }), TypeError) +}) + +test('K20 und K21: Gastangaben, "–" fuer Leeres, Kundenname, ein Fallblock je K21', () => { + const ctx = ctxMit() + const k20 = werteFuer('K20', ctx, buchung({ gastTelefon: '', gastNachricht: '' }), { kundeName: 'Silke Müller' }) + assert.deepEqual(k20.blocks, {}) + assert.deepEqual( + [k20.values.kunde_name, k20.values.gast_name, k20.values.gast_email, k20.values.gast_telefon, k20.values.gast_nachricht, k20.values.person], + ['Silke Müller', 'Max Mustermann', 'max@example.com', '–', '–', 'Anna'], + ) + assert.deepEqual(werteFuer('K20', ctx, buchung(), { fall: 'anfrage' }).blocks, { anfrage: true }) + assert.equal(werteFuer('K20', ctx, buchung(), {}).values.kunde_name, 'Salon Müller', 'ohne Kundenname der Betriebsname') + + for (const fall of ['abgesagt', 'verschoben', 'widerrufen', 'nach_frist']) { + const { blocks } = werteFuer('K21', ctx, buchung(), { fall, altStart: ALT_START, eingang: EINGANG }) + assert.deepEqual(blocks, { [fall]: true }, fall) + } + const frei = werteFuer('K21', ctx, ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }) + assert.deepEqual(frei.blocks, { nicht_zugeordnet: true }) + assert.equal(frei.values.gast_name, 'Erika Beispiel') + assert.equal(frei.values.gast_telefon, '–') + assert.equal(frei.values.eingang, '05.10.2026, 10:07 Uhr') + assert.throws(() => werteFuer('K21', ctx, buchung(), {}), TypeError, 'fall ist Pflicht') + assert.throws(() => werteFuer('K21', ctx, buchung(), { fall: 'verschoben' }), TypeError, 'altStart ist Pflicht') + assert.throws(() => werteFuer('K21', ctx, buchung(), { fall: 'widerrufen' }), TypeError, 'eingang ist Pflicht') +}) + +test('werteFuer: unbekannte Vorlage, fehlende Einstellung und unbrauchbare Buchung werfen TypeError', () => { + assert.throws(() => werteFuer('G99', ctxMit(), buchung(), {}), TypeError) + assert.throws(() => werteFuer('G02', { feature: feature(), einstellung: null }, buchung(), {}), TypeError) + assert.throws(() => werteFuer('G02', ctxMit(), buchung({ startsAt: 'irgendwann' }), {}), TypeError) + assert.throws(() => werteFuer('G02', ctxMit(), buchung({ leistungName: '' }), {}), TypeError) + assert.throws(() => werteFuer('G02', ctxMit(), buchung({ $id: undefined }), {}), TypeError) +}) + +// ---------------------------------------------------------------- Einreihen + +const eintraege = () => [...aw.docs('termineOutbox').values()] +const NUMMER = () => buchungsNummer(BUCHUNG_ID) + +test('anGast G02: Outbox-Eintrag an den Gast, Absender Betriebsname, Antworten an Betrieb, Einladung (REQUEST) ohne BCC', async () => { + const doc = await anGast('G02', ctxMit(), buchung(), {}, JETZT) + assert.deepEqual( + { featureId: doc.featureId, bookingId: doc.bookingId, art: doc.art, an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo, icsMethod: doc.icsMethod }, + { featureId: FEATURE, bookingId: BUCHUNG_ID, art: 'G02', an: 'max@example.com', fromName: 'Salon Müller', replyTo: 'info@salon.de', icsMethod: 'REQUEST' }, + ) + assert.equal(doc.subject, 'Ihr Termin bei Salon Müller am 12.10.2026') + await warteAufVersand() + assert.equal(sink.sent.length, 1) + const [mail] = sink.sent + assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'Salon Müller' }) + assert.deepEqual(mail.to, [{ address: 'max@example.com', name: '' }]) + assert.deepEqual(mail.replyTo, [{ address: 'info@salon.de', name: '' }]) + assert.equal('bcc' in mail, false) + assert.equal(mail.icalEvent.method, 'REQUEST') + const ics = entfalte(mail.icalEvent.content) + assert.match(ics, /METHOD:REQUEST/) + assert.match(ics, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`)) + assert.match(ics, /SEQUENCE:0/) + assert.match(ics, /DTSTART:20261012T080000Z/) + assert.match(ics, /DTEND:20261012T084500Z/) + assert.match(ics, /SUMMARY:Damenschnitt – Salon Müller/) + assert.match(ics, /LOCATION:Hauptstr\. 1\\, 67722 Winnweiler/) + assert.match(ics, /ORGANIZER;CN="Salon Müller":mailto:info@salon\.de/) + assert.match(ics, /ATTENDEE;CN="Max Mustermann";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:max@example\.com/) + assert.ok(mail.html.includes(`termin.html#m=${verwaltungsToken(BUCHUNG_ID)}`)) + assert.match(mail.text, new RegExp(NUMMER())) +}) + +test('anGast G07: Einladung mit der Sequenz der Buchung (nach der Verschiebung erhoeht)', async () => { + await anGast('G07', ctxMit(), buchung({ icsSequenz: 1 }), { altStart: ALT_START }, JETZT) + await warteAufVersand() + const [mail] = sink.sent + assert.equal(mail.subject, 'Ihr Termin wurde verschoben') + assert.equal(mail.icalEvent.method, 'REQUEST') + assert.match(mail.icalEvent.content, /SEQUENCE:1/) + assert.match(mail.icalEvent.content, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`)) +}) + +test('anGast G06: Absage (CANCEL) mit derselben UID und hoeherer Sequenz; extra.sequenz uebersteuert', async () => { + await anGast('G06', ctxMit(), buchung({ status: 'abgesagt', icsSequenz: 2 }), { von: 'gast' }, JETZT) + await anGast('G06', ctxMit(), buchung({ $id: 'b2', status: 'abgesagt' }), { von: 'betrieb', grund: 'Krankheit', sequenz: 7 }, JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 2) + for (const mail of sink.sent) { + assert.equal(mail.icalEvent.method, 'CANCEL') + assert.match(mail.icalEvent.content, /METHOD:CANCEL/) + assert.match(mail.icalEvent.content, /STATUS:CANCELLED/) + } + const sequenzen = Object.fromEntries(sink.sent.map((m) => [/UID:([^@\r\n]+)@/.exec(m.icalEvent.content)[1], /SEQUENCE:(\d+)/.exec(m.icalEvent.content)[1]])) + assert.deepEqual(sequenzen, { [BUCHUNG_ID]: '3', b2: '7' }) + const vonGast = sink.sent.find((m) => m.icalEvent.content.includes(BUCHUNG_ID)) + assert.equal(vonGast.subject, 'Ihr Termin am 12.10.2026 wurde abgesagt') + assert.match(vonGast.text, /Sie selbst/) +}) + +test('anGast: G01, G03, G04, G05 und G08 gehen ohne Kalenderdatei raus', async () => { + const ctx = ctxMit() + await anGast('G01', ctx, buchung(), { verifyToken: TOKEN }, JETZT) + await anGast('G03', ctx, buchung({ $id: 'b3' }), {}, JETZT) + await anGast('G04', ctx, buchung({ $id: 'b4' }), { nachricht: 'Leider nicht.' }, JETZT) + await anGast('G05', ctx, buchung({ $id: 'b5' }), {}, JETZT) + await anGast('G08', ctx, buchung({ $id: 'b8' }), { fall: 'zugeordnet', eingang: EINGANG }, JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 5) + for (const mail of sink.sent) { + assert.equal('icalEvent' in mail, false, mail.subject) + assert.equal('bcc' in mail, false) + } + assert.deepEqual(eintraege().map((d) => d.icsMethod).filter(Boolean), []) +}) + +test('anGast ohne Buchung: Adresse aus extra.an, Bezug "-" oder extra.bookingId (G01 zu_viele, G08 nicht_zugeordnet)', async () => { + const ctx = ctxMit({ anrede: 'du' }) + const a = await anGast('G01', ctx, null, { fall: 'zu_viele', an: 'dritter@example.com' }, JETZT) + const b = await anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: EINGANG, an: 'erika@example.com', bookingId: 'widerruf' }, JETZT) + assert.deepEqual([a.an, a.bookingId, b.an, b.bookingId], ['dritter@example.com', '-', 'erika@example.com', 'widerruf']) + assert.equal(a.subject, 'Dein Termin bei Salon Müller konnte nicht vorgemerkt werden') + await warteAufVersand() + assert.equal(sink.sent.length, 2) + await assert.rejects(() => anGast('G02', ctx, buchung({ gastEmail: '' }), {}, JETZT), TypeError, 'ohne Empfaenger') +}) + +test('anGast: Betriebsmails und unbekannte Vorlagen werden abgewiesen, nichts wird gespeichert', async () => { + await assert.rejects(() => anGast('K20', ctxMit(), buchung(), {}, JETZT), TypeError) + await assert.rejects(() => anGast('G09', ctxMit(), buchung(), {}, JETZT), TypeError) + await assert.rejects(() => anGast('G02', ctxMit(), buchung(), {}, 'gestern'), TypeError) + assert.equal(eintraege().length, 0) +}) + +test('anBetrieb K20: an die Betriebs-E-Mail, Absender WEBklar, ohne Antworten-an und BCC, Kalendereintrag (PUBLISH) ohne Gast', async () => { + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) + const doc = await anBetrieb('K20', ctxMit(), buchung(), {}, JETZT) + assert.deepEqual( + { an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo, art: doc.art, bookingId: doc.bookingId, icsMethod: doc.icsMethod }, + { an: 'info@salon.de', fromName: 'WEBklar', replyTo: '', art: 'K20', bookingId: BUCHUNG_ID, icsMethod: 'PUBLISH' }, + ) + assert.equal(doc.subject, 'Neue Buchung: Damenschnitt am 12.10.2026 um 10:00') + await warteAufVersand() + const [mail] = sink.sent + assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'WEBklar' }) + assert.deepEqual(mail.to, [{ address: 'info@salon.de', name: '' }]) + assert.ok(!mail.replyTo, 'kein Antworten-an') + assert.equal('bcc' in mail, false) + assert.match(mail.text, /Hallo Silke Müller,/) + assert.match(mail.text, /Max Mustermann/) + assert.equal(mail.icalEvent.method, 'PUBLISH') + const ics = mail.icalEvent.content + assert.match(ics, /METHOD:PUBLISH/) + assert.match(ics, /SUMMARY:Damenschnitt: Max Mustermann/) + assert.doesNotMatch(ics, /ATTENDEE/) + assert.match(ics, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`)) +}) + +test('anBetrieb: K20 "anfrage" ohne Kalendereintrag, K21 je Fall; ohne Kundendokument der Betriebsname als Anrede', async () => { + const ctx = ctxMit() + await anBetrieb('K20', ctx, buchung({ $id: 'b_anfrage', status: 'angefragt' }), { block: 'anfrage' }, JETZT) + await anBetrieb('K21', ctx, buchung({ $id: 'b_abgesagt' }), { block: 'abgesagt' }, JETZT) + await anBetrieb('K21', ctx, buchung({ $id: 'b_verschoben' }), { block: 'verschoben', extra: { altStart: ALT_START } }, JETZT) + await anBetrieb('K21', ctx, null, { block: 'nicht_zugeordnet', extra: { eingang: EINGANG, bookingId: 'widerruf' } }, JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 4) + for (const mail of sink.sent) { + assert.equal('icalEvent' in mail, false, mail.subject) + assert.deepEqual(mail.to, [{ address: 'info@salon.de', name: '' }]) + assert.match(mail.text, /Hallo Salon Müller,/, 'kein Kundendokument => Betriebsname') + } + const betreffe = sink.sent.map((m) => m.subject).sort() + assert.deepEqual(betreffe, [ + 'Neue Terminanfrage: Damenschnitt am 12.10.2026 um 10:00', + 'Termin abgesagt: Damenschnitt am 12.10.2026 um 10:00', + 'Termin verschoben: Damenschnitt, jetzt am 12.10.2026 um 10:00', + 'Widerruf ohne passende Buchung', + ]) + assert.deepEqual(eintraege().map((d) => d.bookingId).sort(), ['b_abgesagt', 'b_anfrage', 'b_verschoben', 'widerruf']) +}) + +test('anBetrieb: Gaestemails und unbekannte Vorlagen werden abgewiesen, K21 braucht einen Fall', async () => { + await assert.rejects(() => anBetrieb('G02', ctxMit(), buchung(), {}, JETZT), TypeError) + await assert.rejects(() => anBetrieb('K21', ctxMit(), buchung(), {}, JETZT), TypeError, 'ohne block') + await assert.rejects(() => anBetrieb('K20', ctxMit(), buchung(), {}, 'gestern'), TypeError) + assert.equal(eintraege().length, 0) +})