543 lines
23 KiB
JavaScript
543 lines
23 KiB
JavaScript
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 Impressum und Datenschutz des Betriebs fallen auf die Website
|
||
* des Betriebs zurueck (websiteUrl(ctx), zuletzt die Portal-Adresse; 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)
|
||
* G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen,
|
||
* es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben)
|
||
* G08 fall 'zugeordnet' | 'nach_frist' | 'begonnen' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (Adresse der
|
||
* Erklaerung, Pflicht bei 'nicht_zugeordnet'). 'begonnen' (Widerruf nach Terminbeginn, Frist noch nicht um) und
|
||
* 'nicht_zugeordnet' tragen keinen Fallblock (neutraler Satz) und keine Buchungsnummer
|
||
* K20 fall (= block) 'anfrage' oder nichts
|
||
* K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' | 'begonnen' (Pflicht);
|
||
* altStart (verschoben); eingang (widerrufen, nach_frist, begonnen, 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}',
|
||
begonnen: 'Widerruf nach Terminbeginn: {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)
|
||
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". */
|
||
export 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', 'begonnen', 'nicht_zugeordnet'], 'G08')
|
||
const mitBlock = fall === 'zugeordnet' || fall === 'nach_frist'
|
||
const values = {
|
||
...betriebsWerte(ctx),
|
||
eingang: eingangText(extra.eingang),
|
||
// die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes
|
||
nummer: mitBlock ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER,
|
||
betreff: betreff('G08', anrede, 'standard', {}),
|
||
}
|
||
return { values, blocks: { [anrede]: true, ...(mitBlock ? { [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', 'begonnen'], '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', 'begonnen', '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), jeweils
|
||
* ausser mit extra.ohneIcs.
|
||
* @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 = extra.ohneIcs === true ? null : 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,
|
||
)
|
||
}
|