Öffnungszeiten: Mails K24 bis K27

Neue Kundenmails (Du-Form, Skelett von K23):
- K24 eingeschaltet (Blöcke angestossen/manuell nach einbau, wieder nach der Abo-Rückkehr)
- K25 Feiertage prüfen (Blöcke pruefen/mehrere, ohne BCC)
- K26 Abo beendet (abo_ende/hosting/wechsel_widerrufen, Frist, einmalig 30 € behalten)
- K27 abgeschaltet (feste Zeiten, Eingaben bis zum Löschdatum, Wiedereinschalten)

server/services/zeiten/mails.js: Builder k24Mail bis k27Mail und sendeZeitenMail
(wirft nie, ohne Adresse { sent:false, reason:'keine_adresse' }, Log ohne Adresse und Namen).

K15 bekommt den Block behalten (Gegenstück neu für den Einrichtungstext; bestellMail
setzt neu = nicht behalten, Standard unverändert), T16 die Blöcke abo und team
(sendT16 führt sie als Vorgabe aus). Pins in mailTemplates- und featureMails-Tests
angepasst; K26 darf „Wartungs-Abo“ im Betreff führen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:33:04 +00:00
1 parent d1f9176c99
commit 0042d8dd8f
11 files changed
+760 -22

No files matched your search

