diff --git a/server/mail-templates/K15 Bestellung Funktion.html b/server/mail-templates/K15 Bestellung Funktion.html index 693faee..48b807e 100644 --- a/server/mail-templates/K15 Bestellung Funktion.html +++ b/server/mail-templates/K15 Bestellung Funktion.html @@ -8,11 +8,12 @@ Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen). preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis). danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal. rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat). + behalten = der Kunde behält die Funktion nach dem Ende seines Wartungs-Abos für einmalig 30 € (Öffnungszeiten live): nichts wird neu eingerichtet. neu = der Normalfall, die Einrichtung läuft jetzt los; genau einer der beiden ist an (bestellMail setzt neu = nicht behalten). --> @@ -38,9 +39,9 @@ a{color:#111111;} - - - + + + diff --git a/server/mail-templates/K24 Zeiten eingeschaltet.html b/server/mail-templates/K24 Zeiten eingeschaltet.html new file mode 100644 index 0000000..4353e17 --- /dev/null +++ b/server/mail-templates/K24 Zeiten eingeschaltet.html @@ -0,0 +1,54 @@ + + + + + + + + +Öffnungszeiten live ist eingeschaltet + + + + +Öffnungszeiten live ist eingeschaltet. ͏ ͏ ͏ +
✓  Bestellung eingegangen
Deine Bestellung: {{funktion}}
Hallo {{kunde_name}},
vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.
Funktion{{funktion}} – Einrichtung
Projekt{{projekt}}
Einmalig bezahlt{{preis}} ({{preis_hinweis}})
Einrichtung{{zeit}}
Hallo {{kunde_name}},
vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein. Du behältst {{funktion_akk}} für Dein Projekt, auch ohne Wartungs-Abo.
Funktion{{funktion}} – Einrichtung – behalten
Projekt{{projekt}}
Einmalig bezahlt{{preis}} ({{preis_hinweis}})
Einrichtung{{zeit}}
Rechnung ansehen →
+ + + + diff --git a/server/mail-templates/K25 Zeiten Feiertage pruefen.html b/server/mail-templates/K25 Zeiten Feiertage pruefen.html new file mode 100644 index 0000000..4195eff --- /dev/null +++ b/server/mail-templates/K25 Zeiten Feiertage pruefen.html @@ -0,0 +1,56 @@ + + + + + + + + +Feiertage stehen an – stimmen Deine Zeiten? + + + + +Stimmen Deine Zeiten an den nächsten Feiertagen? ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K26 Zeiten Abo beendet.html b/server/mail-templates/K26 Zeiten Abo beendet.html new file mode 100644 index 0000000..5539dc4 --- /dev/null +++ b/server/mail-templates/K26 Zeiten Abo beendet.html @@ -0,0 +1,54 @@ + + + + + + + + +Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten? + + + + +Öffnungszeiten live läuft noch bis {{frist}}. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K27 Zeiten abgeschaltet.html b/server/mail-templates/K27 Zeiten abgeschaltet.html new file mode 100644 index 0000000..84a3ba6 --- /dev/null +++ b/server/mail-templates/K27 Zeiten abgeschaltet.html @@ -0,0 +1,52 @@ + + + + + + + + +Öffnungszeiten live ist abgeschaltet + + + + +Öffnungszeiten live ist abgeschaltet. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html index 69ef73f..106ffad 100644 --- a/server/mail-templates/T16 Funktion bestellt.html +++ b/server/mail-templates/T16 Funktion bestellt.html @@ -8,7 +8,7 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“. ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt. zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer. @@ -16,6 +16,8 @@ bereits_aktiv = die Zahlung ist da, aber die Funktion war schon in dem Zustand {{zustand}}; es wurde kein Ticket angelegt und nichts eingerichtet (Einbau nachholen oder erstatten); sitzung nennt die Stripe-Sitzung. anleitung = Knopf „Im Ticketsystem öffnen“ zur Einbauanleitung des Projekts (anleitung_url); nur bei Funktionen mit Anleitung und angelegtem Ticket. gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs). + abo = die Funktion wurde ohne Zahlung eingeschaltet, weil sie im Wartungs-Abo des Kunden enthalten ist (Gratisweg, der Kunde hat selbst eingeschaltet); es gibt keine Rechnung. + team = das Team hat die Funktion ohne Zahlung freigeschaltet („Aktivieren ohne Zahlung“); es gibt keine Rechnung. --> @@ -54,6 +56,12 @@ a{color:#111111;} + + + + + + diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 338ba49..549160f 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -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 }, }) } diff --git a/server/services/zeiten/mails.js b/server/services/zeiten/mails.js new file mode 100644 index 0000000..94966fd --- /dev/null +++ b/server/services/zeiten/mails.js @@ -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' } + } +} diff --git a/test/featureMails.test.js b/test/featureMails.test.js index 1e5f199..c0aa87e 100644 --- a/test/featureMails.test.js +++ b/test/featureMails.test.js @@ -502,7 +502,7 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => { 'portal_url', 'rechnung_url', ]) - assert.deepEqual(k15.blocks, ['rechnung']) + assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu']) assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}') const k16 = loadTemplate('K16') assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url']) @@ -514,7 +514,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => { const t16 = loadTemplate('T16') assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}') assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text']) - assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit']) + assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team']) const t19 = loadTemplate('T19') assert.equal(t19.file, 'T19 Erstattung faellig.html') assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung') @@ -533,8 +533,8 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', assert.equal(k16.template, 'K16') assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`) assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`) - assert.deepEqual(k15.blocks, { rechnung: true }) - assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false }) + assert.deepEqual(k15.blocks, { rechnung: true, behalten: false, neu: true }) + assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, behalten: false, neu: true }) for (const nachricht of [k15, k16]) { const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks) assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index ab29d6c..1212538 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K24–K27 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20'] +const EXPECTED = [...ids('G', 8), ...ids('K', 27), ...ids('T', 19), 'T20'] const GUEST = ids('G', 8) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -75,14 +75,14 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text const k15 = loadTemplate('K15') assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}') assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url']) - assert.deepEqual(k15.blocks, ['rechnung']) + assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu']) const k16 = loadTemplate('K16') assert.equal(k16.subject, '{{funktion_dein}} ist bereit') assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url']) assert.deepEqual(k16.blocks, []) for (const tpl of [k15, k16]) assert.doesNotMatch(tpl.body, /News|Beitr|#news|#termine|Terminplaner/, `${tpl.id}: keine feste Funktion`) - const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true }) + const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true, neu: true }) assert.deepEqual(mail.problems, []) assert.equal(mail.subject, 'Deine Bestellung: Wert-funktion') for (const part of ['Wir richten Wert-funktion_akk jetzt für Dein Projekt ein.', 'Sobald Wert-funktion_nom auf Deiner Website eingebaut ist, schalten wir Wert-funktion_pron im Kundenportal frei', 'Wert-danach', 'Wert-preis (Wert-preis_hinweis)', 'Rechnung ansehen: https://rechnung.example/1', 'Zum Kundenportal: https://portal.example/#x']) { @@ -101,7 +101,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => { const t16 = loadTemplate('T16') assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}') - assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit']) + assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team']) const t19 = loadTemplate('T19') assert.equal(t19.file, 'T19 Erstattung faellig.html') assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/) @@ -511,8 +511,8 @@ test('T02 und T07 erinnern an UptimeRobot', () => { assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text) }) -test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => { - for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id) +test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14, T12 und K26 (Abo beendet, Öffnungszeiten live)', () => { + for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K26'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id) }) test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => { @@ -918,9 +918,9 @@ test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und na assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./) }) -// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest) -test('K22, K23: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => { - for (const id of ['K22', 'K23']) { +// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest); Öffnungszeiten live: K24–K27 ebenso +test('K22, K23, K24–K27: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => { + for (const id of ['K22', 'K23', 'K24', 'K25', 'K26', 'K27']) { const tpl = loadTemplate(id) assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`) const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true)) diff --git a/test/zeitenMails.test.js b/test/zeitenMails.test.js new file mode 100644 index 0000000..ab24543 --- /dev/null +++ b/test/zeitenMails.test.js @@ -0,0 +1,363 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' + +// Mails K24–K27 (Öffnungszeiten live: eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet), ihre Builder und +// sendeZeitenMail (server/services/zeiten/mails.js, Task 13) sowie die neuen Blöcke behalten/neu in K15 und abo/team in T16. +// Die allgemeinen Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Du-Form) prüft test/mailTemplates.test.js. +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { k24Mail, k25Mail, k26Mail, k27Mail, sendeZeitenMail } = await import('../server/services/zeiten/mails.js') +after(() => setMailTransportForTests(null)) + +const KUNDE = { $id: 'c1', name: 'Silke Müller', email: 'silke@example.com' } +const PORTAL = `${config.publicBaseUrl}/dashboard.html#zeiten` + +let sink +let log +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), info: mock.method(console, 'info', () => {}), log: mock.method(console, 'log', () => {}) } +}) +afterEach(() => mock.restoreAll()) +const logText = () => Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') + +const render = (nachricht) => renderMail(nachricht.template, nachricht.values, nachricht.blocks) +const an = (nachricht) => Object.entries(nachricht.blocks || {}).filter(([, on]) => on).map(([name]) => name).sort() + +// Ein Beispiel je Variante: genau diese Nachrichten gehen durch die Prüfung "Werte decken die Platzhalter ab" +const FRIST = new Date('2026-11-04T09:00:00.000Z') +const BEISPIELE = { + 'K24 angestossen': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }), + 'K24 manuell mit Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }), + 'K24 wieder ohne Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }), + 'K25 ein Tag': () => k25Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }), + 'K25 mehrere mit prüfen': () => + k25Mail({ + kunde: KUNDE.name, + projekt: 'Praxis Muster', + tage: [ + { tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' }, + { tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' }, + ], + }), + 'K26 beendet': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }), + 'K26 hosting': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'hosting', frist: FRIST, preis: '30 €' }), + 'K26 Wechsel widerrufen': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'wechsel_widerrufen', frist: FRIST, preis: '30 €' }), + K27: () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', loeschdatum: FRIST, preis: '30 €' }), +} + +test('Vorlagen K24–K27: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => { + const erwartet = { + K24: { file: 'K24 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder'] }, + K25: { file: 'K25 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] }, + K26: { file: 'K26 Zeiten Abo beendet.html', subject: 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?', placeholders: ['kunde_name', 'projekt', 'frist', 'preis', 'portal_url'], blocks: ['abo_ende', 'hosting', 'wechsel_widerrufen'] }, + K27: { file: 'K27 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] }, + } + for (const [id, soll] of Object.entries(erwartet)) { + const tpl = loadTemplate(id) + assert.equal(tpl.file, soll.file, id) + assert.equal(tpl.subject, soll.subject, id) + assert.deepEqual(tpl.placeholders, soll.placeholders, id) + assert.deepEqual(tpl.blocks, soll.blocks, id) + } +}) + +test('Builder decken genau die Platzhalter und Blöcke der Vorlage ab', () => { + for (const [name, bauen] of Object.entries(BEISPIELE)) { + const nachricht = bauen() + const tpl = loadTemplate(nachricht.template) + assert.deepEqual(Object.keys(nachricht.values).sort(), [...tpl.placeholders].sort(), `${name}: Platzhalter`) + for (const block of Object.keys(nachricht.blocks || {})) assert.ok(tpl.blocks.includes(block), `${name}: Block ${block} gehört zur Vorlage`) + const mail = render(nachricht) + assert.deepEqual(mail.problems, [], name) + assert.equal(nachricht.values.portal_url, PORTAL, `${name}: Knopf führt in den Editor`) + assert.equal(nachricht.values.kunde_name, 'Silke Müller', name) + assert.match(mail.text, /Hallo Silke Müller,/, name) + assert.ok(mail.text.includes(`: ${PORTAL}`), `${name}: Knopf mit Adresse im Text`) + } +}) + +test('Anrede: Kunde als Name oder als Kundendokument, ohne Namen bleibt der Wert leer (wie K22/K23)', () => { + assert.equal(k27Mail({ kunde: KUNDE, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, 'Silke Müller') + for (const kunde of [undefined, null, '', ' ', {}, { name: null }]) { + assert.equal(k27Mail({ kunde, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, '', JSON.stringify(kunde)) + } +}) + +test('K24: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => { + const auto = BEISPIELE['K24 angestossen']() + assert.equal(auto.template, 'K24') + assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false }) + const textAuto = render(auto).text + assert.match(textAuto, /nach dem nächsten Veröffentlichen/) + assert.doesNotMatch(textAuto, /baut unser Team/) + assert.match(textAuto, /Ticket #100123/, 'Ticketnummer steht in beiden Fällen in der Mail') + + const hand = BEISPIELE['K24 manuell mit Ticket']() + assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false }) + const textHand = render(hand).text + assert.match(textHand, /baut unser Team/) + assert.doesNotMatch(textHand, /nach dem nächsten Veröffentlichen/) + assert.match(textHand, /Ticket #100123/) + + // ohne bekannten Einbau sagt die Mail das Vorsichtige: das Team baut ein + for (const einbau of [undefined, null, '', 'unbekannt']) { + const unklar = k24Mail({ kunde: 'K', projekt: 'P', einbau }) + assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false }, String(einbau)) + } +}) + +test('K24: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => { + const ohne = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' }) + assert.equal(ohne.values.ticket, '') + assert.doesNotMatch(render(ohne).text, /Ticket|\(\)|\s\./) + const leer = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: ' ' }) + assert.equal(leer.values.ticket, '') + assert.equal(k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)') + + const wieder = BEISPIELE['K24 wieder ohne Ticket']() + assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true }) + const mitWieder = render(wieder).text + assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/) + assert.match(mitWieder, /wieder eingeschaltet/) + assert.match(mitWieder, /Zeiten und Einstellungen sind unverändert/) + assert.doesNotMatch(render(BEISPIELE['K24 angestossen']()).text, /Wartungs-Abo läuft wieder/) +}) + +test('K24: Betreff, Projekt und Knopf', () => { + const mail = render(BEISPIELE['K24 angestossen']()) + assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') + assert.match(mail.text, /Projekt: Praxis Muster/) + assert.match(mail.html, new RegExp(` { + const ein = BEISPIELE['K25 ein Tag']() + assert.equal(ein.template, 'K25') + assert.equal(ein.values.tage, 'Sa 03.10. – Tag der Deutschen Einheit') + assert.deepEqual(an(ein), []) + assert.deepEqual(ein.blocks, { pruefen: false, mehrere: false }) + const textEin = render(ein).text + assert.match(textEin, /Betrifft: Sa 03\.10\. – Tag der Deutschen Einheit/) + assert.doesNotMatch(textEin, /bitte prüfen|mehrere Tage/) + assert.match(textEin, /Stimmen Deine Zeiten/) + assert.match(textEin, /Google/, 'Hinweis: bei Google auch eintragen') + + const viele = BEISPIELE['K25 mehrere mit prüfen']() + assert.equal(viele.values.tage, 'Do 24.12. – Heiligabend (bitte prüfen)\nFr 25.12. – 1. Weihnachtstag') + assert.deepEqual(viele.blocks, { pruefen: true, mehrere: true }) + const mail = render(viele) + assert.match(mail.text, /Betrifft: Do 24\.12\. – Heiligabend \(bitte prüfen\)\nFr 25\.12\. – 1\. Weihnachtstag/) + assert.match(mail.text, /mehrere Tage/) + assert.match(mail.text, /Silvester/, 'Erklärung der „bitte prüfen“-Tage') + assert.match(mail.html, /Heiligabend \(bitte prüfen\)
Fr 25\.12\./, 'Zeilenumbrüche werden zu
') +}) + +test('K25: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => { + const nachricht = k25Mail({ + kunde: 'K', + projekt: 'P', + tage: [ + { tag: '2026-10-05', name: 'A fett', art: 'feiertag' }, + { tag: '2026-10-06', name: 'B', art: 'feiertag' }, + { tag: '2026-10-07', name: 'C', art: 'feiertag' }, + { tag: '2026-10-08', name: 'D', art: 'feiertag' }, + { tag: '2026-10-09', name: 'E', art: 'feiertag' }, + { tag: '2026-10-10', name: 'F', art: 'feiertag' }, + { tag: '2026-10-11', name: 'G', art: 'feiertag' }, + ], + }) + assert.deepEqual(nachricht.values.tage.split('\n').map((zeile) => zeile.slice(0, 9)), ['Mo 05.10.', 'Di 06.10.', 'Mi 07.10.', 'Do 08.10.', 'Fr 09.10.', 'Sa 10.10.', 'So 11.10.']) + const mail = render(nachricht) + assert.match(mail.html, /A <b>fett<\/b>/) + assert.doesNotMatch(mail.html, /fett/) +}) + +test('K25 geht ohne BCC ans Team, K24 mit', async () => { + const k25 = await sendeZeitenMail(KUNDE, BEISPIELE['K25 ein Tag'](), { bccTeam: false }) + assert.deepEqual(k25, { sent: true }) + assert.equal(sink.sent.length, 1) + assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['silke@example.com']) + assert.ok(!sink.sent[0].bcc, 'keine Kopie ans Team') + assert.equal(sink.sent[0].subject, 'Feiertage stehen an – stimmen Deine Zeiten?') + + assert.deepEqual(await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']()), { sent: true }) + assert.equal(sink.sent.length, 2) + assert.deepEqual(sink.sent[1].bcc.map((t) => t.address), config.chatNotify.emails, 'Standard: Kopie ans Team') + assert.ok(sink.sent[1].bcc.length > 0) + assert.equal(sink.sent[1].subject, 'Öffnungszeiten live ist eingeschaltet') + assert.match(sink.sent[1].html, /Hallo Silke Müller,/) +}) + +test('K26: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf', () => { + const faelle = [ + ['K26 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/], + ['K26 hosting', { abo_ende: false, hosting: true, wechsel_widerrufen: false }, /Hosting-Abo/, /Dein Wartungs-Abo ist beendet|widerrufen/], + ['K26 Wechsel widerrufen', { abo_ende: false, hosting: false, wechsel_widerrufen: true }, /Wechsel.*widerrufen/, /Dein Wartungs-Abo ist beendet|Dein Abo ist ein Hosting-Abo/], + ] + for (const [name, blocks, passt, fremd] of faelle) { + const nachricht = BEISPIELE[name]() + assert.equal(nachricht.template, 'K26') + assert.deepEqual(nachricht.blocks, blocks, name) + assert.equal(nachricht.values.frist, '04.11.2026', `${name}: Datum TT.MM.JJJJ`) + assert.equal(nachricht.values.preis, '30 €') + const text = render(nachricht).text + assert.match(text, passt, name) + assert.doesNotMatch(text, fremd, name) + assert.match(text, /04\.11\.2026/, name) + assert.match(text, /einmalig 30 €/, name) + assert.match(text, /Für 30 € behalten: .*#zeiten/, `${name}: Knopf zum Behalten`) + } + // unbekannte Lage: lieber die Standard-Fassung (Abo beendet) als gar kein Grund + assert.deepEqual(k26Mail({ kunde: 'K', projekt: 'P', art: 'was-neues', frist: FRIST, preis: '30 €' }).blocks, { abo_ende: true, hosting: false, wechsel_widerrufen: false }) + assert.equal(render(BEISPIELE['K26 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?') +}) + +test('K26/K27: Datum aus Date (Berliner Tag), ungültiges Datum wirft früh', () => { + // Berliner Tag, nicht UTC: 22:30 UTC am 30.10. ist 23:30 MEZ (noch der 30.), 23:30 UTC am 31.10. ist 00:30 MEZ (schon der 1.11.) + assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-30T22:30:00.000Z'), preis: '30 €' }).values.frist, '30.10.2026') + assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.2026') + assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError) + assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError) + assert.throws(() => k27Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', preis: '30 €' }), TypeError) +}) + +test('K27: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => { + const nachricht = BEISPIELE.K27() + assert.equal(nachricht.template, 'K27') + assert.equal(nachricht.values.loeschdatum, '04.11.2026') + assert.equal(nachricht.values.preis, '30 €') + const mail = render(nachricht) + assert.equal(mail.subject, 'Öffnungszeiten live ist abgeschaltet') + assert.match(mail.text, /wieder feste Zeiten/) + assert.match(mail.text, /04\.11\.2026/) + assert.match(mail.text, /für einmalig 30 € wieder einschalten/) + assert.match(mail.text, /Projekt: Praxis Muster/) +}) + +test('K26 und K27 gehen mit Kopie ans Team', async () => { + assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())).sent, true) + assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, true) + for (const mail of sink.sent) assert.ok(mail.bcc?.length > 0) +}) + +test('ohne E-Mail-Adresse: nichts senden, nichts werfen', async () => { + for (const kunde of [{ name: 'Silke' }, { name: 'Silke', email: '' }, { email: ' ' }, { email: null }, null, undefined]) { + assert.deepEqual(await sendeZeitenMail(kunde, BEISPIELE.K27()), { sent: false, reason: 'keine_adresse' }, JSON.stringify(kunde)) + } + assert.equal(sink.sent.length, 0) +}) + +test('Versandfehler werden gemeldet, nie geworfen; der Grund nennt keine Adresse', async () => { + setMailTransportForTests({ + sendMail: async () => { + throw new Error('550 Recipient silke@example.com rejected for Silke Müller') + }, + }) + const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE.K27()) + assert.equal(ergebnis.sent, false) + assert.equal(ergebnis.reason, 'fehler') + assert.doesNotMatch(JSON.stringify(ergebnis), /silke|Müller/i) + // auch ohne SMTP-Konfiguration (sendCustomerMail wirft) und mit unbrauchbarer Nachricht + setMailTransportForTests(null) + assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, false) + setMailTransportForTests(sink) + assert.equal((await sendeZeitenMail(KUNDE, undefined)).sent, false) + assert.equal((await sendeZeitenMail(KUNDE, { template: 'K99' })).sent, false) + assert.equal(sink.sent.length, 0) +}) + +test('Log enthält weder Adresse noch Namen', async () => { + setMailTransportForTests({ + sendMail: async () => { + throw new Error('550 Recipient silke@example.com rejected for Silke Müller (Praxis Muster)') + }, + }) + const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']()) + assert.equal(ergebnis.sent, false) + const text = logText() + assert.match(text, /K26/, 'das Log nennt die Vorlage') + assert.ok(text.length > 0) + assert.doesNotMatch(text, /silke|example\.com|Müller/i, text) + // auch der Fall ohne Adresse loggt höchstens ohne Namen + await sendeZeitenMail({ name: 'Silke Müller' }, BEISPIELE.K27()) + assert.doesNotMatch(logText(), /silke|Müller/i) +}) + +test('Erfolg schreibt nichts ins Log', async () => { + await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']()) + assert.equal(logText(), '') +}) + +// ---------------------------------------------------------------- K15 behalten, T16 abo/team --- +test('K15: Block behalten ersetzt den Einrichtungstext, ohne ihn bleibt die Mail wie gehabt', () => { + const feature = reg.featureConfig('zeiten') + const ctx = { kundeName: 'Silke Müller', projekt: 'Praxis Muster', invoiceUrl: 'https://invoice.stripe.test/i/1' } + const neu = sf.bestellMail(feature, ctx) + assert.deepEqual(neu.blocks, { rechnung: true, behalten: false, neu: true }) + assert.deepEqual(sf.bestellMail(feature, { ...ctx, behalten: false }).blocks, neu.blocks) + const behalten = sf.bestellMail(feature, { ...ctx, behalten: true }) + assert.deepEqual(behalten.blocks, { rechnung: true, behalten: true, neu: false }) + assert.deepEqual(behalten.values, neu.values, 'dieselben Werte, nur der Wortlaut wechselt') + + const textNeu = render(neu) + assert.deepEqual(textNeu.problems, []) + assert.match(textNeu.text, /Wir richten Öffnungszeiten live jetzt für Dein Projekt ein\./) + assert.match(textNeu.text, /Funktion: Öffnungszeiten live – Einrichtung/) + assert.match(textNeu.text, /Einrichtung: meist innerhalb weniger Tage/) + assert.match(textNeu.text, /Sobald Öffnungszeiten live auf Deiner Website eingebaut ist/) + assert.doesNotMatch(textNeu.text, /behält|behalten/) + + const mail = render(behalten) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Deine Bestellung: Öffnungszeiten live') + assert.match(mail.text, /Hallo Silke Müller,\nvielen Dank für Deine Bestellung und Deine Zahlung\. Du behältst Öffnungszeiten live für Dein Projekt/) + assert.match(mail.text, /Funktion: Öffnungszeiten live – behalten/) + assert.match(mail.text, /Einmalig bezahlt: 30,00 €/) + assert.match(mail.text, /nichts neu eingebaut/) + assert.match(mail.text, /Rechnung ansehen: https:\/\/invoice\.stripe\.test\/i\/1/) + assert.ok(mail.text.includes(`Zum Kundenportal: ${config.publicBaseUrl}/dashboard.html#zeiten`)) + // nichts vom Einrichtungsablauf in der Behalten-Fassung + assert.doesNotMatch(mail.text, /Wir richten|Einrichtung|Sobald|eingebaut ist/) +}) + +test('K15: die Blöcke behalten und neu stehen im Kopf der Vorlage', () => { + const tpl = loadTemplate('K15') + assert.deepEqual(tpl.blocks, ['rechnung', 'behalten', 'neu']) + // jeder Block erscheint, und in der Behalten-Fassung (neu aus) fehlt jeder Rest von zeit/danach + const behalten = renderMail('K15', Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])), { behalten: true }) + assert.deepEqual(behalten.problems, []) + assert.doesNotMatch(behalten.text, /Wert-zeit|Wert-danach/) +}) + +test('T16: Blöcke abo und team nennen den Weg ohne Zahlung', () => { + const tpl = loadTemplate('T16') + assert.deepEqual(tpl.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team']) + const werte = { kunde_name: 'Silke Müller', email: 'silke@example.com', projekt: 'Praxis Muster', funktion: 'Öffnungszeiten live', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: '', gesundheit_text: '' } + const aus = { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false } + const abo = renderMail('T16', werte, { ...aus, abo: true, team: false }) + assert.deepEqual(abo.problems, []) + assert.match(abo.text, /im Wartungs-Abo enthalten/i) + assert.doesNotMatch(abo.text, /vom Team freigeschaltet/i) + const team = renderMail('T16', werte, { ...aus, abo: false, team: true }) + assert.deepEqual(team.problems, []) + assert.match(team.text, /vom Team freigeschaltet/i) + assert.doesNotMatch(team.text, /im Wartungs-Abo enthalten/i) + const keiner = renderMail('T16', werte, aus) + assert.doesNotMatch(keiner.text, /im Wartungs-Abo enthalten|vom Team freigeschaltet/i) + // sendT16 führt beide Blöcke als Vorgabe (aus), damit jede Team-Mail sie nennt + assert.match(readFileSync(new URL('../server/services/siteFeatures.js', import.meta.url), 'utf8'), /gesundheit: false, abo: false, team: false, \.\.\.bloecke/) +})