Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af4029af81
commit
30d40ad6ce
3 files changed
+1656
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user