# Bestätigung und Mails – Umsetzungsplan (Strang L5) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ein Kunde bestätigt eine Auftragsbestätigung (AB) per Link ohne Anmeldung oder im Portal; die Bestätigung wird einmalig protokolliert und löst K32 (Kunde) und T22 (Team) aus. K31 verschickt die AB mit PDF und AGB (der Bestätigungslink geht nur an den Kunden, das Team bekommt eine Kopie ohne Link); eine Privatperson kann einen Projektauftrag über die Widerrufsseite widerrufen. **Architecture:** Drei neue Bausteine in `server/services/auftraege/` (`token.js`, `mails.js`, `bestaetigung.js`), zwei gefüllte Router-Gerüste aus L0 (`server/routes/auftrag.js` öffentlich per Token, `server/routes/auftraegeKunde.js` für angemeldete Kunden) und eine Erweiterung der öffentlichen Widerrufsroute in `server/routes/abo.js` um `order = 'projekt'` (mit je einem neuen Block `projekt` in K04 und T09). Bestätigen und Widerrufen laufen unter der Sperre `sperreAuftrag(id)` aus L0 und lesen den Stand innerhalb der Sperre neu; die Mails folgen erst nach der Freigabe. Die Routen sind dünn (Token prüfen, Schalter, Status, Antwort). **Tech Stack:** Node 20.19 (Container) und 24 (Host), Express 4, ES-Module, nodemailer über `sendCustomerMail`/`sendAdminNotice` mit HTML-Vorlagen (`renderMail`), Tests mit `node --test` und den Nachbauten aus `test/support/`. **Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitte 2 „Versand“, „Bestätigung“, „Widerruf“, 6, 7, 9). **Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V2, V3, V4 „L5“, V5, V6 und der Abschnitt „Nachträge nach der Planrunde“ gelten wörtlich). Beide Dateien liegen im Basis-Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/` (die Spec ist committet, Pläne sind dort ggf. unversioniert); der Lane-Worktree ab Task 1 enthält sie evtl. nicht, dann von dort lesen. ## Global Constraints Alle Punkte der Übersicht gelten. Zusätzlich für diesen Strang: - Nur die Dateien aus „Dateien“ ändern. `public/widerrufen.html`, `server/index.js`, `server/services/mailer.js`, `test/support/*` und `kundenansichtGuard.js` bleiben unberührt; K04 und T09 bekommen nur den Block `projekt` (Task 11). - `token.js` importiert nur `node:crypto` und `./konstanten.js`, nie `server/config.js` (die Testhilfe lädt es vor der Testumgebung). - Tests nach dem Rezept „kundenportal-testing“: Nachbauten starten, `useTestEnv()`, danach alle Server-Module **dynamisch** importieren. `AUFTRAEGE_ENABLED=on` und `AUFTRAEGE_UPLOAD_DIR` (Temp-Verzeichnis) werden vor dem ersten Import von `config.js` gesetzt; den Schalter im Test ändern über `config.auftraege.mode` und `config.auftraege.pilotEmails`; die Uhr der öffentlichen Routen über `setzeUhrFuerTests`; verschiedene IP-Adressen über den Kopf `x-forwarded-for` (die Test-App setzt `trust proxy` auf 1). - Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (das Schreibwerkzeug macht daraus das Zeichen selbst), stattdessen `\u{a0}` mit dem Flag `u` oder `String.fromCodePoint`. - Mailtexte der Kunden in Sie-Form. In den Vorlagen keine HTML-Entities außer `→`, ` ` und `✓` (die Textfassung kennt nur diese); „ “ – € § als echte Zeichen. Beträge über `euroDe`, Tage über `berlinDate` + `formatDe`, Uhrzeiten in Berliner Zeit als `TT.MM.JJJJ, HH:MM`. - Öffentliche Routen antworten nur mit den festen Texten aus V5 (404, 410, 429) und eigenen deutschen Texten, nie mit Rohtexten von Fehlern; alle Antworten `Cache-Control: no-store`. Token und IP-Adressen stehen nie in Datenbank oder Protokoll (Token nur als Hash). - **Der Bestätigungslink steht nur in der Mail an den Kunden:** K31 geht ohne BCC (`bccTeam: false`) an den Kunden; das Team bekommt über `sendAdminNotice` eine Kopie aus derselben Vorlage mit dem Block `teamkopie`, ohne Knopf, ohne Link, ohne Token und ohne Anhänge. Der Link hat die Form `/auftrag.html#t=` (Fragment, nie `?t=`). - Bestätigen und Widerrufen: Sperre `sperreAuftrag(id)` aus `konstanten.js` (V2: ein zusammengesetzter Schlüssel, `withLocks`), Stand innerhalb der Sperre neu lesen; nie `sperreRechnung` innerhalb von `sperreAuftrag`. K32 und T22 (bzw. K04 und T09 beim Widerruf) erst nach Freigabe der Sperre. Mailfehler dort nie als Fehler an den Kunden weitergeben (nur `console.error` mit der AB-Nummer); `sendeAuftragMail` dagegen wirft (L3 setzt `mailStatus`), nur eine gescheiterte Teamkopie gilt nicht als Fehler. - Bestätigung und Widerruf berühren keine anderen Felder als die in V2/V3 genannten. - Shell: `grep` ist hier eine Funktion, die Dateien still auslässt; immer `command grep -a` benutzen. Nie `pkill -f`. ## Review Focus 1. **Alte oder ungültige Links und Link-Hygiene** (Erinnerung mit neuem Link, ersetzte/stornierte/widerrufene AB, abgelaufen, Müll im Token, Schalter aus; der Link steht nie in einer Mail ans Team): der alte Link bestätigt nie etwas; 404 „Dieser Link ist ungültig oder abgelaufen.“ bzw. 410 mit dem Grund. Tests: Task 7 (`ungültige, unbekannte und abgelaufene Links`, `Erinnerung mit neuem Link`, `Schalter`, `ersetzte, stornierte und widerrufene AB`), Task 8 (`POST mit unbekanntem, falschem, altem oder abgelaufenem Link`), Task 4 (`K31 an den Kunden ohne BCC`, `Teamkopie`), Task 12 (`nach dem Widerruf bestätigt der Link nichts mehr`). 2. **Doppelklick, Link und Portal gleichzeitig**: genau eine Bestätigung zählt (die erste), K32 und T22 genau einmal, beide erst nach Freigabe der Sperre. Tests: Task 5 (`zwei gleichzeitige Bestätigungen`, `die zweite Bestätigung ändert nichts`, `unter der Sperre`), Task 6 (`Bestätigen und Widerrufen gleichzeitig`), Task 8 (`zweite und gleichzeitige Bestätigungen`). 3. **Ungültige Eingaben beim Bestätigen** (`bestaetigt` fehlt oder ist nicht `true`, `fruehbeginn` als Text oder Zahl, Frühbeginn bei Unternehmen): 400 ohne Änderung; Frühbeginn zählt nur bei Privatpersonen und nur als `true`. Tests: Task 8 (`ohne bestaetigt: true`, `fruehbeginn zählt nur bei Privatpersonen`), Task 5 (`Unternehmen – fruehbeginn wird ignoriert`). 4. **Widerruf eines Projektauftrags ohne oder mit falscher Nummer, mit fremder E-Mail-Adresse, als Unternehmen, nach der Frist, doppelt oder bei Datenbankfehler**: immer dieselbe Antwort `{ ok: true, receivedAt }`, K04 und T09 gehen raus, die AB ändert sich nur bei Privatperson in der Frist; T09 nennt den Grund. Tests: Task 12, Task 6, Task 11. 5. **Fremde ABs und Entwürfe im Portal, Kundenansicht**: ein Kunde sieht und bestätigt nur eigene gesendete ABs (sonst 404); in der Kundenansicht ist Bestätigen gesperrt, auch über den öffentlichen Link. Tests: Task 9 (`nur eigene, nur gesendete ABs`, `kannBestaetigen wie in V3`, `Schalter aus`, `fremde, Entwurf, unbekannte AB 404`), Task 10. ## Dateien | Datei | Aufgabe | |---|---| | `server/services/auftraege/token.js` (neu) | `neuerToken`, `hashToken` | | `server/services/auftraege/mails.js` (neu) | K31/K32/T22 aufbauen und senden, Teamkopie, AGB-Anhang | | `server/services/auftraege/bestaetigung.js` (neu) | `bestaetigeAuftrag`, `widerrufeAuftrag`, Zustandsregeln | | `server/mail-templates/K31 Auftragsbestaetigung.html`, `K32 Auftrag bestaetigt.html`, `T22 Auftrag bestaetigt.html` (neu) | Vorlagen | | `server/mail-templates/K04 Eingang Widerruf.html`, `T09 Widerruf eingegangen.html` (ändern) | nur der Block `projekt` | | `server/routes/auftrag.js` (Gerüst aus L0 füllen) | öffentliche Link-Routen | | `server/routes/auftraegeKunde.js` (Gerüst aus L0 füllen) | Portal-Routen des Kunden | | `server/routes/abo.js` (ändern) | Widerruf `order = 'projekt'` | | `test/mailTemplates.test.js` (ändern) | Vorlagenliste, Betreff-Präfix `Aufträge`, Team-Fuß, Block-Listen von K04 und T09 | | `test/auftraegeToken.test.js`, `auftraegeVorlagen.test.js`, `auftraegeMails.test.js`, `auftraegeBestaetigung.test.js`, `auftraegeTokenRoutes.test.js`, `auftraegeKundeRoutes.test.js`, `auftraegeKundenansicht.test.js`, `auftraegeWiderruf.test.js` (neu) | Tests | | `test/auftraegeBestaetigungHilfe.js` (neu, kein `.test.js`) | Testdaten: gesendete AB, Token, Schema des Nachbaus | --- ### Task 1: Worktree einrichten und L0 prüfen **Files:** keine Repository-Dateien; legt den Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5` an. **Interfaces:** - Consumes: Branch `feature/auftraege` mit dem fertigen Strang L0 (Dateien und Namen aus V1–V4). - Produces: Worktree `dev-auftraege-l5`, Branch `auftraege/l5-bestaetigung`, Symlink `node_modules`. - [ ] **Step 1: L0 ist im Branch** ```bash git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege ls-tree -r --name-only feature/auftraege | command grep -a -E '^server/services/auftraege/|^server/routes/(auftrag|auftraegeKunde)\.js$' | sort ``` Erwartet: mindestens `ablage.js`, `arbeitsblaetter.js`, `auftragSicht.js`, `auftragStore.js`, `fehler.js`, `konstanten.js`, `projektRechnungenStore.js`, `schalter.js`, `schema.js`, `texte.js` unter `server/services/auftraege/` sowie `server/routes/auftrag.js` und `server/routes/auftraegeKunde.js`. Fehlt etwas, den Strang anhalten und melden. - [ ] **Step 2: Worktree anlegen** ```bash git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l5 -b auftraege/l5-bestaetigung feature/auftraege cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && ln -s ../dev-auftraege/node_modules node_modules && git status --short ``` Erwartet: nur `?? node_modules` (der Symlink bleibt unversioniert; nie `git add -A`). Existiert `../dev-auftraege/node_modules` noch nicht, fehlt L0 etwas: anhalten. - [ ] **Step 3: Namen aus L0 sind da** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && node --max-old-space-size=512 --input-type=module -e " const erwartet = { auftragStore: ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer', 'docToAuftrag', 'auftragToDoc'], auftragSicht: ['oeffentlicheAnsicht', 'fruehesterBeginn'], ablage: ['speicherePdf', 'lesePdf'], schalter: ['auftraegeAktiv'], fehler: ['AuftragError', 'sendeFehler'], konstanten: ['TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'sperreAuftrag'], texte: ['FRUEHBEGINN_FASSUNG', 'FRUEHBEGINN_TEXT', 'BESTAETIGUNG_TEXT'], schema: ['AUFTRAEGE_COLLECTIONS'], } let fehlt = 0 for (const [datei, namen] of Object.entries(erwartet)) { const modul = await import('./server/services/auftraege/' + datei + '.js') for (const name of namen) if (!(name in modul)) { console.log('FEHLT', datei, name); fehlt += 1 } } const { config } = await import('./server/config.js') if (!config.auftraege || !config.auftraege.uploadDir) { console.log('FEHLT config.auftraege'); fehlt += 1 } const { sperreAuftrag } = await import('./server/services/auftraege/konstanten.js') if (JSON.stringify(sperreAuftrag('x1')) !== JSON.stringify(['auftraege-ab:x1'])) { console.log('FALSCH sperreAuftrag (V2: ein zusammengesetzter Schlüssel)'); fehlt += 1 } console.log(fehlt ? 'L0 UNVOLLSTÄNDIG' : 'L0 ok') " ``` Erwartet: `L0 ok`. - [ ] **Step 4: Reine Datenmodule und Einbindung** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 command grep -a -n "^import" server/services/auftraege/konstanten.js server/services/auftraege/schema.js command grep -a -n "auftrag" server/index.js ``` Erwartet: die erste Abfrage liefert **keine** Zeile (sonst lädt die Testhilfe `config.js` zu früh: anhalten und melden); die zweite zeigt u. a. `app.use('/api/auftrag', …)` und `app.use('/api/auftraege', …)`. - [ ] **Step 5: Ausgangslage grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/mailTemplates.test.js` Expected: `# fail 0`. Kein Commit (nichts zu committen). --- ### Task 2: Bestätigungs-Token (`token.js`) **Files:** - Create: `server/services/auftraege/token.js` - Test: `test/auftraegeToken.test.js` **Interfaces:** - Consumes: `TOKEN_GUELTIG_TAGE` (60) aus `server/services/auftraege/konstanten.js`. - Produces: `neuerToken(jetzt = new Date()) → { token: string, hash: string, gueltigBis: Date }` und `hashToken(token) → string | null` (V4 „L5“). - [ ] **Step 1: Failing Test** `test/auftraegeToken.test.js` ```js import { test } from 'node:test' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { hashToken, neuerToken } from '../server/services/auftraege/token.js' const TAG_MS = 24 * 60 * 60 * 1000 test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => { const jetzt = new Date('2026-10-06T10:00:00.000Z') const { token, hash, gueltigBis } = neuerToken(jetzt) assert.match(token, /^[A-Za-z0-9_-]{43}$/) assert.equal(hash, createHash('sha256').update(token).digest('hex')) assert.ok(gueltigBis instanceof Date) assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z') assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert') }) test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => { const vorher = Date.now() const a = neuerToken() const b = neuerToken() const nachher = Date.now() assert.notEqual(a.token, b.token) assert.notEqual(a.hash, b.hash) assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS) assert.throws(() => neuerToken(new Date('kein Datum')), TypeError) assert.throws(() => neuerToken('2026-10-06'), TypeError) }) test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => { const { token, hash } = neuerToken() assert.equal(hashToken(token), hash) const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`] for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert)) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeToken.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` (`token.js` fehlt). - [ ] **Step 3: Implementieren** `server/services/auftraege/token.js` Imports: nur `createHash`, `randomBytes` aus `node:crypto` und `TOKEN_GUELTIG_TAGE` aus `./konstanten.js`. - `neuerToken(jetzt = new Date())`: `jetzt` muss ein `Date` mit endlichem Zeitwert sein, sonst `TypeError`. `token = randomBytes(32).toString('base64url')`, `hash` = SHA-256 des Tokentextes in Hex (64 Zeichen), `gueltigBis = new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * 86_400_000)` (reine UTC-Millisekunden, kein Kalenderrechnen). - `hashToken(token)`: nur ein Text, der genau `^[A-Za-z0-9_-]{43}$` entspricht, ergibt den Hash, alles andere `null`. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# pass 3`, `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/services/auftraege/token.js test/auftraegeToken.test.js git commit -m "Aufträge L5: Bestätigungs-Token (32 Zufallsbytes, nur als Hash gespeichert, 60 Tage gültig)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Mailvorlagen K31, K32, T22 **Files:** - Create: `server/mail-templates/K31 Auftragsbestaetigung.html`, `server/mail-templates/K32 Auftrag bestaetigt.html`, `server/mail-templates/T22 Auftrag bestaetigt.html` - Modify: `test/mailTemplates.test.js` (Vorlagenliste, Betreff-Präfix, Team-Fuß, Kommentar) - Test: `test/auftraegeVorlagen.test.js` **Interfaces:** - Consumes: `renderMail(id, values, blocks)`, `loadTemplate(id)` aus `server/services/mailTemplates.js` (unverändert). - Produces: Vorlagen **K31** (Platzhalter `name, nummer, titel, datum, betrag, link, gueltig_bis`; Blöcke `erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail`), **K32** (`name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text`; Blöcke `fruehbeginn, ohne_fruehbeginn`), **T22** (`kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn`; Blöcke `privat_fruehbeginn, privat_ohne_fruehbeginn`), Betreffe wie V6. `kundenmail` ist der Gegenblock zu `teamkopie`: die Vorlagen kennen nur positive Blöcke, „statt Knopf“ braucht also einen Block für die Kundenmail (Vertragsfrage 12). - [ ] **Step 1: Failing Tests** (a) `test/auftraegeVorlagen.test.js`: ```js 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, /&/) }) ``` (b) `test/mailTemplates.test.js`, vier Änderungen (mit dem Edit-Werkzeug; Zeilen nach den Texten suchen): 1. Den langen Kommentar über `const ids = …` am Ende um `; K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge])` ergänzen. 2. `const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]` → `const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)]`. 3. In der Prüfung „Kopfkommentar passt zur Vorlage“ die Präfix-Gruppe `(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)` um `|Aufträge` erweitern und im Meldungstext „oder [Webshop]“ durch „, [Webshop] oder [Aufträge]“ ersetzen. **Keine** Ausnahme für T22: die Vorlage trägt das Präfix. 4. `const TEAM_FOOTER = [… 'T19', 'T20', 'T21']` → `… 'T19', 'T20', 'T21', 'T22']`. - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeVorlagen.test.js test/mailTemplates.test.js` Expected: FAIL (`Mail-Vorlage K31 fehlt`; `alle Vorlagen sind da` meldet K31, K32, T22 als fehlend). - [ ] **Step 3: Vorlagen schreiben** Ausgangsdateien kopieren und nur das unten Genannte ändern: `cp "server/mail-templates/K16 Funktion bereit.html" "server/mail-templates/K31 Auftragsbestaetigung.html"`, ebenso K32; `cp "server/mail-templates/T16 Funktion bestellt.html" "server/mail-templates/T22 Auftrag bestaetigt.html"`. In jeder Datei ersetzen: (a) den Kopfkommentar durch den unten genannten (Format wie bei K15: nach `…`). Zeilenmuster (jeweils aus den vorhandenen Dateien übernehmen, nur Texte ändern): - **Pill grün** = erste Zeile der Karte in K16; **Pill grau** = erste Zeile der Karte in T16. - **Überschrift** = zweite Zeile in K16; **Absatz** = dritte Zeile in K16 (`Hallo …,
…` wird zu `Guten Tag {{name}},
…`). - **Tabelle** = vierte Zeile in K16; Tabellenzeilen wie dort (Rahmenlinie unten), die letzte Zeile ohne `border-bottom`. - **Kasten** = die Zeile „Wie geht es weiter?“ in K15 (Titel in `div.ink`, Text in `div.mut`). - **Knopf** = Knopfzeile in K16 (`href` und Beschriftung ersetzen; die Beschriftung endet mit ` →`). - **Gruß** = letzte Zeile der Karte in K04 (Sie-Form). **K31** · Kopfkommentar: ``` ``` `` = Betreff. Vorschautext: `Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}.` Wort rechts oben: `Auftrag`. Karte, in dieser Reihenfolge: 1. Pill grau: `Auftragsbestätigung` 2. Überschrift: `Ihre Auftragsbestätigung {{nummer}}` 3. Absatz: `Guten Tag {{name}},<br>vielen Dank für Ihren Auftrag. Anbei erhalten Sie die Auftragsbestätigung {{nummer}} zum Projekt „{{titel}}“. Bitte prüfen Sie sie und bestätigen Sie den Auftrag; eine Anmeldung ist nicht nötig.` 4. Block `erinnerung`: Kasten, Titel `Erinnerung`, Text `Wir haben Ihnen diese Auftragsbestätigung am {{datum}} schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.` 5. Tabelle mit den Zeilen: `Auftrag` | `{{nummer}} vom {{datum}}`; `Projekt` | `{{titel}}`; Block `festpreis`: `Gesamtpreis` | `<strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>`; Block `aufwand`: `Vergütung` | `<strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)</span>`; letzte Zeile `Bestätigung möglich bis` | `{{gueltig_bis}}`. 6. Block `kundenmail`: Knopf, `href="{{link}}"`, Beschriftung `Auftrag bestätigen →` 7. Block `teamkopie`: eine Zeile im Muster **Absatz** (aber mit `padding:28px 40px 0 40px`), Text `Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.` 8. Block `kundenmail`: Kasten, Titel `Anhänge`, Text `Die Auftragsbestätigung ({{nummer}}.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail. Bitte bewahren Sie beide auf. Den Auftrag können Sie auch in Ihrem Kundenportal ansehen und bestätigen.` 9. Block `privat`: Kasten, Titel `Ihr Widerrufsrecht`, Text `Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung; widerrufen können Sie auch über das <a href="https://project.webklar.com/widerrufen.html" style="color:#111111;">Widerrufsformular auf unserer Website</a>. Mit der Arbeit beginnen wir erst nach Ablauf der Widerrufsfrist, es sei denn, Sie verlangen beim Bestätigen ausdrücklich einen früheren Beginn.` 10. Gruß: `Bei Fragen antworten Sie einfach auf diese E-Mail.<br>Viele Grüße<br><strong>Ihr WEBklar-Team</strong>` **K32** · Kopfkommentar: ``` <!-- K32 · Auftrag bestätigt (Eingangsbestätigung der Bestätigung; Variante Privatperson mit oder ohne früheren Beginn) Betreff: Ihre Bestätigung zu {{nummer}} ist eingegangen Absender: „WEBklar“ <Portal-Postfach> Empfänger: Kunde (E-Mail der Auftragsbestätigung) · BCC intern Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{fruehester_beginn}}, {{fruehbeginn_text}} Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): fruehbeginn, ohne_fruehbeginn zeitpunkt = Zeitpunkt der Bestätigung (TT.MM.JJJJ, HH:MM, Berliner Zeit). fruehester_beginn = frühester Arbeitsbeginn ohne ausdrücklichen Wunsch (TT.MM.JJJJ, Tag nach Ablauf der Widerrufsfrist), nur im Block ohne_fruehbeginn. fruehbeginn = die Privatperson hat ausdrücklich verlangt, dass die Arbeit schon vor Ablauf der Widerrufsfrist beginnt; fruehbeginn_text = Wortlaut ihrer Erklärung (FRUEHBEGINN_TEXT aus server/services/auftraege/texte.js), nur in diesem Block, ohne weitere Sätze. ohne_fruehbeginn = sie hat das nicht verlangt. Bei Unternehmen ist keiner der beiden Blöcke an. --> ``` Vorschautext: `Wir haben Ihre Bestätigung zu {{nummer}} erhalten.` Wort rechts oben: `Bestätigung`. Karte: 1. Pill grün: `✓  Auftrag bestätigt` 2. Überschrift: `Vielen Dank für Ihre Bestätigung` 3. Absatz: `Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf.` 4. Tabelle: `Auftrag` | `{{nummer}}`; `Projekt` | `{{titel}}`; letzte Zeile `Bestätigt am` | `{{zeitpunkt}} Uhr` 5. Block `fruehbeginn`: Kasten, Titel `Ihre Erklärung zum früheren Beginn`, Text `{{fruehbeginn_text}}` (nichts sonst: keine Einleitung, kein Satz zum Widerrufsrecht) 6. Block `ohne_fruehbeginn`: Kasten, Titel `Beginn der Arbeiten`, Text `Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit.` 7. Gruß: `Viele Grüße<br><strong>Ihr WEBklar-Team</strong>` **T22** · Kopfkommentar: ``` <!-- T22 · Auftrag bestätigt (Hinweis ans Team) Betreff: [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}} Absender: „WEBklar Kundenportal“ <Portal-Postfach> Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{kunde}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{ueber}}, {{woms_link}}, {{fruehester_beginn}} Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): privat_fruehbeginn, privat_ohne_fruehbeginn kunde = Firma, sonst Name des Kunden. ueber = Weg der Bestätigung (Bestätigungslink oder Kundenportal). zeitpunkt = TT.MM.JJJJ, HH:MM, Berliner Zeit. woms_link = Aufträge und Rechnungen des Kunden im Ticketsystem. privat_fruehbeginn = Privatperson, früherer Beginn ausdrücklich verlangt (die Arbeit darf sofort starten). privat_ohne_fruehbeginn = Privatperson ohne diesen Wunsch (Arbeitsbeginn erst ab fruehester_beginn, TT.MM.JJJJ). Bei Unternehmen ist keiner der beiden Blöcke an. --> ``` `<title>` = Betreff. Vorschautext: `{{kunde}}: {{nummer}} bestätigt.` Wort rechts oben: `Intern · Kundenportal`. Karte: 1. Pill grau: `Aufträge` 2. Überschrift: `Auftrag bestätigt` 3. Tabelle: `Kunde` | `{{kunde}}`; `Auftrag` | `{{nummer}}`; `Projekt` | `{{titel}}`; `Bestätigt am` | `{{zeitpunkt}} Uhr`; letzte Zeile `Weg` | `{{ueber}}` 4. Block `privat_fruehbeginn`: Kasten, Titel `Privatperson: früher Beginn verlangt`, Text `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).` 5. Block `privat_ohne_fruehbeginn`: Kasten, Titel `Privatperson: kein früher Beginn`, Text `Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist.` 6. Knopf: `href="{{woms_link}}"`, Beschriftung `Im Ticketsystem öffnen →`, danach die Leerzeile `<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>` wie am Ende von T16 (statt der Gruß-Zeile). - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0` (alle Vorlagentests inklusive der neuen Ids K31, K32, T22). - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add "server/mail-templates/K31 Auftragsbestaetigung.html" "server/mail-templates/K32 Auftrag bestaetigt.html" "server/mail-templates/T22 Auftrag bestaetigt.html" test/auftraegeVorlagen.test.js test/mailTemplates.test.js git commit -m "Aufträge L5: Mailvorlagen K31 Auftragsbestätigung (mit Teamkopie), K32 Auftrag bestätigt, T22 Hinweis ans Team" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 4: Mails (`mails.js`) **Files:** - Create: `server/services/auftraege/mails.js`, `test/auftraegeBestaetigungHilfe.js` - Test: `test/auftraegeMails.test.js` **Interfaces:** - Consumes: `sendCustomerMail(to, { template, values, blocks, attachments, bccTeam })` und `sendAdminNotice({ template, values, blocks }) → Promise<{ sent, reason? }>` (wirft nie; `server/services/mailer.js`, unverändert); L0: `fruehesterBeginn(auftrag) → 'YYYY-MM-DD' | null` (`auftragSicht.js`), `lesePdf(pdfDatei) → Promise<Buffer>` (`ablage.js`), `AGB_FASSUNG` (`konstanten.js`), `FRUEHBEGINN_TEXT` (`texte.js`); `hashToken` (Task 2); `euroDe` (`finance/format.js`), `berlinDate`, `formatDe` (`finance/dates.js`); `config.publicBaseUrl`, `config.womsBaseUrl`. Die `auftrag`-Objekte haben die Form „Auftrag“ aus V3; L3 übergibt das **gespeicherte** Dokument (mit `nummer`, `gesendetAm`, `gesendetAn`, `tokenGueltigBis`, `agbFassung`). - Produces: `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void>` (wirft bei Fehler der Kundenmail) und `sendeBestaetigtMails(auftrag) → Promise<void>` (wirft nie), beide laut V4; dazu `k31Mail(auftrag, { token, erinnerung = false, teamkopie = false })`, `k32Mail(auftrag)`, `t22Mail(auftrag)` → `{ template, values, blocks }`, `agbPdfPfad(fassung = AGB_FASSUNG) → string`, `setzeAgbVerzeichnisFuerTests(verzeichnis | null)`. - [ ] **Step 1: Failing Tests** (a) Testhilfe `test/auftraegeBestaetigungHilfe.js` (reine Daten, kein `config.js`, wird von `npm test` nicht als Test gestartet): ```js import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js' import { hashToken } from '../server/services/auftraege/token.js' // Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau. // Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv). const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) } export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) } export const TOKEN = 'T'.repeat(43) export const TOKEN_NEU = 'U'.repeat(43) export const SHA = 'a'.repeat(64) export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.' export const kunde = (kundenart = 'privat', extra = {}) => ({ name: 'Erika Muster', firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'k@example.invalid', kundenart, kundennummer: 'K-100', ...extra, }) /** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */ export const abDokument = (over = {}) => ({ id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet', kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [], art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }], stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA, gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '', tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...over, }) ``` (b) `test/auftraegeMails.test.js`: ```js import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { mkdtempSync, writeFileSync } from 'node:fs' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort. process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-')) useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') const { euroDe } = await import('../server/services/finance/format.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js') const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') const { speicherePdf } = await import('../server/services/auftraege/ablage.js') const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js') const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-')) const AB_PDF = Buffer.from('%PDF-1.4 AB-Test') const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test') writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF) setzeAgbVerzeichnisFuerTests(agbVerzeichnis) after(() => { setzeAgbVerzeichnisFuerTests(null) setMailTransportForTests(null) }) let sink let protokoll beforeEach(() => { sink = mailSink() setMailTransportForTests(sink) protokoll = mock.method(console, 'error', () => {}) }) afterEach(() => mock.restoreAll()) const adressen = (liste) => liste.map((a) => a.address) const AB = abDokument() const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') } const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over }) const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei' const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' } test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => { setzeAgbVerzeichnisFuerTests(null) try { const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url)) assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`)) assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf')) for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung) } finally { setzeAgbVerzeichnisFuerTests(agbVerzeichnis) } }) test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => { assert.deepEqual(k31Mail(AB, { token: TOKEN }), { template: 'K31', values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` }, blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true }, }) // auch wenn man das Token mitgibt: die Teamkopie enthält es nie assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), { template: 'K31', values: { ...WERTE_K31, link: '' }, blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false }, }) const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true }) assert.equal(nachts.values.datum, '07.10.2026') assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`) assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true }) }) test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => { const ja = BESTAETIGT({ fruehbeginn: true }) assert.deepEqual(k32Mail(ja), { template: 'K32', values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT }, blocks: { fruehbeginn: true, ohne_fruehbeginn: false }, }) const nein = BESTAETIGT({ bestaetigtUeber: 'portal' }) assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026') assert.equal(k32Mail(nein).values.fruehbeginn_text, '') assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true }) const firma = BESTAETIGT({ kunde: kunde('unternehmen') }) assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false }) assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', '']) assert.deepEqual(t22Mail(nein), { template: 'T22', values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' }, blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true }, }) assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH') assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink') assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false }) }) test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => { const nachrichten = [ k31Mail(AB, { token: TOKEN }), k31Mail(AB, { token: TOKEN, teamkopie: true }), k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }), k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }), k32Mail(BESTAETIGT({ fruehbeginn: true })), k32Mail(BESTAETIGT()), t22Mail(BESTAETIGT({ fruehbeginn: true })), t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })), ] for (const { template, values, blocks } of nachrichten) { const vorlage = loadTemplate(template) assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template) assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template) assert.deepEqual(renderMail(template, values, blocks).problems, [], template) } }) test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => { await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) assert.equal(sink.sent.length, 2) const [kundenmail, teamkopie] = sink.sent assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid']) assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink') assert.equal(kundenmail.subject, K31_BETREFF) assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`)) assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']]) assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF)) assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF)) assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails) assert.equal(teamkopie.subject, K31_BETREFF) assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.')) assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem') const geheim = JSON.stringify(teamkopie) for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`) }) test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => { const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF) await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true }) assert.equal(sink.sent.length, 2) for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt')) assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF)) assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN)) }) test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => { await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/) await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError) await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/) assert.equal(sink.sent.length, 0) let versuche = 0 setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } }) await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/) assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie') }) test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => { let aufrufe = 0 setMailTransportForTests({ async sendMail(mail) { aufrufe += 1 if (aufrufe === 2) throw new Error('SMTP 451') return sink.sendMail(mail) }, }) await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) assert.equal(aufrufe, 2) assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']]) assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001'))) }) test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => { await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true })) assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF]) const [k32, t22] = sink.sent assert.deepEqual(adressen(k32.to), ['k@example.invalid']) assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails) assert.deepEqual(adressen(t22.to), config.chatNotify.emails) assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr')) assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`)) }) test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => { let aufrufe = 0 setMailTransportForTests({ async sendMail(mail) { aufrufe += 1 if (aufrufe === 1) throw new Error('SMTP 451') return sink.sendMail(mail) }, }) await sendeBestaetigtMails(BESTAETIGT()) assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001'))) sink.sent.length = 0 setMailTransportForTests(sink) await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) })) assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeMails.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` (`mails.js` fehlt). - [ ] **Step 3: Implementieren** `server/services/auftraege/mails.js` Nicht exportierte Hilfen: - `tagText(iso)`: `formatDe(berlinDate(new Date(iso)))`; fehlt der Zeitpunkt oder ist er ungültig, `TypeError`. `zeitpunktText(iso)`: `<tagText>, HH:MM` mit `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })`. - `preisText(auftrag)`: Festpreis `euroDe(summeCents)`, Aufwand `` `${euroDe(stundensatzCents)} pro Stunde` ``. - `empfaenger(auftrag)`: `String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()`. Builder (rein; die Werte und Blöcke genau wie im Test; `kunde = auftrag.kunde ?? {}`, `privat = kunde.kundenart === 'privat'`): - `k31Mail(auftrag, { token, erinnerung = false, teamkopie = false })`: `name = kunde.name`, `nummer`, `titel`, `datum = tagText(gesendetAm)`, `betrag = preisText`, `link = teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`` (das Token wird bei der Teamkopie nie benutzt), `gueltig_bis = tagText(tokenGueltigBis)`; Blöcke `erinnerung`, `privat: privat && !teamkopie`, `festpreis`/`aufwand` (nach `art`), `teamkopie`, `kundenmail: !teamkopie`. - `k32Mail`: `zeitpunkt = zeitpunktText(bestaetigtAm)`; `fruehester_beginn` = `fruehesterBeginn(auftrag)` als `formatDe`, sonst `''`; `fruehbeginn = privat && auftrag.fruehbeginn`, `ohne_fruehbeginn = privat && !auftrag.fruehbeginn`; `fruehbeginn_text = fruehbeginn ? FRUEHBEGINN_TEXT : ''` (aus `./texte.js`). - `t22Mail`: Wert `kunde` = Firma, sonst Name, sonst E-Mail des Kunden-Snapshots; `ueber` = `{ link: 'Bestätigungslink', portal: 'Kundenportal' }[auftrag.bestaetigtUeber] ?? ''`; `woms_link = ${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`; übrige Werte und Blöcke wie K32 (`privat_fruehbeginn`, `privat_ohne_fruehbeginn`). AGB-Pfad: Modul-Variable für das Verzeichnis (Vorgabe `<Repo>/public/rechtliches`, ermittelt aus `import.meta.url` mit drei Ebenen nach oben: `server/services/auftraege/` → Repo); `setzeAgbVerzeichnisFuerTests(verzeichnis | null)`; `agbPdfPfad(fassung = AGB_FASSUNG)` wirft `TypeError`, wenn `fassung` nicht `^[A-Za-z0-9._-]{1,20}$` entspricht, sonst `path.join(verzeichnis, `agb-${fassung}.pdf`)`. `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false })`, in dieser Reihenfolge; die Schritte 1 bis 4 werfen **vor** jedem Versand: 1. Adresse = `empfaenger(auftrag)`, leer → `Error('Keine E-Mail-Adresse für die Auftragsbestätigung')`. 2. `hashToken(token) === null` → `TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')`. 3. AB-PDF = `pdf`, wenn es ein `Buffer` ist, sonst `await lesePdf(auftrag.pdfDatei)` (Rückfall; der Fehler von `lesePdf` geht durch). 4. Fassung = `auftrag.agbFassung || AGB_FASSUNG`; AGB-PDF aus `agbPdfPfad(fassung)` lesen, Fehler als `Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}`)`. 5. Kundenmail: `await sendCustomerMail(adresse, { ...k31Mail(auftrag, { token, erinnerung }), attachments: [{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' }, { filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' }], bccTeam: false })`. Scheitert sie, wirft die Funktion und es geht keine Teamkopie raus. 6. Teamkopie: `const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true }))` (ohne Token, ohne Anhänge; `sendAdminNotice` wirft nie). Ist `kopie.sent` falsch, nur `console.error` mit „Teamkopie“, der AB-Nummer und `kopie.reason`; der Versand gilt trotzdem als erfolgreich. `sendeBestaetigtMails(auftrag)`: K32 und T22 je einzeln in `try/catch`; Fehler → `console.error` mit Vorlage und AB-Nummer, nie mit Adresse. Ohne Adresse entfällt K32 (Protokolleintrag), T22 geht immer. Reihenfolge K32 (`sendCustomerMail`, Kopie ans Team wie üblich: die Mail trägt keinen Link), dann T22 (`sendAdminNotice`, das selbst nie wirft; kein BCC). - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/services/auftraege/mails.js test/auftraegeBestaetigungHilfe.js test/auftraegeMails.test.js git commit -m "Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 5: Auftrag bestätigen (`bestaetigung.js`) **Files:** - Create: `server/services/auftraege/bestaetigung.js` - Test: `test/auftraegeBestaetigung.test.js` **Interfaces:** - Consumes: L0 `getAuftrag(id) → Auftrag | null`, `updateAuftrag(id, patch) → Auftrag` (`auftragStore.js`), `AuftragError(status, message)` (`fehler.js`), `sperreAuftrag(id)` (`konstanten.js`, V2: ein zusammengesetzter Schlüssel `['auftraege-ab:<id>']`), `FRUEHBEGINN_FASSUNG` (`texte.js`); `withLocks(keys, fn)` (`server/services/keyedLock.js`); `sendeBestaetigtMails` (Task 4). - Produces: `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }) → Promise<Auftrag>` (V4); dazu `bestaetigungsLage(auftrag) → 'offen' | 'bestaetigt' | 'gesperrt'`, `GESPERRT_MELDUNG` (`{ ersetzt, storniert, widerrufen }` → Meldungstext). - [ ] **Step 1: Failing Tests** `test/auftraegeBestaetigung.test.js` ```js import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { AuftragError } = await import('../server/services/auftraege/fehler.js') const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') const { withLocks } = await import('../server/services/keyedLock.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { bestaetigeAuftrag, bestaetigungsLage } = await import('../server/services/auftraege/bestaetigung.js') after(() => { setMailTransportForTests(null) return aw.close() }) const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ) const ms = (iso) => new Date(iso).getTime() let sink let protokoll beforeEach(() => { sink = mailSink() setMailTransportForTests(sink) protokoll = mock.method(console, 'error', () => {}) }) afterEach(() => mock.restoreAll()) const seed = (over = {}) => { const ab = abDokument(over) aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) }) return ab } const betreffe = () => sink.sent.map((mail) => mail.subject) const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen' const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' const norm = (text) => text.replace(/\s+/g, ' ').trim() test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => { const lage = (over) => bestaetigungsLage(abDokument(over)) assert.equal(lage({}), 'offen') assert.equal(lage({ status: 'abgerechnet' }), 'offen') assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status) }) test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => { const ab = seed() const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) assert.equal(neu.status, 'bestaetigt') assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) assert.equal(neu.bestaetigtUeber, 'link') assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256) assert.equal(neu.fruehbeginn, true) assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG) const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()]) assert.deepEqual(betreffe(), [K32, T22]) assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder') assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`)) }) test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => { const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.bestaetigtUeber, 'portal') assert.equal(neu.fruehbeginn, false) assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten') assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026')) assert.ok(sink.sent[1].text.includes('Weg: Kundenportal')) }) test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => { const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) assert.equal(neu.fruehbeginn, false) assert.equal(neu.fruehbeginnFassung, '') for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/) assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') }) test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => { const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.status, 'abgerechnet') assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) assert.equal((await getAuftrag('ab1')).status, 'abgerechnet') assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => { const ab = seed() const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) }) assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm)) assert.equal(zweite.bestaetigtUeber, 'link') assert.equal(zweite.fruehbeginn, true) assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung') }) test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => { const ab = seed() const [a, b] = await Promise.all([ bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }), ]) assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm)) assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt') assert.equal(b.bestaetigtUeber, 'link') assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => { const ab = seed() let freigeben const gehalten = new Promise((resolve) => { freigeben = resolve }) const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) let fertig = false const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => { fertig = true return neu }) await new Promise((resolve) => setTimeout(resolve, 100)) assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') assert.equal((await getAuftrag('ab1')).status, 'versendet') freigeben() await halter assert.equal((await bestaetigt).status, 'bestaetigt') // Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück) seed() sink.sent.length = 0 const frei = [] setMailTransportForTests({ async sendMail(mail) { frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))])) return sink.sendMail(mail) }, }) await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) assert.deepEqual(frei, [true, true]) assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => { const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']] for (const [status, meldung] of faelle) { const ab = seed({ status }) await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status) const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status) } assert.equal(sink.sent.length, 0) }) test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => { setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.status, 'bestaetigt') assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll') }) test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => { const ab = seed() await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError) await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError) await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404) assert.equal(sink.sent.length, 0) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeBestaetigung.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` (`bestaetigung.js` fehlt). - [ ] **Step 3: Implementieren** `server/services/auftraege/bestaetigung.js` Zustandsregeln (V2): - `GESPERRT_MELDUNG = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }`. - `bestaetigungsLage(a)`: Status `entwurf`, `ersetzt`, `storniert`, `widerrufen` → `'gesperrt'`; sonst `'bestaetigt'`, wenn `a.bestaetigtAm` gesetzt oder `a.status === 'bestaetigt'`; sonst `'offen'` (also `versendet` und `abgerechnet` ohne `bestaetigtAm`). `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() })` (async; die Prüfungen 1 und 2 laufen **synchron vor** der Sperre, damit der zuerst gestartete Aufruf die Sperre zuerst bekommt): 1. `ueber` nicht `'link'` oder `'portal'` → `TypeError`. 2. `jetzt` kein `Date` mit gültiger Zeit → `TypeError`. 3. `withLocks(sperreAuftrag(auftrag.id), …)` mit `sperreAuftrag` aus `./konstanten.js` (nicht neu definieren): `frisch = await getAuftrag(auftrag.id)`; `null` → `AuftragError(404, 'Auftragsbestätigung nicht gefunden.')`. 4. Lage `gesperrt` → `AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? 'Diese Auftragsbestätigung kann nicht bestätigt werden.')`. Lage `bestaetigt` → `frisch` unverändert zurück, **keine** Mails. 5. Lage `offen`: `privat = frisch.kunde?.kundenart === 'privat'`; `updateAuftrag(frisch.id, { bestaetigtAm: jetzt.toISOString(), bestaetigtUeber: ueber, bestaetigtPdfSha256: frisch.pdfSha256, fruehbeginn: privat && fruehbeginn === true, fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '' })`, dazu `status: 'bestaetigt'` nur wenn `frisch.status === 'versendet'` (bei `abgerechnet` bleibt der Status). Ergebnis merken. 6. Erst nach der Freigabe der Sperre (der Test oben hält das fest) und nur, wenn dieser Aufruf bestätigt hat: `await sendeBestaetigtMails(neu)`. Rückgabe: das gespeicherte Dokument. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/services/auftraege/bestaetigung.js test/auftraegeBestaetigung.test.js git commit -m "Aufträge L5: Auftrag bestätigen (idempotent, unter Sperre, Frühbeginn nur bei Privatpersonen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 6: Auftrag widerrufen (`widerrufeAuftrag`) **Files:** - Modify: `server/services/auftraege/bestaetigung.js` - Test: `test/auftraegeBestaetigung.test.js` (ergänzen) **Interfaces:** - Consumes: wie Task 5 (insbesondere `sperreAuftrag(id)` aus `konstanten.js`, V2: ein zusammengesetzter Schlüssel, und `withLocks`); `withdrawalDeadline(contractAt) → { lastDay, endsAt }` (`server/services/deadlines.js`). - Produces: `widerrufeAuftrag(auftrag, { jetzt = new Date() }) → Promise<{ auftrag, ergebnis, fristEnde }>` mit `ergebnis` ∈ `'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'` und `fristEnde: Date | null` (= `withdrawalDeadline(new Date(gesendetAm)).endsAt`). Genutzt von `abo.js` (Task 12); V2: `versendet|bestaetigt → widerrufen`, nur Kundenart `privat`. Die Funktion verschickt selbst keine Mails: K04 und T09 schickt `abo.js` nach der Rückkehr, also nach Freigabe der Sperre. - [ ] **Step 1: Failing Tests** In `test/auftraegeBestaetigung.test.js` die Importzeile `const { bestaetigeAuftrag, bestaetigungsLage } = …` zu `const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = …` ändern und anhängen: ```js // gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC) const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z') test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => { const ab = seed() const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE }) assert.equal(ok.ergebnis, 'widerrufen') assert.equal(ok.auftrag.status, 'widerrufen') assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime()) assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime()) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') seed() const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) }) assert.equal(spaet.ergebnis, 'nach_frist') assert.equal(spaet.auftrag.status, 'versendet') assert.equal((await getAuftrag('ab1')).widerrufenAm, null) }) test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => { const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true }) const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT }) assert.equal(r.ergebnis, 'widerrufen') assert.equal(r.auftrag.fruehbeginn, true) assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime()) const firma = seed({ kunde: kunde('unternehmen') }) const k = await widerrufeAuftrag(firma, { jetzt: JETZT }) assert.equal(k.ergebnis, 'kein_widerrufsrecht') assert.equal((await getAuftrag('ab1')).status, 'versendet') }) test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => { const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']] for (const [status, ergebnis] of faelle) { const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null }) const r = await widerrufeAuftrag(ab, { jetzt: JETZT }) assert.equal(r.ergebnis, ergebnis, status) const doc = await getAuftrag('ab1') assert.equal(doc.status, status, status) assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status) } }) test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => { const veraltet = seed() let freigeben const gehalten = new Promise((resolve) => { freigeben = resolve }) const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) let fertig = false const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => { fertig = true return r }) await new Promise((resolve) => setTimeout(resolve, 100)) assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3) freigeben() await halter const r = await widerruf assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre') assert.equal((await getAuftrag('ab1')).status, 'storniert') assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails') }) test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => { const ab = seed() const [bestaetigt, widerrufen] = await Promise.all([ bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), widerrufeAuftrag(ab, { jetzt: JETZT }), ]) assert.equal(bestaetigt.status, 'bestaetigt') assert.equal(widerrufen.ergebnis, 'widerrufen') assert.equal((await getAuftrag('ab1')).status, 'widerrufen') seed() const umgekehrt = await Promise.allSettled([ widerrufeAuftrag(ab, { jetzt: JETZT }), bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), ]) assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen') assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeBestaetigung.test.js` Expected: FAIL (`widerrufeAuftrag is not a function`). - [ ] **Step 3: Implementieren** `widerrufeAuftrag` in `server/services/auftraege/bestaetigung.js` Die Prüfung von `jetzt` (wie in Task 5) läuft synchron vor der Sperre, damit die Reihenfolge der Aufrufe die Reihenfolge an der Sperre bestimmt. Alles Weitere läuft unter `withLocks(sperreAuftrag(auftrag.id), …)` (`sperreAuftrag` aus `./konstanten.js`, nicht neu definieren; nie `sperreRechnung` darin): den Stand frisch lesen (`null` → `AuftragError(404, 'Auftragsbestätigung nicht gefunden.')`). `fristEnde = a.gesendetAm ? withdrawalDeadline(new Date(a.gesendetAm)).endsAt : null`. Ergebnis in dieser Reihenfolge: 1. `a.status === 'widerrufen'` → `'bereits_widerrufen'`. 2. `a.kunde?.kundenart !== 'privat'` → `'kein_widerrufsrecht'`. 3. Status nicht `versendet` oder `bestaetigt`, oder `fristEnde === null` → `'nicht_widerrufbar'`. 4. `jetzt.getTime() > fristEnde.getTime()` → `'nach_frist'`. 5. sonst `updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() })` → `'widerrufen'`. Nur Fall 5 schreibt; Rückgabe immer `{ auftrag: <aktueller Stand>, ergebnis, fristEnde }`. Mails verschickt die Funktion nicht (das tut `abo.js` in Task 12 nach der Freigabe der Sperre). - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/services/auftraege/bestaetigung.js test/auftraegeBestaetigung.test.js git commit -m "Aufträge L5: Auftrag widerrufen (nur Privatpersonen innerhalb der Frist, unter Sperre)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 7: Öffentliche Link-Routen – ansehen und PDF (`routes/auftrag.js`) **Files:** - Modify: `server/routes/auftrag.js` (Gerüst aus L0 ersetzen, `export default router` bleibt) - Test: `test/auftraegeTokenRoutes.test.js` (neu) **Interfaces:** - Consumes: Task 2 `hashToken`; L0 `findAuftragByTokenHash(hash)`, `oeffentlicheAnsicht(auftrag)`, `auftraegeAktiv(kunde)`, `lesePdf(pdfDatei)`, `AuftragError`, `sendeFehler(res, was, err)`; Task 5 `GESPERRT_MELDUNG`; `createWriteLimiter({ max, now })` (`server/services/writeLimiter.js`). - Produces: `GET /api/auftrag/:token` → `{ auftrag: AuftragOeffentlich }`; `GET /api/auftrag/:token/pdf` → `application/pdf`; Fehler 404/410/429 wie V5; Exporte `setzeUhrFuerTests(fn | null)` und `resetAuftragRoutesForTests()` (leert die Zähler, Uhr zurück auf die echte). - [ ] **Step 1: Failing Tests** `test/auftraegeTokenRoutes.test.js` ```js import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { mkdtempSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { hashToken } from '../server/services/auftraege/token.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-')) process.env.AUFTRAEGE_ENABLED = 'on' const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { startTestApp, send } = await import('./support/testApp.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { speicherePdf } = await import('../server/services/auftraege/ablage.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js') const app = await startTestApp({ '/api/auftrag': tokenRoutes }) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close()]) }) const PDF = '%PDF-1.4 Test-AB' await speicherePdf('c1', 'ab1', Buffer.from(PDF)) const JETZT = new Date('2026-10-10T09:00:00.000Z') const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' } const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' } let sink const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) beforeEach(() => { resetAuftragRoutesForTests() setzeUhrFuerTests(() => JETZT) config.auftraege.mode = 'on' config.auftraege.pilotEmails = [] sink = mailSink() setMailTransportForTests(sink) seed() }) const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts) const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body }) const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } }) test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => { const res = await get() assert.equal(res.status, 200) assert.deepEqual(Object.keys(res.body), ['auftrag']) const a = res.body.auftrag assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat']) assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }]) assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/) for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`) assert.equal(res.headers.get('cache-control'), 'no-store') assert.equal(res.headers.get('x-robots-tag'), 'noindex') }) test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => { for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) { for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token) } seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() }) for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) seed({ tokenGueltigBis: JETZT.toISOString() }) assert.equal((await get()).status, 200) }) test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => { seed({ tokenHash: hashToken(TOKEN_NEU) }) assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200]) }) test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => { config.auftraege.mode = 'off' for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) config.auftraege.mode = 'pilot' config.auftraege.pilotEmails = ['andere@example.invalid'] assert.equal((await get()).status, 404) config.auftraege.pilotEmails = ['k@example.invalid'] assert.equal((await get()).status, 200) }) test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => { for (const [status, meldung] of Object.entries(GRUND)) { seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' }) for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status) } seed({ status: 'entwurf' }) assert.equal((await get()).status, 404) }) test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => { const res = await get(TOKEN, '/pdf') assert.equal(res.status, 200) assert.equal(res.headers.get('content-type'), 'application/pdf') assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"') assert.equal(res.headers.get('cache-control'), 'no-store') assert.equal(res.body, PDF) seed({ pdfDatei: 'c1/fehlt.pdf' }) const fehlt = await get(TOKEN, '/pdf') assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }]) }) test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => { for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`) const zuViele = await get(TOKEN, '', ip('203.0.113.9')) assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE]) assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTokenRoutes.test.js` Expected: FAIL (das Gerüst antwortet nicht: 404 statt 200, `resetAuftragRoutesForTests is not a function`). - [ ] **Step 3: Implementieren** `server/routes/auftrag.js` Konstanten: `UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'`, `ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'`. Uhr der Routen: Modul-Variable (Vorgabe `() => new Date()`), `setzeUhrFuerTests(fn)` setzt sie (kein `fn` = zurück auf die echte), `resetAuftragRoutesForTests()` leert beide Begrenzer und setzt die Uhr zurück. Begrenzer: `createWriteLimiter({ max: 120, now })` für GET, `createWriteLimiter({ max: 10, now })` für POST (`now` liest die Modul-Uhr), Schlüssel `req.ip`, Meldung `ZU_VIELE`, nur im Speicher. `router.use` setzt für alle Antworten `Cache-Control: no-store`, `X-Robots-Tag: noindex`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`. Fehler laufen über `sendeFehler(res, 'auftrag', err)` (Handler in `try/catch`, Express 4 fängt async-Fehler nicht). Gemeinsamer Vorlauf `ladeNachToken(req, res) → Auftrag | null` (bei `null` ist die Antwort schon gesendet), Reihenfolge fest: 1. `hash = hashToken(req.params.token)`; `null` → 404 `{ error: UNGUELTIG }`. 2. `a = await findAuftragByTokenHash(hash)`; `null` → 404. 3. Schalter: `!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })` → 404 (kein Hinweis auf den Grund). 4. `a.status === 'entwurf'` → 404. 5. `GESPERRT_MELDUNG[a.status]` vorhanden → 410 `{ error: <Meldung> }` (geht dem Ablauf vor). 6. `tokenGueltigBis` fehlt oder `uhr().getTime() > Date.parse(tokenGueltigBis)` → 404 (am Ende des letzten Moments gilt der Link noch). `GET /:token` (Begrenzer GET): Vorlauf, dann `res.json({ auftrag: oeffentlicheAnsicht(a) })`. `GET /:token/pdf` (Begrenzer GET): Vorlauf, dann `lesePdf(a.pdfDatei)`; scheitert das mit etwas anderem als einem `AuftragError`, `console.error` mit der Nummer und `AuftragError(404, 'PDF nicht gefunden.')`. Antwort mit `res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })` und `res.end(buffer)`; `nummer` vorher auf `[A-Za-z0-9-]` beschränken. Der POST-Pfad folgt in Task 8; die Datei exportiert schon jetzt `export default router`, `setzeUhrFuerTests` und `resetAuftragRoutesForTests`, und beide Begrenzer (der POST-Begrenzer wird in Task 8 benutzt) werden hier angelegt. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/routes/auftrag.js test/auftraegeTokenRoutes.test.js git commit -m "Aufträge L5: öffentliche Link-Routen (ansehen, PDF) mit Ratenbegrenzung, Schalter und Statusprüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 8: Öffentliche Bestätigung per Link (`POST /api/auftrag/:token/bestaetigen`) **Files:** - Modify: `server/routes/auftrag.js` - Test: `test/auftraegeTokenRoutes.test.js` (ergänzen) **Interfaces:** - Consumes: Task 5 `bestaetigeAuftrag`; Task 7 Vorlauf `ladeNachToken` (404/410 gehen dem 400 vor) und POST-Begrenzer. - Produces: `POST /api/auftrag/:token/bestaetigen` mit Rumpf `{ bestaetigt: true, fruehbeginn: boolean }` → `{ auftrag: AuftragOeffentlich }`; 400 `{ error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }` ohne `bestaetigt === true`; 404/410/429 wie Task 7. - [ ] **Step 1: Failing Tests** — an `test/auftraegeTokenRoutes.test.js` anhängen: ```js const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' } test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => { for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) { const res = await post(body) assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body)) } const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null]) assert.equal(sink.sent.length, 0) // die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400 const unbekannt = await post({}, TOKEN_NEU) assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }]) }) test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => { const pruefe = async (erwartet, token = TOKEN) => { const res = await post({ bestaetigt: true }, token) assert.deepEqual([res.status, res.body], erwartet) const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0]) } await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU) await pruefe([404, { error: UNGUELTIG }], 'kurz') seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt await pruefe([404, { error: UNGUELTIG }], TOKEN) seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() }) await pruefe([404, { error: UNGUELTIG }]) seed({ status: 'ersetzt' }) const ersetzt = await post({ bestaetigt: true }) assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }]) assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0]) seed() config.auftraege.mode = 'off' await pruefe([404, { error: UNGUELTIG }]) }) test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => { const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77')) assert.equal(res.status, 200) assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()]) assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag)) const doc = await getAuftrag('ab1') assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true]) assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster']) assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument') }) test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => { for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) { seed({ kunde: kunde(kundenart) }) const res = await post({ bestaetigt: true, fruehbeginn: eingabe }) assert.equal(res.status, 200, `${kundenart} ${eingabe}`) assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`) } }) test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => { const erste = await post({ bestaetigt: true, fruehbeginn: true }) setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000)) const zweite = await post({ bestaetigt: true, fruehbeginn: false }) assert.equal(zweite.status, 200) assert.deepEqual(zweite.body, erste.body) assert.equal(sink.sent.length, 2) seed() sink.sent.length = 0 const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })]) assert.deepEqual([a.status, b.status], [200, 200]) assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm) assert.equal(sink.sent.length, 2) }) test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => { seed({ status: 'abgerechnet' }) const res = await post({ bestaetigt: true }) assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet']) assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null) }) test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => { setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) const log = mock.method(console, 'error', () => {}) const res = await post({ bestaetigt: true }) assert.equal(res.status, 200) assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') assert.ok(log.mock.callCount() > 0) mock.restoreAll() }) test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => { const kopf = ip('203.0.113.20') for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`) const zuViele = await post({ bestaetigt: true }, TOKEN, kopf) assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE]) assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) assert.equal((await get(TOKEN, '', kopf)).status, 200) assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeTokenRoutes.test.js` Expected: FAIL (die neuen POST-Tests: der Pfad existiert noch nicht, Antwort 404 statt 200 bzw. 400). - [ ] **Step 3: Implementieren** `POST /:token/bestaetigen` in `server/routes/auftrag.js` Mit dem POST-Begrenzer (Task 7), danach der Vorlauf `ladeNachToken`; Rumpf `body = req.body` (ist es kein Objekt, `{}`); `body.bestaetigt !== true` → 400 `{ error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }`; sonst `neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })` und `res.json({ auftrag: oeffentlicheAnsicht(neu) })`. Nichts von der Anfrage (IP, Kopfzeilen, Rumpf) wird gespeichert oder protokolliert. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0` (alle Tests der Datei). - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/routes/auftrag.js test/auftraegeTokenRoutes.test.js git commit -m "Aufträge L5: öffentliche Bestätigung per Link (idempotent, Frühbeginn nur bei Privatpersonen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 9: Portal-Routen des Kunden (`routes/auftraegeKunde.js`) **Files:** - Modify: `server/routes/auftraegeKunde.js` (Gerüst aus L0 ersetzen, `export default router` bleibt) - Test: `test/auftraegeKundeRoutes.test.js` (neu) **Interfaces:** - Consumes: `requireSession`, `getSessionCustomerId` (`server/middleware/session.js`); `isKundenansicht(session)` (`server/services/kundenansicht.js`); `getDocument(config.collections.customers, id)` (`appwriteAdmin.js`); L0 `listAuftraege({ kundenId })`, `getAuftrag(id)`, `oeffentlicheAnsicht`, `auftraegeAktiv`, `lesePdf`, `AuftragError`, `sendeFehler`; Task 5 `bestaetigeAuftrag`, `GESPERRT_MELDUNG`. - Produces: `GET /api/auftraege` → `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (V3/V5: `kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht`; Entwürfe fehlen); `GET /api/auftraege/:id/pdf` → `application/pdf`; `POST /api/auftraege/:id/bestaetigen` (`{ bestaetigt: true, fruehbeginn }`) → `{ auftrag }` (gleiche Form wie ein Listeneintrag); 400 mit dem Text der Token-Route, 404 fremd/Entwurf/unbekannt, 410 mit den Meldungen der Token-Route (ersetzt, storniert, widerrufen). - [ ] **Step 1: Failing Tests** `test/auftraegeKundeRoutes.test.js` ```js import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { mkdtempSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-')) process.env.AUFTRAEGE_ENABLED = 'on' const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { startTestApp, send } = await import('./support/testApp.js') const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { speicherePdf } = await import('../server/services/auftraege/ablage.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js') const app = await startTestApp({ '/api/auftraege': kundeRoutes }) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close()]) }) seedFixtures(aw) const PDF = '%PDF-1.4 Kunden-AB' for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF)) const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' } const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' } const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' } let sink const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) }) beforeEach(() => { config.auftraege.mode = 'on' config.auftraege.pilotEmails = [] sink = mailSink() setMailTransportForTests(sink) for (const over of [{}, ENTWURF, AB3, AB4]) seed(over) }) const cookie = customerCookie('c1') const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts) const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body }) test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => { const res = await get('') assert.equal(res.status, 200) assert.equal(res.body.aktiv, true) assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4']) const ab1 = res.body.auftraege.find((a) => a.id === 'ab1') const ab4 = res.body.auftraege.find((a) => a.id === 'ab4') assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false]) for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern) assert.equal(res.headers.get('cache-control'), 'no-store') }) test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => { const faelle = [ [{}, true], [{ status: 'abgerechnet' }, true], [{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false], [{ status: 'ersetzt' }, false], [{ status: 'storniert' }, false], [{ status: 'widerrufen' }, false], ] for (const [over, erwartet] of faelle) { seed(over) const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1') assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`) assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over)) } }) test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => { for (const opts of [{}, { cookie: adminCookie() }]) { assert.equal((await get('', opts)).status, 401) assert.equal((await get('/ab1/pdf', opts)).status, 401) assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401) } assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) }) test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => { config.auftraege.mode = 'off' assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] }) for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }]) assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) config.auftraege.mode = 'pilot' config.auftraege.pilotEmails = ['k@example.invalid'] assert.equal((await get('')).body.aktiv, true) assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] }) }) test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => { const eigene = await get('/ab1/pdf') assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF]) assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"') seed({ status: 'ersetzt' }) assert.equal((await get('/ab1/pdf')).status, 200) for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) { const res = await get(`/${id}/pdf`) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id) } }) test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => { const vorher = Date.now() const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true }) assert.equal(res.status, 200) assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false]) const doc = await getAuftrag('ab1') assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true]) assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000) assert.equal(sink.sent.length, 2) const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false }) assert.deepEqual([zweite.status, zweite.body], [200, res.body]) assert.equal(sink.sent.length, 2) }) test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => { for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) { const res = await post('/ab1/bestaetigen', body) assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }]) } for (const id of ['ab3', 'ab2', 'gibtsnicht']) { const res = await post(`/${id}/bestaetigen`, { bestaetigt: true }) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id) } for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) { seed({ status }) const res = await post('/ab1/bestaetigen', { bestaetigt: true }) assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status) assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status) } assert.equal((await getAuftrag('ab3')).bestaetigtAm, null) assert.equal(sink.sent.length, 0) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundeRoutes.test.js` Expected: FAIL (Gerüst antwortet 404 statt 200 bzw. 401). - [ ] **Step 3: Implementieren** `server/routes/auftraegeKunde.js` `router.use(requireSession)`, danach `Cache-Control: no-store` für alle Antworten; Fehler über `sendeFehler(res, 'auftraege', err)`. Konstanten: `NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'`, `HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'`, `ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/`. - `ansicht(a, req) = { ...oeffentlicheAnsicht(a), id: a.id, kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)) }` (wörtlich die Formel aus V3; in der Kundenansicht ist Bestätigen nie angeboten). - `ladeEigene(req, res) → Auftrag | null` (bei `null` ist 404 `{ error: NICHT_GEFUNDEN }` schon gesendet): `ID_RE` prüfen; Kunden-Dokument `getDocument(config.collections.customers, getSessionCustomerId(req))`; `!auftraegeAktiv(kunde)` → 404; `a = await getAuftrag(req.params.id)`; `null`, `a.kundenId !== customerId` oder `a.status === 'entwurf'` → 404. - `GET /`: Kunden-Dokument laden; `!auftraegeAktiv(kunde)` → `{ aktiv: false, auftraege: [] }`; sonst `listAuftraege({ kundenId })`, Entwürfe herausfiltern, `{ aktiv: true, auftraege: liste.map((a) => ansicht(a, req)) }`. - `GET /:id/pdf`: `ladeEigene`, `lesePdf` wie in Task 7 (Fehler → 404 `'PDF nicht gefunden.'`), Antwort inline mit `Content-Disposition: inline; filename="<Nummer>.pdf"`; jeder Status außer Entwurf ist abrufbar. - `POST /:id/bestaetigen`: `ladeEigene` (404); `GESPERRT_MELDUNG[a.status]` → 410 `{ error }` (wie in der Token-Route); `body.bestaetigt !== true` → 400 `{ error: HAEKCHEN }`; sonst `bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })` und `res.json({ auftrag: ansicht(neu, req) })`. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/routes/auftraegeKunde.js test/auftraegeKundeRoutes.test.js git commit -m "Aufträge L5: Portal-Routen für Aufträge (eigene gesendete ABs, PDF, Bestätigen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 10: Kundenansicht sperrt das Bestätigen **Files:** - Test: `test/auftraegeKundenansicht.test.js` (neu); kein Produktivcode (der Wächter sperrt jede Schreib-Route ohne Erlaubnisliste-Eintrag). **Interfaces:** - Consumes: `kundenansichtGuard`, `KUNDENANSICHT_ALLOWED` (`server/middleware/kundenansichtGuard.js`, unverändert); Router aus Task 7 und 9; `kundenansichtCookie` (`test/support/fixtures.js`). - Produces: Regressionstest: Bestätigen ist in der Kundenansicht nie möglich, weder im Portal noch über den öffentlichen Link. - [ ] **Step 1: Test schreiben** `test/auftraegeKundenansicht.test.js` ```js import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { mkdtempSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js' // Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-')) process.env.AUFTRAEGE_ENABLED = 'on' const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { startTestApp, send } = await import('./support/testApp.js') const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { speicherePdf } = await import('../server/services/auftraege/ablage.js') const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js') const { default: tokenRoutes } = await import('../server/routes/auftrag.js') // Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes }) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close()]) }) seedFixtures(aw) await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test')) const VIEW = 'a'.repeat(32) const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } }) let sink beforeEach(() => { sink = mailSink() setMailTransportForTests(sink) aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) }) }) const unveraendert = async () => { const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0]) } test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => { for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) { assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad) } }) test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => { const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } }) assert.equal(res.status, 403) assert.equal(res.body.code, 'view_blocked') await unveraendert() }) test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => { const schreibRouten = kundeRoutes.stack .filter((schicht) => schicht.route) .flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`])) assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']]) for (const [methode, pfad] of schreibRouten) { const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } }) assert.equal(res.status, 403, `${methode} ${pfad}`) } await unveraendert() }) test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => { const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } }) assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked']) const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } }) assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced']) await unveraendert() }) test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => { const liste = await send('GET', '/api/auftraege', kv()) assert.equal(liste.status, 200) assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]]) assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200) const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') }) assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]]) const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } }) assert.equal(res.status, 200) assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') }) ``` - [ ] **Step 2: Gegenprobe, erwartet FAIL** (der Test beißt wirklich) Temporär (nicht committen) in `server/middleware/kundenansichtGuard.js` unter `KUNDENANSICHT_ALLOWED` die Zeile `['POST', /^\/api\/auftraege\/[^/]+\/bestaetigen\/?$/],` einfügen und ausführen: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenansicht.test.js` Expected: FAIL (drei Tests: `kein Eintrag der Erlaubnisliste gilt für Aufträge`, `POST /api/auftraege/:id/bestaetigen in der Kundenansicht …` und `alle Schreib-Routen des Kunden-Routers …`). Danach **sofort zurücknehmen**: `git checkout -- server/middleware/kundenansichtGuard.js`. - [ ] **Step 3: Implementierung** Keine: der Wächter und die Router aus Task 7 und 9 liefern das Verhalten. Besteht der Test ohne die Probe-Zeile nicht, ist ein Router falsch eingehängt oder liefert `kannBestaetigen` falsch: dort korrigieren, nicht den Wächter ändern. - [ ] **Step 4: Lauf, erwartet PASS** Run: wie in Step 2, ohne Probe-Zeile (`git diff --stat server/middleware/kundenansichtGuard.js` muss leer sein). Expected: `# fail 0`. - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add test/auftraegeKundenansicht.test.js git commit -m "Aufträge L5: Test – in der Kundenansicht ist das Bestätigen von Aufträgen gesperrt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 11: K04 und T09 – Block `projekt` **Files:** - Modify: `server/mail-templates/K04 Eingang Widerruf.html`, `server/mail-templates/T09 Widerruf eingegangen.html` (je nur der Kopfkommentar und der neue Block), `test/mailTemplates.test.js` (die gepinnten Block-Listen) - Test: `test/auftraegeVorlagen.test.js` (ergänzen) **Interfaces:** - Consumes: `renderMail(id, values, blocks)`, `loadTemplate(id)` (unverändert); die Hilfen `K04`-/`T09`-Testwerte legt dieser Task in `auftraegeVorlagen.test.js` an. - Produces: K04 mit den zusätzlichen Blöcken `projekt` (Kasten „Wie es weitergeht“: „Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.“) und darin `projekt_unternehmen` (Zusatz „Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.“); T09 mit dem zusätzlichen Block `projekt` (roter Kasten „Zu tun“ mit den Zeilen `Ergebnis:`, `Widerrufsfrist bis:`, `Eingang:`). **Keine** neuen Platzhalter: die Vertragsart steht wie bei den anderen Bestellungen in `{{bestellung}}` („Projektauftrag (Auftragsbestätigung AB-JJJJ-NNNN)“, Task 12), T09 nutzt `{{ereignisse}}`, `{{fristende}}` und `{{rechtzeitig}}`. Der Kasten sagt nichts zu Erstattung oder Wertersatz (das ist Sache der Rechtsrunde in L7 und der Handarbeit des Teams). Der verschachtelte Block `projekt_unternehmen` ist nötig, weil die Vorlagen nur positive Blöcke kennen (Vertragsfrage 13). - [ ] **Step 1: Failing Tests** (a) An `test/auftraegeVorlagen.test.js` anhängen (die Hilfen `pruefeZeilen` und die Importe stehen schon in der Datei): ```js const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' } const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' } test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => { assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen']) assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer']) assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt']) assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis']) }) test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => { const privat = renderMail('K04', K04, { kundennummer: true, projekt: true }) assert.deepEqual(privat.problems, []) assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag') pruefeZeilen(privat, [ 'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.', 'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)', 'Kundennummer: KD-9', 'Wie es weitergeht', 'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.', ]) assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/) const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true }) assert.deepEqual(firma.problems, []) pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.']) // der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/) assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/) }) test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => { const mail = renderMail('T09', T09, { kundennummer: true, projekt: true }) assert.deepEqual(mail.problems, []) assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid') pruefeZeilen(mail, [ 'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)', 'Abo: Projektauftrag', 'Kundennummer: KD-9', 'Zu tun', 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt', 'Widerrufsfrist bis: 20.10.2026, 24 Uhr', 'Eingang: RECHTZEITIG', ]) assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).')) assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/) assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/) }) ``` (b) `test/mailTemplates.test.js`, die gepinnten Block-Listen anpassen (Edit-Werkzeug; nach den Texten suchen, nicht nach Zeilennummern): 1. Die Zeile `assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])` kommt **zweimal** vor (im Test „K03, K04, T08, T09: Vertrag bzw. Bestellung …“ und im Test „F9: T09 deklariert preis …“): beide Male `'pruefen']` zu `'pruefen', 'projekt', 'projekt_unternehmen']` ändern (`replace_all`). 2. Im Test „F9: T09 deklariert preis …“ die Liste von `loadTemplate('T09').blocks` am Ende `'nicht_pruefbar', 'bereits_widerrufen',` zu `'nicht_pruefbar', 'bereits_widerrufen', 'projekt',` ändern und den Testtitel zu `'F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)'`. - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeVorlagen.test.js test/mailTemplates.test.js` Expected: FAIL (die Blöcke `projekt` und `projekt_unternehmen` fehlen in K04, `projekt` in T09: die Pins und die neuen Tests schlagen fehl, `renderMail` meldet `unbekannter Block "projekt"`). - [ ] **Step 3: Vorlagen ergänzen** (nur diese Änderungen, sonst bleiben beide Dateien byteweise gleich) **K04** (`server/mail-templates/K04 Eingang Widerruf.html`): 1. Kopfkommentar: die Zeile `…: kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen` endet danach auf `…, funktion, pruefen, projekt, projekt_unternehmen`, und direkt darunter (vor der Zeile ` produkt = Katalogname …`) steht neu: ` projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht.` 2. Direkt unter der Zeile `<!-- ENDE pruefen -->` (vor der Gruß-Zeile `Viele Grüße`) diese drei Zeilen einfügen (Kasten wie bei `pruefen`, nur der Text ist neu): ```html <!-- WENN projekt --> <tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.<!-- WENN projekt_unternehmen --> Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.<!-- ENDE projekt_unternehmen --></div></td></tr></table></td></tr> <!-- ENDE projekt --> ``` **T09** (`server/mail-templates/T09 Widerruf eingegangen.html`): 1. Kopfkommentar: die Zeile `Optionale Blöcke …: kundennummer, abo_gefunden, …, nicht_pruefbar, bereits_widerrufen` endet danach auf `…, nicht_pruefbar, bereits_widerrufen, projekt`, und direkt darunter (vor `-->`) steht neu: ` projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart.` 2. Direkt unter der Zeile `<!-- ENDE funktion -->` diese drei Zeilen einfügen (Kasten wie bei `funktion`, nur der Text ist neu): ```html <!-- WENN projekt --> <tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf eines Projektauftrags. Geprüft wurde nur die Auftragsbestätigung (kein Abo, kein Vertragsereignis); die Zeile „Abo“ oben nennt nur die Vertragsart.<br>Ergebnis: {{ereignisse}}<br>Widerrufsfrist bis: {{fristende}}<br>Eingang: <strong>{{rechtzeitig}}</strong><br>Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).</div></td></tr></table></td></tr> <!-- ENDE projekt --> ``` Sonst nichts ändern: keine neuen Platzhalter, kein weiterer HTML-Kommentar, keine HTML-Entities (die Zeichen „ “ → – stehen als echte Zeichen). - [ ] **Step 4: Lauf, erwartet PASS** Run: wie Step 2. Expected: `# fail 0` (insbesondere die allgemeinen Prüfungen zu K04 und T09: deklarierte = verwendete Platzhalter und Blöcke, jeder Block geschlossen, rendert mit allen Blöcken an und aus ohne Reste). - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add "server/mail-templates/K04 Eingang Widerruf.html" "server/mail-templates/T09 Widerruf eingegangen.html" test/auftraegeVorlagen.test.js test/mailTemplates.test.js git commit -m "Aufträge L5: K04 und T09 mit Block projekt für den Widerruf eines Projektauftrags" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 12: Widerruf eines Projektauftrags (`abo.js`, `order = 'projekt'`) **Files:** - Modify: `server/routes/abo.js` (`ORDERS`, `validDeclaration`, `orderLabel`, neue Hilfen, `router.post('/public/withdraw')`) - Test: `test/auftraegeWiderruf.test.js` (neu) **Interfaces:** - Consumes: Task 6 `widerrufeAuftrag(auftrag, { jetzt }) → { auftrag, ergebnis, fristEnde }`; L0 `findAuftragByNummer(nummer) → Auftrag | null`; Task 11 (K04 und T09 mit dem Block `projekt`, K04 zusätzlich `projekt_unternehmen`); bestehend `sendCustomerMail`, `sendAdminNotice`, Ratenbegrenzung und `readDeclaration` der Widerrufsseite. - Produces: `POST /api/abo/public/withdraw` nimmt zusätzlich `order: 'projekt'` und `abNummer` (**optional**, Text, wird getrimmt, groß geschrieben, auf 20 Zeichen gekürzt); Antwort immer `{ ok: true, receivedAt }`. Text der Vertragsart: `Projektauftrag (Auftragsbestätigung AB-JJJJ-NNNN)` (ohne gültige Nummer `Projektauftrag (Auftragsbestätigung)`). Nur mit gültiger Nummer wird nachgeschlagen: passt die AB zu Nummer **und** E-Mail-Adresse und ist der Kunde eine Privatperson, wird sie `widerrufen` (mit `widerrufenAm`), wenn sie `versendet` oder `bestaetigt` ist und die Frist (`withdrawalDeadline(gesendetAm)`) nicht überschritten ist; sonst ändert sich nichts. Ohne oder mit ungültiger Nummer, bei fremder Adresse oder bei einem Datenbankfehler gehen nur die Mails raus, mit dem Vermerk „ungeprüft“. K04 trägt den Block `projekt` (bei einer Auftragsbestätigung eines Unternehmens zusätzlich `projekt_unternehmen`), T09 den Block `projekt` mit den Zeilen `Ergebnis:`, `Widerrufsfrist bis:` und `Eingang:`. Der Schalter `AUFTRAEGE_ENABLED` hat auf den Widerruf **keinen** Einfluss. Die Mails gehen nach der Rückkehr von `widerrufeAuftrag` raus, also nach Freigabe der Sperre. - [ ] **Step 1: Failing Tests** `test/auftraegeWiderruf.test.js` ```js import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt process.env.AUFTRAEGE_ENABLED = 'on' const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, post, send } = await import('./support/testApp.js') const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { withdrawalDeadline } = await import('../server/services/deadlines.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js') const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes }) const sink = mailSink() setMailTransportForTests(sink) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString() const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) let protokoll beforeEach(() => { resetPublicRateLimit() resetAuftragRoutesForTests() sink.sent.length = 0 config.auftraege.mode = 'on' protokoll = mock.method(console, 'error', () => {}) }) afterEach(() => mock.restoreAll()) const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body }) const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs')) const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen')) const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang)) const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)' const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)' const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.' const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen' const pruefeT09 = (ergebnis, frist, eingang) => { assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`) assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`) assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`) } test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => { const gesendetAm = vor(3) seed({ gesendetAm }) const stripeVorher = st.calls.length const res = await widerruf({ customerNumber: 'KD-9' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt']) assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf') const ab = await getAuftrag('ab1') assert.equal(ab.status, 'widerrufen') assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime()) // K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag') assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid']) assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text) assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`) assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.')) assert.ok(k04().text.includes('Kundennummer: KD-9')) assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/) // T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid') assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag') pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG') assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/) }) test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => { seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true }) assert.equal((await widerruf()).status, 200) const ab = await getAuftrag('ab1') assert.equal(ab.status, 'widerrufen') assert.notEqual(ab.bestaetigtAm, null) assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)') }) test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => { seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text) pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft') }) test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => { const gesendetAm = vor(20) seed({ gesendetAm }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS)) }) test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => { const faelle = [ [{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'], [{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'], [{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER], [{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER], ] seed({ gesendetAm: vor(3) }) for (const [body, label, ergebnis] of faelle) { sink.sent.length = 0 resetPublicRateLimit() const res = await widerruf(body) const was = JSON.stringify(body) assert.equal(res.status, 200, was) assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was) assert.equal((await getAuftrag('ab1')).status, 'versendet', was) assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was) assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was) pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was) assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was) } }) test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => { seed({ gesendetAm: vor(3) }) assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) }) test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => { const faelle = [ ['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'], ['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'], ['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'], ['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'], ] for (const [status, ergebnis, eingang] of faelle) { const gesendetAm = vor(3) seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null }) sink.sent.length = 0 resetPublicRateLimit() assert.equal((await widerruf()).status, 200, status) pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang) assert.equal((await getAuftrag('ab1')).status, status, status) } }) test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => { seed({ gesendetAm: vor(3) }) assert.equal((await widerruf()).status, 200) const lesen = await send('GET', `/api/auftrag/${TOKEN}`) assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }]) assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410) assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) }) test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => { seed({ gesendetAm: vor(3) }) config.auftraege.mode = 'off' assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404) }) test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => { seed({ gesendetAm: vor(3) }) aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 }) assert.equal((await widerruf()).status, 200) assert.equal((await getAuftrag('ab1')).status, 'versendet') pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft') assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) assert.ok(protokoll.mock.callCount() > 0) }) ``` - [ ] **Step 2: Lauf, erwartet FAIL** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeWiderruf.test.js` Expected: FAIL (`order: 'projekt'` fällt auf `abschluss` zurück: K04 trägt „Abschluss Ihres Abos“, die AB bleibt unverändert). - [ ] **Step 3: Implementieren** in `server/routes/abo.js` (nach den Namen suchen, nicht nach Zeilennummern) 1. Importe oben ergänzen: ```javascript import { findAuftragByNummer } from '../services/auftraege/auftragStore.js' import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js' ``` 2. `const ORDERS = [..., 'zeiten']` → `[..., 'zeiten', 'projekt']` (den Kommentar mit der Aufzählung der `order`-Werte über `validDeclaration` um `'projekt'` ergänzen); direkt unter `ORDERS`: `const AB_NUMMER = /^AB-\d{4}-\d{4}$/`. 3. `validDeclaration`: im zurückgegebenen Objekt zusätzlich `abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),`. 4. `orderLabel(order, sub, { customer = false } = {})` → Parameter `{ customer = false, abNummer = '' } = {}` und als erste Anweisung der Funktion: ```javascript if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)' ``` 5. Neue Hilfen vor `describeWithdrawn`: ```javascript /** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */ async function withdrawProjekt(d, receivedAt) { const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase()) if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null } return widerrufeAuftrag(auftrag, { jetzt: receivedAt }) } const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' } /** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */ function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) { const ergebnisText = { widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`, bereits_widerrufen: () => 'war schon widerrufen, nichts geändert', kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', nach_frist: () => 'nichts geändert', nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`, fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', }[ergebnis] return { ereignisse: ergebnisText ? ergebnisText() : AB_NUMMER.test(abNummer) ? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert` : 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen', fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden', rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft', } } ``` (`ergebnis` ist `'ungeprueft'` ohne Treffer; es gibt dort keinen Eintrag in `ergebnisText`. Die Nummer wird nur im gültigen Format ausgegeben, nie freier Text des Absenders.) 6. In `router.post('/public/withdraw', …)`: - Nach `const feature = featureKey !== null`: `const projekt = d.order === 'projekt'` und `let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }`. - Im `try`: `if (feature) {` → `if (projekt) {\n pr = await withdrawProjekt(d, receivedAt)\n } else if (feature) {` (für `projekt` gibt es weder Abo-Suche noch Stripe: `sub` bleibt `null`); im `catch` nach dem `console.error` ergänzen: `if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }`. - Nach `const { inTime } = result`: `const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null` und `const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)`; den Ausdruck `produkt: feature ? funktion.label.name : productLabelOf(sub),` an beiden Stellen (K04 und T09) durch `produkt: produktText,` ersetzen. - K04: `bestellung: orderLabel(d.order, sub, { customer: true }),` → `bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),`; in den Blöcken `pruefen: !inTime && !feature,` → `pruefen: !inTime && !feature && !projekt,` und danach `projekt,` sowie `projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',`. - T09: `bestellung: orderLabel(d.order, sub),` → `bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),`; `fristende: result.deadline ? …` → `fristende: projekt ? projektAngaben.fristende : result.deadline ? …`; dem Ausdruck `rechtzeitig: feature ? …` die Bedingung voranstellen: `rechtzeitig: projekt ? projektAngaben.rechtzeitig : feature ? …`; `ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),` → `ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),`; in den Blöcken `kein_abo_gefunden: !sub && !feature,` → `kein_abo_gefunden: !sub && !feature && !projekt,` und `projekt,` ergänzen. - Alles Übrige (Antwort `{ ok: true, receivedAt }`, Reihenfolge K04 vor T09, Ratenbegrenzung, Mailfehler nur ins Protokoll) bleibt. - [ ] **Step 4: Lauf, erwartet PASS** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeWiderruf.test.js test/publicForms.test.js test/aboRoutes.test.js` Expected: `# fail 0` (die bestehenden Widerrufstests der anderen Bestellungen bleiben grün). - [ ] **Step 5: Commit** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git add server/routes/abo.js test/auftraegeWiderruf.test.js git commit -m "Aufträge L5: Widerruf eines Projektauftrags auf der Widerrufsseite (nur Privatpersonen in der Frist, Nummer optional)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" ``` --- ### Task 13: Selbstprüfung **Files:** keine Änderungen vorgesehen (Korrekturen nur in eigenen Dateien, mit eigenem Commit). - [ ] **Step 1: Freier Speicher** Run: `free -m`. Unter 1500 MB „available“: warten (Memory „server-speicher“). - [ ] **Step 2: Eigene Tests** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeToken.test.js test/auftraegeVorlagen.test.js test/auftraegeMails.test.js test/auftraegeBestaetigung.test.js test/auftraegeTokenRoutes.test.js test/auftraegeKundeRoutes.test.js test/auftraegeKundenansicht.test.js test/auftraegeWiderruf.test.js ``` Expected: `# fail 0`. - [ ] **Step 3: Berührte bestehende Tests** (Mailvorlagen, Kundenansicht-Wächter, öffentlicher Widerruf, Abo) ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/mailTemplates.test.js test/kundenansichtGuard.test.js test/publicForms.test.js test/aboRoutes.test.js ``` Expected: `# fail 0`. - [ ] **Step 4: Nur eigene Dateien geändert, Arbeitsbaum sauber** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 git status --short diff <(git diff --name-only feature/auftraege...HEAD | LC_ALL=C sort) - <<'EOF' && echo "nur eigene Dateien" server/mail-templates/K04 Eingang Widerruf.html server/mail-templates/K31 Auftragsbestaetigung.html server/mail-templates/K32 Auftrag bestaetigt.html server/mail-templates/T09 Widerruf eingegangen.html server/mail-templates/T22 Auftrag bestaetigt.html server/routes/abo.js server/routes/auftraegeKunde.js server/routes/auftrag.js server/services/auftraege/bestaetigung.js server/services/auftraege/mails.js server/services/auftraege/token.js test/auftraegeBestaetigung.test.js test/auftraegeBestaetigungHilfe.js test/auftraegeKundeRoutes.test.js test/auftraegeKundenansicht.test.js test/auftraegeMails.test.js test/auftraegeToken.test.js test/auftraegeTokenRoutes.test.js test/auftraegeVorlagen.test.js test/auftraegeWiderruf.test.js test/mailTemplates.test.js EOF ``` Expected: `git status --short` zeigt nur `?? node_modules` (der Symlink); `diff` schweigt und gibt `nur eigene Dateien` aus. Nichts gepusht, kein `--force`, kein `git add -A`. Vom Mailer (`server/services/mailer.js`), von `server/index.js`, `public/widerrufen.html` und dem Wächter `kundenansichtGuard.js` steht nichts in der Liste. - [ ] **Step 5: Keine Reste** ```bash cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l5 DATEIEN="server/services/auftraege/mails.js server/services/auftraege/bestaetigung.js server/services/auftraege/token.js server/routes/auftrag.js server/routes/auftraegeKunde.js test/auftraegeBestaetigung.test.js test/auftraegeBestaetigungHilfe.js test/auftraegeKundeRoutes.test.js test/auftraegeKundenansicht.test.js test/auftraegeMails.test.js test/auftraegeToken.test.js test/auftraegeTokenRoutes.test.js test/auftraegeVorlagen.test.js test/auftraegeWiderruf.test.js" command grep -a -n -E "html\?t=|\['auftraege-ab'|const sperreAuftrag|bccTeam: true" server/services/auftraege/mails.js server/services/auftraege/bestaetigung.js server/services/auftraege/token.js server/routes/auftrag.js server/routes/auftraegeKunde.js || echo "keine alten Link-Formen, Sperren oder BCC-Reste" LC_ALL=C command grep -a -n -P '\xC2\xA0' $DATEIEN || echo "kein unsichtbares geschütztes Leerzeichen im Code" ``` Erwartet: beide Male die Meldung nach dem `||` (kein Treffer). Ein Treffer der zweiten Abfrage heißt: irgendwo wurde ein Unicode-Escape mit vier Hexziffern zum Zeichen selbst; dort `\u{a0}` mit dem Flag `u` bzw. `String.fromCodePoint` schreiben. - [ ] **Step 6: Vertragsliste abhaken** (jede Zeile gegen den Code prüfen) - [ ] V4 L5 `neuerToken(jetzt = new Date()) → { token, hash, gueltigBis }`, `hashToken(token) → string | null`: 43 Zeichen base64url, SHA-256 hex, 60 Tage; `token.js` importiert nur `node:crypto` und `./konstanten.js`. - [ ] V4 L5 `bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() })`: V2-Regeln, idempotent, unter `sperreAuftrag(id)` aus `konstanten.js` (kein eigener Sperrname), Stand innerhalb der Sperre neu gelesen, `fruehbeginn` nur bei `privat`, `fruehbeginnFassung` bei Privatpersonen immer gespeichert und bei Unternehmen leer, `bestaetigtPdfSha256 = pdfSha256`, K32 und T22 erst nach Freigabe der Sperre (Fehler nur im Protokoll). - [ ] V4 L5 `sendeAuftragMail(auftrag, { pdf, token, erinnerung = false })` wirft bei Fehler der Kundenmail: K31 an den Kunden mit `bccTeam: false`, danach die Teamkopie über `sendAdminNotice` ohne Token, ohne Link und ohne Anhänge (Fehler der Kopie nur im Protokoll, der Versand gilt als erfolgreich); `sendeBestaetigtMails(auftrag)` wirft nie. - [ ] V6 Vorlagen K31/K32/T22: Dateinamen, Betreffe (T22 mit Präfix `[Aufträge]`, keine Testausnahme), Platzhalter, Blöcke (K31 mit `teamkopie` und dem Gegenblock `kundenmail`), K32 mit `fruehbeginn_text` (wörtlich `FRUEHBEGINN_TEXT`, kein Satz zum Widerrufsrecht), `woms_link = <womsBaseUrl>/customers/<kundenId>?tab=invoices`, Link `<publicBaseUrl>/auftrag.html#t=<token>` (Fragment, nie `?t=`), Anhänge `<Nummer>.pdf` und `WEBklar-AGB-<Fassung>.pdf` nur in der Kundenmail, BCC Team nur für K32. - [ ] K04 und T09: nur der Block `projekt` (K04 mit `projekt_unternehmen`) ist neu, keine neuen Platzhalter, die gepinnten Block-Listen in `test/mailTemplates.test.js` stimmen; der K04-Kasten sagt nichts zu Erstattung oder Wertersatz. - [ ] V5 `GET /api/auftrag/:token`, `…/pdf`, `POST …/bestaetigen`: 404 „Dieser Link ist ungültig oder abgelaufen.“, 410 ersetzt/storniert/widerrufen, 429 „Zu viele Anfragen. Bitte später erneut versuchen.“ (GET 120/h, POST 10/h je IP im Speicher), 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“ ohne `bestaetigt: true` (404/410 gehen dem 400 vor). - [ ] V5/V3 `GET /api/auftraege`, `GET /api/auftraege/:id/pdf`, `POST /api/auftraege/:id/bestaetigen`: nur eigene, keine Entwürfe, `kannBestaetigen = (status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht`, 400 mit demselben Text wie die Token-Route, 404 fremd/Entwurf/unbekannt, 410 wie die Token-Routen, 401 ohne Sitzung; Kundenansicht gesperrt, kein Eintrag in `KUNDENANSICHT_ALLOWED`. - [ ] V5 `POST /api/abo/public/withdraw` mit `order: 'projekt'`, `abNummer` **optional**: nur `privat` mit passender Nummer und E-Mail-Adresse → `widerrufen` mit `widerrufenAm` (Fristprüfung über `withdrawalDeadline(gesendetAm)`), sonst nur Mails (ohne oder mit ungültiger Nummer, fremder Adresse oder Datenbankfehler mit dem Vermerk „ungeprüft“); Unternehmen: K04 mit Hinweis ohne gesetzliches Widerrufsrecht; Text „Projektauftrag (Auftragsbestätigung <Nr>)“; Antwort immer `{ ok: true, receivedAt }`. - [ ] Schalter: aus → Token-Routen 404, Kundenrouten `{ aktiv: false, auftraege: [] }` bzw. 404; Widerruf unabhängig vom Schalter. - [ ] Sperren: `bestaetigeAuftrag` und `widerrufeAuftrag` laufen unter `sperreAuftrag(id)` (ein zusammengesetzter Schlüssel), nie `sperreRechnung` darin; K32/T22 (K04/T09) erst nach der Freigabe. - [ ] Review Focus 1–5 je durch einen Test belegt (Verweise oben), keine IP und kein Token im Dokument oder Protokoll, der Link nie in einer Mail ans Team. Kein Commit nötig, wenn nichts geändert wurde; Korrekturen als eigener Commit mit derselben Schlusszeile. --- ## Vertragsfragen Stand nach der Planrunde: Die Fragen 1 bis 11 sind **entschieden (siehe Übersicht, Nachträge)**; dieser Plan folgt den Entscheidungen. Die Fragen 12 bis 14 sind neu, jeweils mit Vorschlag „so lassen“. 1. **K04/T09 und die Dateitabelle.** *Entschieden (siehe Übersicht, Nachträge: L5 bekommt K04 und T09, nur Block `projekt`).* Umgesetzt in Task 11 (K04 `projekt` mit Vertragsart und, bei Unternehmen, Hinweis ohne gesetzliches Widerrufsrecht; T09 `projekt`); die frühere Notlösung über den Block `pruefen` entfällt. 2. **T22-Betreff.** *Entschieden (siehe Übersicht, Nachträge und V6).* `[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}`; das Präfix `Aufträge` kommt in die Präfix-Gruppe von `test/mailTemplates.test.js`, keine Ausnahme für T22 (Task 3). 3. **Kunden-POST für ersetzt/storniert/widerrufen und Text des 400.** *Entschieden (siehe Übersicht, Nachträge und V5).* 400 „Bitte bestätigen Sie den Auftrag mit dem Häkchen.“ für Token- und Kundenroute, 410 wie die Token-Routen, 404 sonst (Task 8 und 9). 4. **`kannBestaetigen`.** *Entschieden (siehe Übersicht, V3).* `(status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht` (Task 9, mit Test über alle Status). 5. **AGB-Anhang.** *Entschieden (siehe Übersicht, V3 und Nachträge L7: alte AGB-PDF-Fassungen bleiben liegen).* Es zählt `auftrag.agbFassung` (Rückfall `AGB_FASSUNG`). Fehlt die Datei, **wirft** `sendeAuftragMail` (L3 setzt `mailStatus: 'fehler'`). Für Z1: nach dem Merge von L7 prüfen, dass `public/rechtliches/agb-<AGB_FASSUNG>.pdf` existiert, sonst scheitert jedes Senden. 6. **Gemeinsame Sperre je AB.** *Entschieden (siehe Übersicht, V2 und Nachträge „Sperren“).* `sperreAuftrag(id)` aus `konstanten.js` (L0), ein zusammengesetzter Schlüssel; L5 legt sie um Bestätigen und Widerrufen und sperrt nie `sperreRechnung` darin. K32/T22 (K04/T09) gehen erst nach der Freigabe raus. 7. **Widerruf und Schalter.** *Entschieden (siehe Übersicht, Nachträge und V5).* Der Widerruf eines Projektauftrags wird auch bei `AUFTRAEGE_ENABLED=off` verarbeitet; die AB-Nummer ist optional (ohne oder ungültig: nur Mails mit dem Vermerk „ungeprüft“). 8. **Zusätzliche Exporte.** *Zur Kenntnis, kein Vertragsthema.* Nur L5-intern oder für Tests: `widerrufeAuftrag`, `bestaetigungsLage`, `GESPERRT_MELDUNG` (`bestaetigung.js`); `k31Mail`, `k32Mail`, `t22Mail`, `agbPdfPfad`, `setzeAgbVerzeichnisFuerTests` (`mails.js`); `setzeUhrFuerTests`, `resetAuftragRoutesForTests` (`routes/auftrag.js`). `sendeAuftragMail` liest bei fehlendem `pdf` die Datei über `lesePdf` (Superset von V4). 9. **L3 übergibt das gespeicherte Dokument an `sendeAuftragMail`.** *Entschieden (siehe Übersicht, Nachträge L3: Mail mit dem gespeicherten Dokument).* Mit `nummer`, `gesendetAm`, `gesendetAn`, `tokenGueltigBis`, `agbFassung`, `kunde`. 10. **Frühbeginn-Fassung.** *Entschieden (siehe Übersicht, Nachträge L5).* Bei Privatpersonen wird `fruehbeginnFassung = FRUEHBEGINN_FASSUNG` auch ohne Haken gespeichert, bei Unternehmen `''` (Task 5). 11. **Testhilfe ohne `.test.js`.** *Entschieden (siehe Übersicht, Nachträge: Testhilfen gehören dem anlegenden Strang).* `test/auftraegeBestaetigungHilfe.js` gehört L5 und wird von `npm test` nicht als Test gestartet. Die Tests erwarten für `AuftragOeffentlich` mindestens `nummer`, `status`, `titel`, `summeCents`, `kundenart`, `positionen` (mit `summeCents`) und `agbUrl` wie in V3; weicht L0 ab, fällt das in Task 7 auf. 12. **K31 braucht einen sechsten Block `kundenmail`.** V6 nennt für K31 die Blöcke `erinnerung, privat, festpreis, aufwand, teamkopie`. Die Vorlagen kennen nur positive Blöcke (`WENN`/`ENDE`); „statt Knopf und Link der Hinweis“ heißt aber, dass Knopf, Anhangs-Kasten und Verbraucherhinweis in der Teamkopie **fehlen** müssen. Plan: zusätzlicher Block `kundenmail` als Gegenblock zu `teamkopie` (genau einer von beiden ist an; `privat` ist in der Teamkopie aus). *Vorschlag:* V6 um `kundenmail` ergänzen. Kein anderer Strang liest die K31-Blöcke (L3 ruft nur `sendeAuftragMail` auf). 13. **K04 und T09: zweiter Blockname, keine neuen Platzhalter.** K04 braucht für den Zusatz „Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.“ einen verschachtelten Block `projekt_unternehmen` (nur positive Blöcke). T09 nutzt `{{ereignisse}}`, `{{fristende}}` und `{{rechtzeitig}}` für Ergebnis, Frist und Eingang; die Zeile „Abo“ zeigt dabei „Projektauftrag“ (die Haupttabelle bleibt unverändert, der Kasten sagt es dem Team). *Vorschlag:* so lassen; ein eigener Platzhalter oder eine geänderte Tabelle wäre mehr als „nur Block `projekt`“. 14. **Teamkopie von K31.** Sie geht als eine Mail über `sendAdminNotice` an alle `CHAT_NOTIFY_EMAILS`, **ohne Anhänge** (die AB-PDF liegt im Ticketsystem) und erst, nachdem die Kundenmail rausging; sie kommt als „WEBklar Kundenportal“ (wie die T-Mails), nicht als „WEBklar“. Scheitert nur die Kopie, gilt der Versand als erfolgreich (Protokoll mit der AB-Nummer, `mailStatus` bleibt `gesendet`). *Vorschlag:* so lassen.