Buchungskalender: Mails an Gäste, Vermieter und Team

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:12:47 +00:00
1 parent 118be44847
commit e15486263f
18 files changed
+1095 -6

No files matched your search

+3 -2
View File
@@ -191,9 +191,9 @@ const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').tr
* (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an.
* Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung.
* @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string,
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} message
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH', attachments?: object[]|null (nodemailer, unveraendert, z. B. Rechnungs-PDF) }} message
*/
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) {
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST', attachments = null }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
checkMailbox(to, 'to')
@@ -211,6 +211,7 @@ export async function sendGuestMail({ to, fromName, replyTo = null, subject, htm
text,
html,
icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined,
attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined,
})
}
+37
View File
@@ -3,6 +3,8 @@ import { Query, countDocuments, createDocument, deleteDocument, getDocument, kei
import { withLocks } from '../keyedLock.js'
import { sendGuestMail } from '../mailer.js'
import { renderMail } from '../mailTemplates.js'
import { ladeRechnung } from '../unterkunft/speicher.js'
import { rechnungPdf } from '../unterkunft/rechnungPdf.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
@@ -286,6 +288,7 @@ async function sende(id, now) {
if (!platz) return 'wartet'
try {
const attachments = await baueAnhaenge(doc)
await sendGuestMail({
to: doc.an,
fromName: doc.fromName,
@@ -295,6 +298,7 @@ async function sende(id, now) {
text: doc.text,
ics: doc.ics || null,
icsMethod: doc.icsMethod || 'REQUEST',
...(attachments.length ? { attachments } : {}),
})
} catch (err) {
gibPlatzFrei(platz)
@@ -305,6 +309,39 @@ async function sende(id, now) {
})
}
/** Nach so vielen Fehlschlaegen beim Erzeugen des Anhangs geht die Mail ohne ihn raus (Buchungskalender). */
const ANHANG_VERSUCHE = 3
/**
* Anhaenge des Eintrags. Das Feld `anhang` ist JSON {art:'ukRechnung', rechnungId}: die Rechnung (ukRechnungen) wird als PDF
* gerendert (Rechnung-<nr>.pdf bzw. Stornorechnung-<nr>.pdf). Ohne `anhang` oder mit anderem Inhalt: keine Anhaenge. Scheitert das
* Laden oder Rendern, wirft die Funktion einen gewoehnlichen Fehler (der Eintrag bleibt `wartet` und wird wie jeder vorlaeufige
* Fehler wiederholt); ab dem vierten Versuch geht die Mail ohne Anhang raus. Der Fehlertext enthaelt keine Personendaten.
*/
async function baueAnhaenge(doc) {
if (!doc.anhang) return []
let verweis
try {
verweis = JSON.parse(doc.anhang)
} catch {
return []
}
if (verweis?.art !== 'ukRechnung' || typeof verweis.rechnungId !== 'string' || !verweis.rechnungId) return []
try {
const rechnung = await ladeRechnung(verweis.rechnungId)
if (!rechnung?.inhalt) throw new Error('Rechnung nicht lesbar')
const storno = rechnung.art === 'storno'
const pdf = rechnungPdf(rechnung.inhalt, { art: rechnung.art, nummer: rechnung.nummer, datum: rechnung.datum })
return [{ filename: `${storno ? 'Stornorechnung' : 'Rechnung'}-${rechnung.nummer}.pdf`, content: Buffer.from(pdf), contentType: 'application/pdf' }]
} catch (err) {
if ((Number(doc.versuche) || 0) >= ANHANG_VERSUCHE) {
console.error(`[termine-outbox] Eintrag ${doc.$id}: Rechnungs-PDF nicht erzeugt, Mail geht ohne Anhang raus (${String(err?.message).slice(0, 80)})`)
return []
}
throw new Error(`Anhang nicht erzeugt: ${String(err?.message).slice(0, 80)}`)
}
}
/**
* Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf
* gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat
+170 -1
View File
@@ -1,10 +1,179 @@
// Stub: Task 20 ersetzt den Inhalt, die Signaturen bleiben. Protokolliert die Aufrufe fuer Tests.
import { config } from '../../config.js'
import { loadTemplate } from '../mailTemplates.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { einreihen } from '../termine/outbox.js'
import { formatTag } from './datum.js'
import { formatEuro } from './preis.js'
import { ladeEinstellungen, ladePreise, ladeUnterkunft } from './speicher.js'
/**
* Mails des Buchungskalenders: G16-G22 an Gaeste (ueber die Mail-Warteschlange termineOutbox, Absendername = Firma des Vermieters,
* Antworten an dessen E-Mail), K31-K34 an den Vermieter (Kunde des Portals, ohne BCC bei Gastdaten) und T22 ans Team.
* `_aufrufe` protokolliert jeden Aufruf fuer Tests (die Mail geht trotzdem raus). Fehler werden geworfen; der Aufrufer
* (ablauf.js) faengt sie ab, weil der gespeicherte Schritt nicht rueckgaengig gemacht wird.
*
* extra je Gastmail: G17 token (Roh-Token, nur hier und nur einmal bekannt; ergibt den Buchungslink), text (G18 Begruendung),
* rechnung (G19: ukRechnungen-Dokument), stornoRechnung + erstattetCent + grund (G22), G21 braucht nichts.
*/
export const _aufrufe = []
const LEER = '–'
const text = (v) => (typeof v === 'string' ? v.trim() : '')
const GAST_ARTEN = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22']
const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
const FRIST_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function fristText(wert) {
const t = Date.parse(wert)
if (!Number.isFinite(t)) return LEER
const teile = Object.fromEntries(FRIST_FORMAT.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr`
}
function anbieterKontakt(e) {
return [text(e.firma), text(e.strasse), [text(e.plz), text(e.ort)].filter(Boolean).join(' '), text(e.email), text(e.telefon)].filter(Boolean).join('\n')
}
function postenText(posten) {
if (!Array.isArray(posten) || posten.length === 0) return LEER
return posten.map((p) => `${p.text}: ${formatEuro(p.menge * p.einzelCent)}`).join('\n')
}
const ZAHLART = { online: 'Online-Zahlung', vorort: 'Zahlung vor Ort' }
/** Einstellungen des Vermieters: aus dem Kontext, sonst aus der Datenbank. */
async function einstellungenVon(ctx, featureId) {
return ctx?.einstellungen ?? (featureId ? await ladeEinstellungen(featureId) : null) ?? {}
}
/** Gemeinsame Werte aller Mails zu einer Anfrage. */
async function werteZuAnfrage(ctx, anfrage) {
const e = await einstellungenVon(ctx, anfrage.featureId ?? ctx?.featureId)
const u = anfrage.unterkunftId ? await ladeUnterkunft(anfrage.unterkunftId) : null
const n = Number(anfrage.naechte) || naechteVon(anfrage)
return {
einstellungen: e,
unterkunft: u,
werte: {
gast_name: text(anfrage.gast?.name) || 'Gast',
firma: text(e.firma) || text(u?.name) || 'Ihr Gastgeber',
farbe: /^#[0-9a-fA-F]{6}$/.test(text(e.akzentfarbe)) ? e.akzentfarbe : '#111111',
anbieter_kontakt: anbieterKontakt(e) || LEER,
unterkunft: text(u?.name) || LEER,
zeitraum: `${formatTag(anfrage.von)} – ${formatTag(anfrage.bis)}`,
naechte: mehrzahl(n, 'Nacht', 'Nächte'),
personen: mehrzahl(Number(anfrage.personen) || 0, 'Person', 'Personen'),
nummer: text(anfrage.nummer) || LEER,
},
}
}
function naechteVon(a) {
return Math.max(0, Math.round((Date.parse(a.bis) - Date.parse(a.von)) / 86400000))
}
async function stornoText(anfrage) {
// Schnappschuss bei der Freigabe gilt; nur ohne ihn die aktuellen Bedingungen
if (text(anfrage.stornobedingungenText)) return text(anfrage.stornobedingungenText)
const preise = anfrage.unterkunftId ? await ladePreise(anfrage.unterkunftId) : null
return text(preise?.stornobedingungen) || 'Es wurden keine besonderen Stornobedingungen vereinbart.'
}
export async function anGast(art, ctx, anfrage, extra = {}) {
_aufrufe.push({ an: 'gast', art, anfrageId: anfrage?.$id, extra })
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Gastmail`)
extra = extra ?? {}
const gast = anfrage?.gast ?? {}
if (!text(gast.email)) throw new TypeError('Empfänger fehlt')
const { einstellungen: e, werte } = await werteZuAnfrage(ctx, anfrage)
const values = { ...werte }
const blocks = {}
let anhang = null
if (art !== 'G16' && art !== 'G18' && art !== 'G21' && art !== 'G20') values.stornobedingungen = await stornoText(anfrage)
if (art === 'G17' || art === 'G19' || art === 'G20') values.gesamt = formatEuro(anfrage.summen?.bruttoCent ?? 0)
if (art === 'G17' || art === 'G19') values.posten_text = postenText(anfrage.posten)
if (art === 'G17') {
values.frist = fristText(anfrage.fristAt)
const token = text(extra.token)
values.buchungs_link = `${config.publicBaseUrl}/unterkunft/${ctx?.siteKey ?? ''}/buchung/${anfrage.$id}?t=${token}`
values.nachricht_vermieter = text(anfrage.nachrichtVermieter)
blocks.nachricht = values.nachricht_vermieter !== ''
if (!token) throw new TypeError('Token für den Buchungslink fehlt')
}
if (art === 'G18') {
values.nachricht_vermieter = text(extra.text)
blocks.nachricht = values.nachricht_vermieter !== ''
}
if (art === 'G19') {
const zahlart = anfrage.zahlart
values.zahlart_text = ZAHLART[zahlart] ?? LEER
values.rechnung_nummer = text(extra.rechnung?.nummer) || LEER
blocks.online_unbezahlt = zahlart === 'online' && anfrage.status !== 'bezahlt'
if (extra.rechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.rechnung.$id }
}
if (art === 'G20') {
values.rechnung_nummer = text(extra.rechnung?.nummer) || text(anfrage.rechnungNummer) || LEER
}
if (art === 'G21') values.frist = fristText(anfrage.fristAt)
if (art === 'G22') {
values.rechnung_nummer = text(extra.stornoRechnung?.nummer) || LEER
values.nachricht_vermieter = text(extra.grund)
blocks.nachricht = values.nachricht_vermieter !== ''
const erstattung = Number(extra.erstattetCent) || 0
values.erstattung = erstattung > 0 ? formatEuro(erstattung) : ''
blocks.erstattung = erstattung > 0
if (extra.stornoRechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.stornoRechnung.$id }
}
return einreihen({
featureId: anfrage.featureId ?? ctx?.featureId,
bookingId: anfrage.$id,
art: `uk-${art}`,
an: text(gast.email),
fromName: values.firma.slice(0, 120),
replyTo: text(e.email) || null,
template: art,
values,
blocks,
...(anhang ? { anhang: JSON.stringify(anhang) } : {}),
})
}
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(ctx?.projectId ?? '')}`
/**
* Mail an den Vermieter (K31-K34, Kunde des Portals) bzw. ans Team (T22). K31/K32: values.anfrage; die Werte lassen sich ueberschreiben,
* values.an ersetzt die Adresse (Standard ctx.besitzerEmail). T22 geht an die Team-Adressen und wirft nie.
*/
export async function anVermieter(template, ctx, values = {}) {
_aufrufe.push({ an: 'vermieter', template, values })
const { anfrage, an, ...eigene } = values ?? {}
const vorgabe = {}
if (anfrage) {
const { werte } = await werteZuAnfrage(ctx, anfrage)
Object.assign(vorgabe, werte, {
kunde_name: text(ctx?.kundeName) || werte.firma,
gast_email: text(anfrage.gast?.email) || LEER,
nachricht_gast: text(anfrage.gast?.nachricht) || LEER,
gesamt: formatEuro(anfrage.summen?.bruttoCent ?? 0),
zahlart_text: ZAHLART[anfrage.zahlart] ?? LEER,
})
}
const alle = { portal_url: portalUrl(ctx), ...vorgabe, ...eigene }
if (template === 'T22') {
const r = await sendAdminNotice({ template, values: nurPlatzhalter(template, alle) })
if (!r?.sent) console.warn(`[unterkunft] Team-Mail ${template} nicht gesendet: ${r?.reason || 'unbekannt'}`)
return r
}
const empfaenger = text(an) || text(ctx?.besitzerEmail)
if (!empfaenger) throw new TypeError('Empfänger fehlt')
return sendCustomerMail(empfaenger, { template, values: nurPlatzhalter(template, alle), blocks: eigene.blocks ?? {}, bccTeam: ['K33', 'K34'].includes(template) })
}
/** Nur die Werte, die die Vorlage kennt (keine Warnung wegen Zusatzwerten wie farbe oder anbieter_kontakt). */
function nurPlatzhalter(template, werte) {
const kennt = loadTemplate(template).placeholders
return Object.fromEntries(Object.entries(werte).filter(([k]) => kennt.includes(k) && k !== 'blocks'))
}