+5 -3
View File
@@ -621,8 +621,10 @@ function einrichtungsPreis(feature) {
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
*/
export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten = false }) {
const { funktion, formen, preisHinweis, danach } = feature.mails
return {
template: 'K15',
@@ -641,7 +643,7 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
portal_url: portalUrl(feature),
rechnung_url: invoiceUrl || '',
},
blocks: { rechnung: Boolean(invoiceUrl) },
blocks: { rechnung: Boolean(invoiceUrl), behalten: Boolean(behalten), neu: !behalten },
}
}
@@ -734,7 +736,7 @@ function sendT16({ funktion, ...werte }, bloecke) {
gesundheit_text: '',
...werte,
},
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, ...bloecke },
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, abo: false, team: false, ...bloecke },
})
}
+148
View File
@@ -0,0 +1,148 @@
import { config } from '../../config.js'
import { sendCustomerMail } from '../mailer.js'
import { datumKurz, wochentag } from '../termine/zeit.js'
/**
* Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K24 bis K27):
* K24 eingeschaltet (Gratisweg im Wartungs-Abo; mit Block `wieder` nach der Rückkehr des Abos)
* K25 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
* K26 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
* K27 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
* siteFeatures.bestellMail: die Werte decken genau die Platzhalter der Vorlage ab. sendeZeitenMail verschickt sie.
*
* Anrede: `kunde` ist der Name oder das Kundendokument; ohne Namen bleibt kunde_name leer (wie bei K22/K23 im Terminplaner).
* Datumsangaben (`frist`, `loeschdatum`) sind Date-Objekte und stehen als TT.MM.JJJJ (Berliner Tag) in der Mail.
*/
const WOCHENTAG_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_FORM = /^\d{4}-\d{2}-\d{2}$/
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
/** Adresse des Editors im Kundenportal. */
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#zeiten`
/** Anrede-Wert: Name aus einem String oder einem Kundendokument, getrimmt; sonst '' (die Vorlage sagt dann „Hallo ,“, wie K22/K23). */
function kundenName(kunde) {
const name = typeof kunde === 'string' ? kunde : kunde?.name
return typeof name === 'string' ? name.trim() : ''
}
/** Date -> 'TT.MM.JJJJ' (Berliner Tag). Wirft bei etwas anderem als einem gültigen Date: ein leeres Datum in der Mail wäre falsch. */
function datum(wert, feld) {
if (!(wert instanceof Date) || Number.isNaN(wert.getTime())) throw new TypeError(`${feld}: gültiges Date erwartet`)
return datumKurz(wert)
}
/**
* K24: Öffnungszeiten live ist eingeschaltet.
* @param {{ kunde: string | { name?: string }, projekt: string, einbau?: string | null, wieder?: boolean, ticketNr?: string | number | null }} p
* einbau: 'angestossen' (Neubau läuft: Leisten erscheinen nach dem nächsten Veröffentlichen) oder 'manuell' (das Team baut ein);
* alles andere, auch ein fehlender Wert, sagt das Vorsichtige: das Team baut ein.
* wieder: das Wartungs-Abo läuft wieder und die Funktion wurde automatisch wieder eingeschaltet.
* ticketNr: Nummer des Einbau-Tickets; ohne sie steht kein Ticket in der Mail.
*/
export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = null }) {
const nr = String(ticketNr ?? '').trim().replace(/^#/, '')
const angestossen = einbau === 'angestossen'
return {
template: 'K24',
values: {
kunde_name: kundenName(kunde),
projekt,
// führendes Leerzeichen: steht direkt hinter einem Wort, ohne Ticket bleibt nichts übrig
ticket: nr ? ` (Ticket #${nr})` : '',
portal_url: portalUrl(),
},
blocks: { angestossen, manuell: !angestossen, wieder: Boolean(wieder) },
}
}
/** Eine Zeile je Erinnerungstag: 'Sa 03.10. – Tag der Deutschen Einheit', bei „bitte prüfen“ mit Zusatz. */
function tagesZeile(eintrag) {
if (typeof eintrag === 'string') return eintrag
const { tag, name, art } = eintrag || {}
const zeile = TAG_FORM.test(String(tag))
? `${WOCHENTAG_KURZ[wochentag(tag) - 1]} ${tag.slice(8, 10)}.${tag.slice(5, 7)}.${name ? ` – ${name}` : ''}`
: [tag, name].filter(Boolean).join(' – ')
return art === 'pruefen' ? `${zeile} (bitte prüfen)` : zeile
}
/**
* K25: Feiertage stehen an (eine Mail für alle neuen Tage; Aufrufer: Erinnerungs-Job, ohne BCC ans Team).
* @param {{ kunde: string | { name?: string }, projekt: string, tage: Array<{ tag: string, name?: string, art?: 'feiertag' | 'pruefen' }> }} p
* tage: [{ tag: 'YYYY-MM-DD', name, art }] wie zeitlogik.erinnerungsTage(); Blöcke: pruefen (mindestens ein „bitte prüfen“-Tag), mehrere (> 1 Tag).
*/
export function k25Mail({ kunde, projekt, tage }) {
const liste = Array.isArray(tage) ? tage : []
return {
template: 'K25',
values: {
kunde_name: kundenName(kunde),
projekt,
tage: liste.map(tagesZeile).join('\n'),
portal_url: portalUrl(),
},
blocks: { pruefen: liste.some((e) => e?.art === 'pruefen'), mehrere: liste.length > 1 },
}
}
/**
* K26: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
* @param {{ kunde: string | { name?: string }, projekt: string, art?: string, frist: Date, preis: string }} p
* art: Abo-Lage aus aboStatus.aboLage(): 'hosting' (das Abo ist ein Hosting-Abo), 'wechsel_widerrufen' (Wechsel ins Wartungs-Abo
* widerrufen), sonst (auch 'beendet' und Unbekanntes) 'abo_ende'. frist: Tag des Abschaltens (aboEndeAt + 30 Tage).
*/
export function k26Mail({ kunde, projekt, art, frist, preis }) {
return {
template: 'K26',
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() },
blocks: { abo_ende: art !== 'hosting' && art !== 'wechsel_widerrufen', hosting: art === 'hosting', wechsel_widerrufen: art === 'wechsel_widerrufen' },
}
}
/**
* K27: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
* @param {{ kunde: string | { name?: string }, projekt: string, loeschdatum: Date, preis: string }} p
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden (30 Tage nach dem Abschalten); preis: Preis zum Wiedereinschalten.
*/
export function k27Mail({ kunde, projekt, loeschdatum, preis }) {
return {
template: 'K27',
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() },
}
}
/**
* Fehlertext fürs Log: Name und gekürzte Meldung. SMTP-Antworten nennen gern die Adresse (und fremde Server den Namen):
* E-Mail-Adressen und der Name des Kunden werden unkenntlich gemacht.
*/
function fehlerText(err, customer) {
let meldung = String(err?.message ?? '').replace(ADRESSE, '[Adresse]')
const name = kundenName(customer)
if (name.length >= 2) meldung = meldung.split(name).join('[Name]')
return `${err?.name ?? 'Fehler'} ${meldung.slice(0, 200)}`
}
/**
* Eine der Mails K24 bis K27 an die E-Mail-Adresse des Kundenkontos senden. Wirft nie; das Log nennt weder Adresse noch Namen.
* @param {{ email?: string, name?: string } | null | undefined} customer Kundendokument
* @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k24Mail ... k27Mail
* @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K25)
* @returns {Promise<{ sent: boolean, reason?: 'keine_adresse' | 'fehler' }>}
*/
export async function sendeZeitenMail(customer, nachricht, { bccTeam = true } = {}) {
const vorlage = nachricht?.template ?? 'Mail'
try {
const to = String(customer?.email ?? '').trim()
if (!to) {
console.warn(`[zeiten-mail] ${vorlage} entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse`)
return { sent: false, reason: 'keine_adresse' }
}
await sendCustomerMail(to, { ...nachricht, bccTeam })
return { sent: true }
} catch (err) {
console.error(`[zeiten-mail] ${vorlage} nicht gesendet: ${fehlerText(err, customer)}`)
return { sent: false, reason: 'fehler' }
}
}