Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent af4029af81
commit 30d40ad6ce
3 files changed
+1656

No files matched your search

+194
View File
@@ -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<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 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<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)
}
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:<featureId>. 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()
}
+536
View File
@@ -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=<verwaltungsToken>` (G02, G03, G05, G07), Bestaetigungslink `#v=<token>`
* (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<string, string>, blocks: Record<string, true> }} 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<object>} 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<object>} 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,
)
}
+926
View File
@@ -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, />Preis</)
assert.doesNotMatch(mail.html, />Bei</)
})
// ---------------------------------------------------------------- Links
test('Links zeigen auf config.publicBaseUrl: Bestaetigung #v=, Verwaltung #m= (auch widerruf_link), Portal dashboard.html#termine', () => {
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)
})