Terminplaner: Mailvorlagen G01–G08, K20, K21

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 171b5086b1
commit 274192e88e
11 files changed
+868 -21

No files matched your search

+252 -21
View File
@@ -5,27 +5,11 @@ import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '.
import { contractAttachmentFor, termsDocFor } from '../server/services/products.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// 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); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
'K15',
'K16',
'K17',
'K18',
'K19',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
'T15',
'T16',
'T17',
'T18',
]
// 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); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 18)]
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
@@ -525,3 +509,250 @@ test('T01: Knopf antwortet im Ticketsystem beim Kunden (team_link)', () => {
assert.deepEqual(mail.problems, [])
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/chat\?kunde=c1"[^>]*>Im Ticketsystem antworten &rarr;<\/a>/)
})
// ---------------------------------------------------------------------------
// Terminplaner (Task 9): G01–G08 an Gäste, K20 und K21 an den Betrieb
// ---------------------------------------------------------------------------
const sourceOf = (id) => readFileSync(new URL(loadTemplate(id).file, DIR), 'utf8')
const FOOTER_SENTENCE = 'Diese E-Mail wurde im Auftrag von Wert-betrieb_name über den Terminplaner von WEBklar versendet.'
// Sie-Formen und Du-Formen (der Rechtstext der Widerrufsbelehrung ist ein Platzhalterwert und kommt hier nicht vor)
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
const anrede = (tpl, sie, extra = {}) => ({ ...allBlocks(tpl, true), ...extra, sie, du: !sie })
test('G-Vorlagen: Betreff ist {{betreff}}, Blöcke sie und du, Platzhalter für Farbe und Betriebsfuß', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const block of ['sie', 'du']) assert.ok(tpl.blocks.includes(block), `${id}: Block ${block}`)
for (const p of ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']) {
assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
}
}
})
test('G-Vorlagen: neutrales Layout, Kopf in der Betriebsfarbe, Fuß mit Betrieb, Links und Satz über den Terminplaner', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const source = sourceOf(id)
assert.match(source, /<td[^>]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`)
assert.doesNotMatch(source, /<img|webklar-logo|project\.webklar\.com/i, `${id}: kein Bild, kein WEBklar-Logo, keine Portal-Adresse`)
assert.doesNotMatch(source, /https?:\/\/(?!www\.w3\.org)|google|tracking|pixel/i, `${id}: keine festen Adressen, keine Werbung, kein Tracking`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, on), sie: true, du: false })
assert.deepEqual(mail.problems, [], id)
assert.ok(mail.text.includes(FOOTER_SENTENCE), `${id}: Fußsatz\n${mail.text}`)
for (const wert of ['Wert-betrieb_adresse', 'Wert-betrieb_telefon', 'Wert-betrieb_email']) assert.ok(mail.text.includes(wert), `${id}: ${wert}`)
assert.match(mail.html, /href="Wert-impressum_url"[^>]*>Impressum</, id)
assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz</, id)
assert.match(mail.html, /background:Wert-farbe/, id)
}
}
})
test('G-Vorlagen: jeder Anredetext steht in sie und du, Du-Mail ohne Sie-Formen und Sie-Mail ohne Du-Formen', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const du = renderMail(id, sampleValues(tpl), anrede(tpl, false))
const sie = renderMail(id, sampleValues(tpl), anrede(tpl, true))
assert.deepEqual([du.problems, sie.problems], [[], []], id)
assert.doesNotMatch(du.text, SIE_FORM, `${id}: Du-Fassung enthält Sie-Form\n${du.text}`)
assert.doesNotMatch(du.text, / Sie | Ihr /, id)
assert.doesNotMatch(sie.text, DU_FORM, `${id}: Sie-Fassung enthält Du-Form\n${sie.text}`)
assert.match(sie.text, SIE_FORM, `${id}: Sie-Fassung spricht den Gast an`)
assert.match(du.text, DU_FORM, `${id}: Du-Fassung spricht den Gast an`)
assert.notEqual(du.text, sie.text, id)
// ohne gesetzte Anrede erscheint kein Anredetext (der Server setzt genau einen der beiden Blöcke)
const neither = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: false })
assert.doesNotMatch(neither.text, SIE_FORM, id)
assert.doesNotMatch(neither.text, DU_FORM, id)
}
})
test('G02: Du-Mail enthält im Text kein „ Sie “ und kein „Ihr “', () => {
const tpl = loadTemplate('G02')
const mail = renderMail('G02', sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: true })
assert.deepEqual(mail.problems, [])
assert.doesNotMatch(mail.text, / Sie |Ihr /)
assert.doesNotMatch(mail.html, / Sie | Ihr /)
})
test('G01 und G08 enthalten keinen vom Gast eingegebenen Text (kein Platzhalter gast_name)', () => {
for (const id of ['G01', 'G08']) {
assert.ok(!loadTemplate(id).placeholders.includes('gast_name'), `${id}: Platzhalter`)
assert.ok(!sourceOf(id).includes('gast_name'), `${id}: Quelltext`)
}
// ... die anderen Gästemails nennen den Gast beim Namen
for (const id of ['G02', 'G03', 'G04', 'G05', 'G06', 'G07']) assert.ok(loadTemplate(id).placeholders.includes('gast_name'), id)
})
test('G-Vorlagen: Platzhalter und Blöcke wie im Plan', () => {
const common = ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']
const expected = {
G01: { p: ['leistung', 'datum', 'zeit', 'link'], b: ['zu_viele'] },
G02: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'dauer', 'preis', 'zahlung', 'nummer', 'link', 'ort', 'storno_text', 'widerrufsbelehrung', 'widerruf_link'], b: ['preis', 'person', 'storno', 'widerruf', 'anfrage_angenommen'] },
G03: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'link'], b: [] },
G04: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'nachricht'], b: ['nachricht'] },
G05: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'link'], b: ['person'] },
G06: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'absage_von', 'grund'], b: ['nachricht'] },
G07: { p: ['gast_name', 'leistung', 'alt_datum', 'alt_zeit', 'datum', 'zeit', 'link'], b: [] },
G08: { p: ['eingang', 'nummer'], b: ['zugeordnet', 'nach_frist'] },
}
for (const [id, { p, b }] of Object.entries(expected)) {
const tpl = loadTemplate(id)
assert.deepEqual([...tpl.placeholders].sort(), [...common, ...p].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), ['du', 'sie', ...b].sort(), `${id}: Blöcke`)
}
})
test('G01: Knopf zur Bestätigung, Hinweis für Dritte; Block zu_viele nennt Telefon und Absagen', () => {
const tpl = loadTemplate('G01')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', betrieb_telefon: '06301 1234', leistung: 'Herrenschnitt', datum: 'Montag, 12.10.2026', zeit: '10:00', link: 'https://project.webklar.com/termin.html#v=abc' }
const sie = renderMail('G01', values, { sie: true })
assert.deepEqual(sie.problems, [])
assert.match(sie.text, /Leistung: Herrenschnitt/)
assert.match(sie.text, /Datum: Montag, 12\.10\.2026/)
assert.match(sie.text, /Uhrzeit: 10:00 Uhr/)
assert.match(sie.text, /Termin bestätigen: https:\/\/project\.webklar\.com\/termin\.html#v=abc/)
assert.match(sie.text, /Das waren Sie nicht\? Dann ignorieren Sie diese E-Mail\./)
assert.doesNotMatch(sie.text, /höchstmögliche Zahl/)
const viele = renderMail('G01', values, { sie: true, zu_viele: true })
assert.match(viele.text, /bereits die höchstmögliche Zahl offener Termine/)
assert.match(viele.text, /sagen Sie einen Ihrer offenen Termine ab/)
assert.match(viele.text, /rufen Sie uns an: 06301 1234/)
assert.match(renderMail('G01', values, { du: true }).text, /Das warst du nicht\? Dann ignoriere diese E-Mail\./)
})
test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf nur mit ihren Blöcken', () => {
const tpl = loadTemplate('G02')
const belehrung = 'Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'
const values = {
...sampleValues(tpl),
gast_name: 'Erika Muster', leistung: 'Herrenschnitt', person: 'Anna', datum: 'Montag, 12.10.2026', zeit: '10:00', dauer: '30 Minuten',
preis: '28,00 € inkl. USt', zahlung: 'Bezahlung vor Ort', nummer: 'AB12CD34', link: 'https://project.webklar.com/termin.html#m=tok', ort: 'Hauptstr. 1, 67722 Winnweiler',
storno_text: 'Absage bis 24 Stunden vorher kostenlos.', widerrufsbelehrung: belehrung, widerruf_link: 'https://project.webklar.com/widerrufen.html#m=tok',
}
const basis = renderMail('G02', values, { sie: true })
assert.deepEqual(basis.problems, [])
for (const zeile of ['Guten Tag Erika Muster,', 'Leistung: Herrenschnitt', 'Datum: Montag, 12.10.2026', 'Uhrzeit: 10:00 Uhr', 'Dauer: 30 Minuten', 'Ort: Hauptstr. 1, 67722 Winnweiler', 'Buchungsnummer: AB12CD34']) {
assert.ok(basis.text.split('\n').includes(zeile), `Zeile fehlt: ${zeile}\n${basis.text}`)
}
assert.match(basis.text, /Termin verwalten: https:\/\/project\.webklar\.com\/termin\.html#m=tok/)
assert.match(basis.text, /Ihr Termin ist bestätigt/)
assert.doesNotMatch(basis.text, /28,00|Bezahlung vor Ort|Anna|Absage bis 24 Stunden|Widerruf|angenommen/)
const voll = renderMail('G02', values, { sie: true, preis: true, person: true, storno: true, widerruf: true, anfrage_angenommen: true })
assert.deepEqual(voll.problems, [])
assert.match(voll.text, /Preis: 28,00 € inkl\. USt/)
assert.match(voll.text, /Zahlung: Bezahlung vor Ort/)
assert.match(voll.text, /Anna/)
assert.match(voll.text, /Absage bis 24 Stunden vorher kostenlos\./)
assert.match(voll.text, /Wert-betrieb_name hat Ihre Terminanfrage angenommen/)
assert.match(voll.text, /Vertrag widerrufen: https:\/\/project\.webklar\.com\/widerrufen\.html#m=tok/)
// Belehrung wörtlich, Zeilenumbrüche bleiben, Sonderzeichen sind maskiert
assert.match(voll.html, /Widerrufsrecht: 14 Tage &lt;b&gt;ohne&lt;\/b&gt; Gründe\.<br>Zweite Zeile &amp; mehr/)
assert.ok(voll.text.includes('Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'), voll.text)
// der Wortlaut der Belehrung steht in einem abgesetzten Kasten
assert.match(voll.html, /class="box"[^>]*><tr><td[^>]*>Widerrufsbelehrung<br><br>Widerrufsrecht/)
const du = renderMail('G02', values, { du: true, anfrage_angenommen: true, widerruf: true })
assert.match(du.text, /Dein Termin ist bestätigt/)
assert.match(du.text, /hat deine Terminanfrage angenommen/)
assert.doesNotMatch(du.text, SIE_FORM)
})
test('G03, G04, G05, G06, G07: Inhalt je Mail', () => {
const g03 = renderMail('G03', { ...sampleValues(loadTemplate('G03')), gast_name: 'Erika', betrieb_name: 'Salon Muster', link: 'https://x.example/m' }, { sie: true })
assert.match(g03.text, /Salon Muster prüft sie und meldet sich bei Ihnen/)
assert.match(g03.text, /Anfrage absagen: https:\/\/x\.example\/m/)
const t04 = loadTemplate('G04')
const g04 = renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true })
assert.doesNotMatch(g04.text, /Urlaub/)
assert.match(g04.text, /leider kann Wert-betrieb_name Ihre Terminanfrage nicht annehmen/)
assert.match(renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true, nachricht: true }).text, /Am Montag sind wir leider im Urlaub\./)
const t05 = loadTemplate('G05')
const g05 = renderMail('G05', { ...sampleValues(t05), person: 'Anna', link: 'https://x.example/m' }, { du: true, person: true })
assert.match(g05.text, /Anna/)
assert.match(g05.text, /Termin verwalten: https:\/\/x\.example\/m/)
assert.match(g05.text, /Erinnerung/)
assert.doesNotMatch(renderMail('G05', { ...sampleValues(t05), person: 'Anna' }, { du: true }).text, /Anna/)
const t06 = loadTemplate('G06')
const g06 = renderMail('G06', { ...sampleValues(t06), absage_von: 'dem Betrieb', grund: 'Krankheit im Team' }, { sie: true })
assert.match(g06.text, /Abgesagt von: dem Betrieb/)
assert.doesNotMatch(g06.text, /Krankheit/)
assert.match(renderMail('G06', { ...sampleValues(t06), grund: 'Krankheit im Team' }, { sie: true, nachricht: true }).text, /Krankheit im Team/)
const t07 = loadTemplate('G07')
const g07 = renderMail('G07', { ...sampleValues(t07), alt_datum: '12.10.2026', alt_zeit: '10:00', datum: '13.10.2026', zeit: '11:30' }, { du: true })
assert.match(g07.text, /Bisher: 12\.10\.2026, 10:00 Uhr/)
assert.match(g07.text, /Neu: 13\.10\.2026, 11:30 Uhr/)
})
test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und nach_frist, nichts vom Gast', () => {
const tpl = loadTemplate('G08')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', eingang: '02.10.2026, 10:07 Uhr', nummer: 'AB12CD34' }
const ohne = renderMail('G08', values, { sie: true })
assert.deepEqual(ohne.problems, [])
assert.match(ohne.text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(ohne.text, /Widerruf/)
assert.doesNotMatch(ohne.text, /AB12CD34|bereits abgelaufen/)
const zugeordnet = renderMail('G08', values, { sie: true, zugeordnet: true })
assert.match(zugeordnet.text, /Buchung AB12CD34/)
assert.match(zugeordnet.text, /Der Termin ist abgesagt\./)
assert.doesNotMatch(zugeordnet.text, /bereits abgelaufen/)
const spaet = renderMail('G08', values, { sie: true, nach_frist: true })
assert.match(spaet.text, /Widerrufsfrist war bei Eingang Ihrer Erklärung bereits abgelaufen/)
assert.doesNotMatch(spaet.text, /Der Termin ist abgesagt|AB12CD34/)
// Hinweis für Dritte: wer nichts erklärt hat, kann die Mail ignorieren
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
})
test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fall', () => {
for (const id of ['K20', 'K21']) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const p of ['betreff', 'kunde_name', 'leistung', 'person', 'datum', 'zeit', 'gast_name', 'gast_email', 'gast_telefon', 'gast_nachricht', 'portal_link']) assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
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))
assert.doesNotMatch(mail.text, SIE_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, /Hallo Wert-kunde_name,/, id)
assert.match(mail.html, /<a href="Wert-portal_link"/, id)
assert.doesNotMatch(sourceOf(id), /google|tracking|pixel/i, id)
}
assert.deepEqual([...loadTemplate('K20').blocks], ['anfrage'])
assert.deepEqual([...loadTemplate('K21').blocks].sort(), ['abgesagt', 'nach_frist', 'nicht_zugeordnet', 'verschoben', 'widerrufen'])
for (const p of ['alt_datum', 'alt_zeit', 'eingang']) assert.ok(loadTemplate('K21').placeholders.includes(p), `K21: ${p}`)
const t20 = loadTemplate('K20')
const werte20 = { ...sampleValues(t20), kunde_name: 'Berta', leistung: 'Herrenschnitt', datum: '12.10.2026', zeit: '10:00', gast_name: 'Max <b>Muster</b>', gast_email: 'max@example.com', gast_telefon: '0170 1', gast_nachricht: 'Bitte\nFenster', portal_link: 'https://project.webklar.com/dashboard.html' }
const buchung = renderMail('K20', werte20, {})
assert.deepEqual(buchung.problems, [])
assert.match(buchung.html, /Max &lt;b&gt;Muster&lt;\/b&gt;/)
assert.match(buchung.html, /Bitte<br>Fenster/)
assert.match(buchung.text, /E-Mail: max@example\.com/)
assert.match(buchung.text, /Im Portal ansehen: https:\/\/project\.webklar\.com\/dashboard\.html/)
assert.doesNotMatch(buchung.text, /noch nicht bestätigt/)
assert.match(renderMail('K20', werte20, { anfrage: true }).text, /Diese Anfrage ist noch nicht bestätigt/)
const t21 = loadTemplate('K21')
const werte21 = { ...sampleValues(t21), alt_datum: '11.10.2026', alt_zeit: '09:00', datum: '12.10.2026', zeit: '10:00', eingang: '02.10.2026, 10:07 Uhr' }
const fall = (block) => renderMail('K21', werte21, { [block]: true })
assert.match(fall('abgesagt').text, /hat den Termin abgesagt/)
assert.match(fall('verschoben').text, /Bisher: 11\.10\.2026, 09:00 Uhr/)
assert.match(fall('verschoben').text, /Neu: 12\.10\.2026, 10:00 Uhr/)
assert.match(fall('widerrufen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(fall('widerrufen').text, /widerrufen/)
assert.match(fall('nach_frist').text, /Widerrufsfrist war bei Eingang bereits abgelaufen/)
assert.match(fall('nicht_zugeordnet').text, /keiner Buchung zuordnen/)
// je Fall erscheint nur der eigene Abschnitt
const SATZ = { abgesagt: 'hat den Termin abgesagt', verschoben: 'hat den Termin verschoben', widerrufen: 'hat den Vertrag widerrufen', nach_frist: 'Widerrufsfrist war bei Eingang bereits abgelaufen', nicht_zugeordnet: 'keiner Buchung zuordnen' }
for (const block of Object.keys(SATZ)) {
const mail = fall(block)
assert.deepEqual(mail.problems, [], block)
for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`)
}
})