diff --git a/server/mail-templates/K31 Auftragsbestaetigung.html b/server/mail-templates/K31 Auftragsbestaetigung.html new file mode 100644 index 0000000..c9b20b9 --- /dev/null +++ b/server/mail-templates/K31 Auftragsbestaetigung.html @@ -0,0 +1,70 @@ + + + + + + + + +Ihre Auftragsbestätigung {{nummer}} – {{titel}} + + + + +Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K32 Auftrag bestaetigt.html b/server/mail-templates/K32 Auftrag bestaetigt.html new file mode 100644 index 0000000..45a26b7 --- /dev/null +++ b/server/mail-templates/K32 Auftrag bestaetigt.html @@ -0,0 +1,56 @@ + + + + + + + + +Ihre Bestätigung zu {{nummer}} ist eingegangen + + + + +Wir haben Ihre Bestätigung zu {{nummer}} erhalten. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T22 Auftrag bestaetigt.html b/server/mail-templates/T22 Auftrag bestaetigt.html new file mode 100644 index 0000000..3830cb2 --- /dev/null +++ b/server/mail-templates/T22 Auftrag bestaetigt.html @@ -0,0 +1,55 @@ + + + + + + + + +[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}} + + + + +{{kunde}}: {{nummer}} bestätigt. ͏ ͏ ͏ + + + + + diff --git a/test/auftraegeVorlagen.test.js b/test/auftraegeVorlagen.test.js new file mode 100644 index 0000000..26c6a2a --- /dev/null +++ b/test/auftraegeVorlagen.test.js @@ -0,0 +1,108 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { config } from '../server/config.js' +import { loadTemplate, renderMail } from '../server/services/mailTemplates.js' +import { euroDe } from '../server/services/finance/format.js' + +// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen +// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js. +const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen +const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu +const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u +const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true])) +const pruefeZeilen = (mail, erwartet) => { + const zeilen = mail.text.split('\n') + for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`) +} + +const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' } +const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' } +const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' } + +test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => { + const erwartet = { + K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] }, + K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] }, + T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] }, + } + for (const [id, soll] of Object.entries(erwartet)) { + const vorlage = loadTemplate(id) + assert.equal(vorlage.file, soll.file, id) + assert.equal(vorlage.subject, soll.subject, id) + assert.deepEqual(vorlage.placeholders, soll.placeholders, id) + assert.deepEqual(vorlage.blocks, soll.blocks, id) + } +}) + +test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => { + const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei') + pruefeZeilen(mail, [ + 'Guten Tag Erika Muster,', + 'Auftrag: AB-2026-0001 vom 06.10.2026', + 'Projekt: Website für Musterbäckerei', + 'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)', + 'Bestätigung möglich bis: 05.12.2026', + 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN', + 'Viele Grüße', + 'Ihr WEBklar-Team', + ]) + assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.')) + assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;')) + assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`)) + assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/) +}) + +test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => { + const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true }) + assert.deepEqual(mail.problems, []) + pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN']) + assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.')) + assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/) +}) + +test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => { + const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true }) + assert.deepEqual(mail.problems, []) + pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026']) + assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.')) + assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/) + assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/) +}) + +test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => { + const ja = renderMail('K32', K32, { fruehbeginn: true }) + assert.deepEqual(ja.problems, []) + assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen') + pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team']) + assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.')) + assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/) + const nein = renderMail('K32', K32, { ohne_fruehbeginn: true }) + assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) + assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/) + assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/) +}) + +test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => { + const ja = renderMail('T22', T22, { privat_fruehbeginn: true }) + assert.deepEqual(ja.problems, []) + assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') + pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices']) + assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).')) + assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/) + const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true }) + assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.')) + assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/) +}) + +test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => { + for (const [id, werte] of [['K31', K31], ['K32', K32]]) { + const mail = renderMail(id, werte, alle(id)) + assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`) + assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`) + } + const mail = renderMail('K31', { ...K31, titel: 'Café &"' }, { festpreis: true }) + assert.match(mail.html, /Café <b>&"/) + assert.doesNotMatch(mail.html, /&/) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 9fe42ff..7d0851b 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); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// 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); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge]) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)] +const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)] // GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'] @@ -27,7 +27,7 @@ for (const id of EXPECTED) { const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Aufträge] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { }) // Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem -const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21'] +const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22'] test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => { for (const id of EXPECTED) {