Files

565 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { darfEmpfaenger } from './limits.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.
* - Freitext des Betriebs (G04 nachricht, G06 grund und nachricht bei Absage durch den Betrieb) geht nur bis zur
* Empfaenger-Kappe (limits.js darfEmpfaenger) an eine Adresse; danach geht die Mail ohne diesen Text raus (anGast).
* - 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}$/
/** Felder mit Freitext des Betriebs je Gastmail (G06 nur bei von === 'betrieb'). */
const FREITEXT_FELDER = { G04: ['nachricht'], G06: ['grund', 'nachricht'] }
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} – leider abgelehnt' }, du: { standard: 'Deine Terminanfrage bei {betrieb} – leider abgelehnt' } },
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
/**
* Zaehlt eine Mail mit Freitext des Betriebs gegen die Empfaenger-Kappe der Adresse (limits.js darfEmpfaenger) und gibt
* das extra zurueck, mit dem die Mail rausgeht: unveraendert, solange die Kappe nicht erreicht ist oder kein Freitext
* dabei ist (dann zaehlt nichts), sonst ohne die Freitextfelder (Log-Warnung ohne Adresse). G05, K20/K21 und der Rest
* sind nicht betroffen, ebenso G06 des Gastes oder des Systems.
*/
function begrenzeFreitext(art, extra, empfaenger, now) {
const felder = FREITEXT_FELDER[art]
if (!felder || (art === 'G06' && extra.von !== 'betrieb') || !felder.some((feld) => hatText(extra[feld]))) return extra
const adresse = hatText(empfaenger) ? empfaenger.trim().toLowerCase() : ''
if (adresse === '' || darfEmpfaenger(adresse, now)) return extra
console.warn(`[termine-mails] ${art}: Empfänger-Kappe erreicht, die Mail geht ohne Freitext raus`)
return { ...extra, ...Object.fromEntries(felder.map((feld) => [feld, undefined])) }
}
/**
* 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. Freitext des Betriebs in G04/G06 geht nur bis zur Empfaenger-Kappe der Adresse mit (siehe
* begrenzeFreitext).
* @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')
const an = hatText(extra?.an) ? extra.an : buchung?.gastEmail
extra = begrenzeFreitext(art, extra ?? {}, an, now)
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,
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,
)
}