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,
)
}