Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt „Nachträge nach der Planrunde“ in der Übersicht). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 KiB
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/*undkundenansichtGuard.jsbleiben unberührt; K04 und T09 bekommen nur den Blockprojekt(Task 11). token.jsimportiert nurnode:cryptound./konstanten.js, nieserver/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=onundAUFTRAEGE_UPLOAD_DIR(Temp-Verzeichnis) werden vor dem ersten Import vonconfig.jsgesetzt; den Schalter im Test ändern überconfig.auftraege.modeundconfig.auftraege.pilotEmails; die Uhr der öffentlichen Routen übersetzeUhrFuerTests; verschiedene IP-Adressen über den Kopfx-forwarded-for(die Test-App setzttrust proxyauf 1). - Schreibweise im Code: nie ein Unicode-Escape mit genau vier Hexziffern (das Schreibwerkzeug macht daraus das Zeichen selbst), stattdessen
\u{a0}mit dem FlaguoderString.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 übereuroDe, Tage überberlinDate+formatDe, Uhrzeiten in Berliner Zeit alsTT.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 übersendAdminNoticeeine Kopie aus derselben Vorlage mit dem Blockteamkopie, ohne Knopf, ohne Link, ohne Token und ohne Anhänge. Der Link hat die Form<config.publicBaseUrl>/auftrag.html#t=<token>(Fragment, nie?t=). - Bestätigen und Widerrufen: Sperre
sperreAuftrag(id)auskonstanten.js(V2: ein zusammengesetzter Schlüssel,withLocks), Stand innerhalb der Sperre neu lesen; niesperreRechnunginnerhalb vonsperreAuftrag. K32 und T22 (bzw. K04 und T09 beim Widerruf) erst nach Freigabe der Sperre. Mailfehler dort nie als Fehler an den Kunden weitergeben (nurconsole.errormit der AB-Nummer);sendeAuftragMaildagegen wirft (L3 setztmailStatus), nur eine gescheiterte Teamkopie gilt nicht als Fehler. - Bestätigung und Widerruf berühren keine anderen Felder als die in V2/V3 genannten.
- Shell:
grepist hier eine Funktion, die Dateien still auslässt; immercommand grep -abenutzen. Niepkill -f.
Review Focus
- 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). - 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). - Ungültige Eingaben beim Bestätigen (
bestaetigtfehlt oder ist nichttrue,fruehbeginnals Text oder Zahl, Frühbeginn bei Unternehmen): 400 ohne Änderung; Frühbeginn zählt nur bei Privatpersonen und nur alstrue. Tests: Task 8 (ohne bestaetigt: true,fruehbeginn zählt nur bei Privatpersonen), Task 5 (Unternehmen – fruehbeginn wird ignoriert). - 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. - 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/auftraegemit dem fertigen Strang L0 (Dateien und Namen aus V1–V4). -
Produces: Worktree
dev-auftraege-l5, Branchauftraege/l5-bestaetigung, Symlinknode_modules. -
Step 1: L0 ist im Branch
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
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
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
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) ausserver/services/auftraege/konstanten.js. -
Produces:
neuerToken(jetzt = new Date()) → { token: string, hash: string, gueltigBis: Date }undhashToken(token) → string | null(V4 „L5“). -
Step 1: Failing Test
test/auftraegeToken.test.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()):jetztmuss einDatemit endlichem Zeitwert sein, sonstTypeError.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 anderenull. -
Step 4: Lauf, erwartet PASS
Run: wie Step 2. Expected: # pass 3, # fail 0.
- Step 5: Commit
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 <noreply@anthropic.com>"
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)ausserver/services/mailTemplates.js(unverändert). -
Produces: Vorlagen K31 (Platzhalter
name, nummer, titel, datum, betrag, link, gueltig_bis; Blöckeerinnerung, privat, festpreis, aufwand, teamkopie, kundenmail), K32 (name, nummer, titel, zeitpunkt, fruehester_beginn, fruehbeginn_text; Blöckefruehbeginn, ohne_fruehbeginn), T22 (kunde, nummer, titel, zeitpunkt, ueber, woms_link, fruehester_beginn; Blöckeprivat_fruehbeginn, privat_ohne_fruehbeginn), Betreffe wie V6.kundenmailist der Gegenblock zuteamkopie: 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:
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é <b>&"' }, { festpreis: true })
assert.match(mail.html, /Café <b>&"/)
assert.doesNotMatch(mail.html, /<b>&/)
})
(b) test/mailTemplates.test.js, vier Änderungen (mit dem Edit-Werkzeug; Zeilen nach den Texten suchen):
- 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. const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]→const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)].- In der Prüfung „Kopfkommentar passt zur Vorlage“ die Präfix-Gruppe
(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)um|Aufträgeerweitern und im Meldungstext „oder [Webshop]“ durch „, [Webshop] oder [Aufträge]“ ersetzen. Keine Ausnahme für T22: die Vorlage trägt das Präfix. 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 <!-- ein Zeilenumbruch, dann K31 · …), (b) <title> (muss dem Betreff im Kopf gleichen), (c) den Text des unsichtbaren Vorschau-Spans (die Zeichenfolge  ͏ ͏ ͏ am Ende bleibt), (d) das Wort rechts oben in der Logo-Zeile, (e) alle <tr>-Zeilen der inneren Tabelle der Karte (von der Pill-Zeile bis zur letzten Zeile: bei K31/K32 die Gruß-Zeile, bei T22 die Leerzeile mit ; die äußeren <tr><td class="card" …> und </table> bleiben) durch die unten genannte Folge. Fußzeilen, Logo, Dark-Mode-Stile und die einzige Outlook-Bedingung <!--[if mso]> bleiben. Neben den WENN/ENDE-Kommentaren und dem Kopf darf kein weiterer HTML-Kommentar stehen. Blockmarken ganzer Zeilen stehen wie in K15 auf eigenen Zeilen; Blöcke innerhalb der Tabelle stehen inline (<!-- WENN festpreis --><tr>…</tr><!-- ENDE festpreis -->).
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 …,<br>…wird zuGuten Tag {{name}},<br>…). - 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 indiv.mut). - Knopf = Knopfzeile in K16 (
hrefund Beschriftung ersetzen; die Beschriftung endet mit→). - Gruß = letzte Zeile der Karte in K04 (Sie-Form).
K31 · Kopfkommentar:
<!--
K31 · Auftragsbestätigung (Projektauftrag; Variante Erinnerung, Variante Privatperson, Kopie für das Team)
Betreff: Ihre Auftragsbestätigung {{nummer}} – {{titel}}
Absender: „WEBklar“ <Portal-Postfach>; die Kopie für das Team kommt als „WEBklar Kundenportal“
Empfänger: Kunde (E-Mail der Auftragsbestätigung), ohne BCC · Kopie ohne Link an kenso@webklar.com, andrej@webklar.com
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{datum}}, {{betrag}}, {{link}}, {{gueltig_bis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail
nummer = Nummer der Auftragsbestätigung (AB-JJJJ-NNNN), titel = Projekt bzw. Titel des Auftrags, datum = Tag des Sendens (Berliner Datum, TT.MM.JJJJ).
betrag = Gesamtpreis bei Festpreis (zum Beispiel 800,00 €) bzw. Stundensatz bei Aufwand (zum Beispiel 100,00 € pro Stunde); genau einer der Blöcke festpreis und aufwand ist an.
link = Bestätigungsseite mit dem persönlichen Link (…/auftrag.html#t=…); er steht nur in der Mail an den Kunden und ist in der Kopie für das Team leer. gueltig_bis = letzter Tag, an dem der Link gilt (TT.MM.JJJJ).
erinnerung = Erinnerung des Teams: dieselbe Auftragsbestätigung mit neuem Link, der frühere Link gilt nicht mehr.
privat = der Kunde ist Privatperson: Hinweis auf Widerrufsrecht, Muster-Widerrufsformular (in der angehängten Auftragsbestätigung) und den Beginn der Arbeiten; nicht in der Kopie für das Team.
kundenmail = die Mail an den Kunden: Knopf mit dem Link und Hinweis auf die Anhänge (die Auftragsbestätigung als PDF, <Nummer>.pdf, und die Allgemeinen Geschäftsbedingungen als PDF, WEBklar-AGB-<Fassung>.pdf).
teamkopie = die Kopie für das Team: statt des Knopfs der Hinweis, dass der Bestätigungslink nur in der Mail an den Kunden steht (ohne Link, ohne Anhänge). Genau einer der Blöcke kundenmail und teamkopie ist an.
-->
<title> = Betreff. Vorschautext: Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}. Wort rechts oben: Auftrag. Karte, in dieser Reihenfolge:
- Pill grau:
Auftragsbestätigung - Überschrift:
Ihre Auftragsbestätigung {{nummer}} - 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. - Block
erinnerung: Kasten, TitelErinnerung, TextWir 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. - Tabelle mit den Zeilen:
Auftrag|{{nummer}} vom {{datum}};Projekt|{{titel}}; Blockfestpreis:Gesamtpreis|<strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>; Blockaufwand: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 ZeileBestätigung möglich bis|{{gueltig_bis}}. - Block
kundenmail: Knopf,href="{{link}}", BeschriftungAuftrag bestätigen → - Block
teamkopie: eine Zeile im Muster Absatz (aber mitpadding:28px 40px 0 40px), TextKopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden. - Block
kundenmail: Kasten, TitelAnhänge, TextDie 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. - Block
privat: Kasten, TitelIhr Widerrufsrecht, TextSie 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. - 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:
- Pill grün:
✓ Auftrag bestätigt - Überschrift:
Vielen Dank für Ihre Bestätigung - Absatz:
Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf. - Tabelle:
Auftrag|{{nummer}};Projekt|{{titel}}; letzte ZeileBestätigt am|{{zeitpunkt}} Uhr - Block
fruehbeginn: Kasten, TitelIhre Erklärung zum früheren Beginn, Text{{fruehbeginn_text}}(nichts sonst: keine Einleitung, kein Satz zum Widerrufsrecht) - Block
ohne_fruehbeginn: Kasten, TitelBeginn der Arbeiten, TextSie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit. - 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:
- Pill grau:
Aufträge - Überschrift:
Auftrag bestätigt - Tabelle:
Kunde|{{kunde}};Auftrag|{{nummer}};Projekt|{{titel}};Bestätigt am|{{zeitpunkt}} Uhr; letzte ZeileWeg|{{ueber}} - Block
privat_fruehbeginn: Kasten, TitelPrivatperson: früher Beginn verlangt, TextDer 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). - Block
privat_ohne_fruehbeginn: Kasten, TitelPrivatperson: kein früher Beginn, TextDer Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist. - Knopf:
href="{{woms_link}}", BeschriftungIm 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
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 })undsendAdminNotice({ 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. Dieauftrag-Objekte haben die Form „Auftrag“ aus V3; L3 übergibt das gespeicherte Dokument (mitnummer,gesendetAm,gesendetAn,tokenGueltigBis,agbFassung). -
Produces:
sendeAuftragMail(auftrag, { pdf, token, erinnerung = false }) → Promise<void>(wirft bei Fehler der Kundenmail) undsendeBestaetigtMails(auftrag) → Promise<void>(wirft nie), beide laut V4; dazuk31Mail(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):
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:
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:MMmitIntl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).preisText(auftrag): FestpreiseuroDe(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öckeerinnerung,privat: privat && !teamkopie,festpreis/aufwand(nachart),teamkopie,kundenmail: !teamkopie.k32Mail:zeitpunkt = zeitpunktText(bestaetigtAm);fruehester_beginn=fruehesterBeginn(auftrag)alsformatDe, sonst'';fruehbeginn = privat && auftrag.fruehbeginn,ohne_fruehbeginn = privat && !auftrag.fruehbeginn;fruehbeginn_text = fruehbeginn ? FRUEHBEGINN_TEXT : ''(aus./texte.js).t22Mail: Wertkunde= 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:
- Adresse =
empfaenger(auftrag), leer →Error('Keine E-Mail-Adresse für die Auftragsbestätigung'). hashToken(token) === null→TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format').- AB-PDF =
pdf, wenn es einBufferist, sonstawait lesePdf(auftrag.pdfDatei)(Rückfall; der Fehler vonlesePdfgeht durch). - Fassung =
auftrag.agbFassung || AGB_FASSUNG; AGB-PDF ausagbPdfPfad(fassung)lesen, Fehler alsError(AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}). - 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. - Teamkopie:
const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true }))(ohne Token, ohne Anhänge;sendAdminNoticewirft nie). Istkopie.sentfalsch, nurconsole.errormit „Teamkopie“, der AB-Nummer undkopie.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
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); dazubestaetigungsLage(auftrag) → 'offen' | 'bestaetigt' | 'gesperrt',GESPERRT_MELDUNG({ ersetzt, storniert, widerrufen }→ Meldungstext). -
Step 1: Failing Tests
test/auftraegeBestaetigung.test.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): Statusentwurf,ersetzt,storniert,widerrufen→'gesperrt'; sonst'bestaetigt', wenna.bestaetigtAmgesetzt odera.status === 'bestaetigt'; sonst'offen'(alsoversendetundabgerechnetohnebestaetigtAm).
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):
uebernicht'link'oder'portal'→TypeError.jetztkeinDatemit gültiger Zeit →TypeError.withLocks(sperreAuftrag(auftrag.id), …)mitsperreAuftragaus./konstanten.js(nicht neu definieren):frisch = await getAuftrag(auftrag.id);null→AuftragError(404, 'Auftragsbestätigung nicht gefunden.').- Lage
gesperrt→AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? 'Diese Auftragsbestätigung kann nicht bestätigt werden.'). Lagebestaetigt→frischunverändert zurück, keine Mails. - 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 : '' }), dazustatus: 'bestaetigt'nur wennfrisch.status === 'versendet'(beiabgerechnetbleibt der Status). Ergebnis merken. - 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
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)auskonstanten.js, V2: ein zusammengesetzter Schlüssel, undwithLocks);withdrawalDeadline(contractAt) → { lastDay, endsAt }(server/services/deadlines.js). -
Produces:
widerrufeAuftrag(auftrag, { jetzt = new Date() }) → Promise<{ auftrag, ergebnis, fristEnde }>mitergebnis∈'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'undfristEnde: Date | null(=withdrawalDeadline(new Date(gesendetAm)).endsAt). Genutzt vonabo.js(Task 12); V2:versendet|bestaetigt → widerrufen, nur Kundenartprivat. Die Funktion verschickt selbst keine Mails: K04 und T09 schicktabo.jsnach 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:
// 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
widerrufeAuftraginserver/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:
a.status === 'widerrufen'→'bereits_widerrufen'.a.kunde?.kundenart !== 'privat'→'kein_widerrufsrecht'.- Status nicht
versendetoderbestaetigt, oderfristEnde === null→'nicht_widerrufbar'. jetzt.getTime() > fristEnde.getTime()→'nach_frist'.- 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 tutabo.jsin Task 12 nach der Freigabe der Sperre).
- Step 4: Lauf, erwartet PASS
Run: wie Step 2. Expected: # fail 0.
- Step 5: Commit
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 routerbleibt) - Test:
test/auftraegeTokenRoutes.test.js(neu)
Interfaces:
-
Consumes: Task 2
hashToken; L0findAuftragByTokenHash(hash),oeffentlicheAnsicht(auftrag),auftraegeAktiv(kunde),lesePdf(pdfDatei),AuftragError,sendeFehler(res, was, err); Task 5GESPERRT_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; ExportesetzeUhrFuerTests(fn | null)undresetAuftragRoutesForTests()(leert die Zähler, Uhr zurück auf die echte). -
Step 1: Failing Tests
test/auftraegeTokenRoutes.test.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:
hash = hashToken(req.params.token);null→ 404{ error: UNGUELTIG }.a = await findAuftragByTokenHash(hash);null→ 404.- Schalter:
!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })→ 404 (kein Hinweis auf den Grund). a.status === 'entwurf'→ 404.GESPERRT_MELDUNG[a.status]vorhanden → 410{ error: <Meldung> }(geht dem Ablauf vor).tokenGueltigBisfehlt oderuhr().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
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 VorlaufladeNachToken(404/410 gehen dem 400 vor) und POST-Begrenzer. -
Produces:
POST /api/auftrag/:token/bestaetigenmit Rumpf{ bestaetigt: true, fruehbeginn: boolean }→{ auftrag: AuftragOeffentlich }; 400{ error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }ohnebestaetigt === true; 404/410/429 wie Task 7. -
Step 1: Failing Tests — an
test/auftraegeTokenRoutes.test.jsanhängen:
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/bestaetigeninserver/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
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 routerbleibt) - 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); L0listAuftraege({ kundenId }),getAuftrag(id),oeffentlicheAnsicht,auftraegeAktiv,lesePdf,AuftragError,sendeFehler; Task 5bestaetigeAuftrag,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
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(beinullist 404{ error: NICHT_GEFUNDEN }schon gesendet):ID_REprüfen; Kunden-DokumentgetDocument(config.collections.customers, getSessionCustomerId(req));!auftraegeAktiv(kunde)→ 404;a = await getAuftrag(req.params.id);null,a.kundenId !== customerIdodera.status === 'entwurf'→ 404. -
GET /: Kunden-Dokument laden;!auftraegeAktiv(kunde)→{ aktiv: false, auftraege: [] }; sonstlistAuftraege({ kundenId }), Entwürfe herausfiltern,{ aktiv: true, auftraege: liste.map((a) => ansicht(a, req)) }. -
GET /:id/pdf:ladeEigene,lesePdfwie in Task 7 (Fehler → 404'PDF nicht gefunden.'), Antwort inline mitContent-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 }; sonstbestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })undres.json({ auftrag: ansicht(neu, req) }). -
Step 4: Lauf, erwartet PASS
Run: wie Step 2. Expected: # fail 0.
- Step 5: Commit
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
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
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 HilfenK04-/T09-Testwerte legt dieser Task inauftraegeVorlagen.test.jsan. -
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 darinprojekt_unternehmen(Zusatz „Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.“); T09 mit dem zusätzlichen Blockprojekt(roter Kasten „Zu tun“ mit den ZeilenErgebnis:,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 Blockprojekt_unternehmenist 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):
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):
- 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). - Im Test „F9: T09 deklariert preis …“ die Liste von
loadTemplate('T09').blocksam 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):
- Kopfkommentar: die Zeile
…: kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefenendet danach auf…, funktion, pruefen, projekt, projekt_unternehmen, und direkt darunter (vor der Zeileprodukt = 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. - Direkt unter der Zeile
<!-- ENDE pruefen -->(vor der Gruß-ZeileViele Grüße) diese drei Zeilen einfügen (Kasten wie beipruefen, nur der Text ist neu):
<!-- 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):
- Kopfkommentar: die Zeile
Optionale Blöcke …: kundennummer, abo_gefunden, …, nicht_pruefbar, bereits_widerrufenendet 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. - Direkt unter der Zeile
<!-- ENDE funktion -->diese drei Zeilen einfügen (Kasten wie beifunktion, nur der Text ist neu):
<!-- 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
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 }; L0findAuftragByNummer(nummer) → Auftrag | null; Task 11 (K04 und T09 mit dem Blockprojekt, K04 zusätzlichprojekt_unternehmen); bestehendsendCustomerMail,sendAdminNotice, Ratenbegrenzung undreadDeclarationder Widerrufsseite. -
Produces:
POST /api/abo/public/withdrawnimmt zusätzlichorder: 'projekt'undabNummer(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 NummerProjektauftrag (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 siewiderrufen(mitwiderrufenAm), wenn sieversendetoderbestaetigtist 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 Blockprojekt(bei einer Auftragsbestätigung eines Unternehmens zusätzlichprojekt_unternehmen), T09 den Blockprojektmit den ZeilenErgebnis:,Widerrufsfrist bis:undEingang:. Der SchalterAUFTRAEGE_ENABLEDhat auf den Widerruf keinen Einfluss. Die Mails gehen nach der Rückkehr vonwiderrufeAuftragraus, also nach Freigabe der Sperre. -
Step 1: Failing Tests
test/auftraegeWiderruf.test.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)
- Importe oben ergänzen:
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
const ORDERS = [..., 'zeiten']→[..., 'zeiten', 'projekt'](den Kommentar mit der Aufzählung derorder-Werte übervalidDeclarationum'projekt'ergänzen); direkt unterORDERS:const AB_NUMMER = /^AB-\d{4}-\d{4}$/.validDeclaration: im zurückgegebenen Objekt zusätzlichabNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),.orderLabel(order, sub, { customer = false } = {})→ Parameter{ customer = false, abNummer = '' } = {}und als erste Anweisung der Funktion:
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
- Neue Hilfen vor
describeWithdrawn:
/** 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'undlet pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }. -
Im
try:if (feature) {→if (projekt) {\n pr = await withdrawProjekt(d, receivedAt)\n } else if (feature) {(fürprojektgibt es weder Abo-Suche noch Stripe:subbleibtnull); imcatchnach demconsole.errorergänzen:if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }. -
Nach
const { inTime } = result:const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : nullundconst produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub); den Ausdruckprodukt: feature ? funktion.label.name : productLabelOf(sub),an beiden Stellen (K04 und T09) durchprodukt: produktText,ersetzen. -
K04:
bestellung: orderLabel(d.order, sub, { customer: true }),→bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),; in den Blöckenpruefen: !inTime && !feature,→pruefen: !inTime && !feature && !projekt,und danachprojekt,sowieprojekt_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 Ausdruckrechtzeitig: 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öckenkein_abo_gefunden: !sub && !feature,→kein_abo_gefunden: !sub && !feature && !projekt,undprojekt,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
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
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)
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
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
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.jsimportiert nurnode:cryptound./konstanten.js. -
V4 L5
bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() }): V2-Regeln, idempotent, untersperreAuftrag(id)auskonstanten.js(kein eigener Sperrname), Stand innerhalb der Sperre neu gelesen,fruehbeginnnur beiprivat,fruehbeginnFassungbei 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 mitbccTeam: false, danach die Teamkopie übersendAdminNoticeohne 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 mitteamkopieund dem Gegenblockkundenmail), K32 mitfruehbeginn_text(wörtlichFRUEHBEGINN_TEXT, kein Satz zum Widerrufsrecht),woms_link = <womsBaseUrl>/customers/<kundenId>?tab=invoices, Link<publicBaseUrl>/auftrag.html#t=<token>(Fragment, nie?t=), Anhänge<Nummer>.pdfundWEBklar-AGB-<Fassung>.pdfnur in der Kundenmail, BCC Team nur für K32. -
K04 und T09: nur der Block
projekt(K04 mitprojekt_unternehmen) ist neu, keine neuen Platzhalter, die gepinnten Block-Listen intest/mailTemplates.test.jsstimmen; 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.“ ohnebestaetigt: 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 inKUNDENANSICHT_ALLOWED. -
V5
POST /api/abo/public/withdrawmitorder: 'projekt',abNummeroptional: nurprivatmit passender Nummer und E-Mail-Adresse →widerrufenmitwiderrufenAm(Fristprüfung überwithdrawalDeadline(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 )“; Antwort immer{ ok: true, receivedAt }. -
Schalter: aus → Token-Routen 404, Kundenrouten
{ aktiv: false, auftraege: [] }bzw. 404; Widerruf unabhängig vom Schalter. -
Sperren:
bestaetigeAuftragundwiderrufeAuftraglaufen untersperreAuftrag(id)(ein zusammengesetzter Schlüssel), niesperreRechnungdarin; 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“.
- K04/T09 und die Dateitabelle. Entschieden (siehe Übersicht, Nachträge: L5 bekommt K04 und T09, nur Block
projekt). Umgesetzt in Task 11 (K04projektmit Vertragsart und, bei Unternehmen, Hinweis ohne gesetzliches Widerrufsrecht; T09projekt); die frühere Notlösung über den Blockpruefenentfällt. - T22-Betreff. Entschieden (siehe Übersicht, Nachträge und V6).
[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}; das PräfixAufträgekommt in die Präfix-Gruppe vontest/mailTemplates.test.js, keine Ausnahme für T22 (Task 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).
kannBestaetigen. Entschieden (siehe Übersicht, V3).(status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht(Task 9, mit Test über alle Status).- AGB-Anhang. Entschieden (siehe Übersicht, V3 und Nachträge L7: alte AGB-PDF-Fassungen bleiben liegen). Es zählt
auftrag.agbFassung(RückfallAGB_FASSUNG). Fehlt die Datei, wirftsendeAuftragMail(L3 setztmailStatus: 'fehler'). Für Z1: nach dem Merge von L7 prüfen, dasspublic/rechtliches/agb-<AGB_FASSUNG>.pdfexistiert, sonst scheitert jedes Senden. - Gemeinsame Sperre je AB. Entschieden (siehe Übersicht, V2 und Nachträge „Sperren“).
sperreAuftrag(id)auskonstanten.js(L0), ein zusammengesetzter Schlüssel; L5 legt sie um Bestätigen und Widerrufen und sperrt niesperreRechnungdarin. K32/T22 (K04/T09) gehen erst nach der Freigabe raus. - Widerruf und Schalter. Entschieden (siehe Übersicht, Nachträge und V5). Der Widerruf eines Projektauftrags wird auch bei
AUFTRAEGE_ENABLED=offverarbeitet; die AB-Nummer ist optional (ohne oder ungültig: nur Mails mit dem Vermerk „ungeprüft“). - 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).sendeAuftragMailliest bei fehlendempdfdie Datei überlesePdf(Superset von V4). - L3 übergibt das gespeicherte Dokument an
sendeAuftragMail. Entschieden (siehe Übersicht, Nachträge L3: Mail mit dem gespeicherten Dokument). Mitnummer,gesendetAm,gesendetAn,tokenGueltigBis,agbFassung,kunde. - Frühbeginn-Fassung. Entschieden (siehe Übersicht, Nachträge L5). Bei Privatpersonen wird
fruehbeginnFassung = FRUEHBEGINN_FASSUNGauch ohne Haken gespeichert, bei Unternehmen''(Task 5). - Testhilfe ohne
.test.js. Entschieden (siehe Übersicht, Nachträge: Testhilfen gehören dem anlegenden Strang).test/auftraegeBestaetigungHilfe.jsgehört L5 und wird vonnpm testnicht als Test gestartet. Die Tests erwarten fürAuftragOeffentlichmindestensnummer,status,titel,summeCents,kundenart,positionen(mitsummeCents) undagbUrlwie in V3; weicht L0 ab, fällt das in Task 7 auf. - K31 braucht einen sechsten Block
kundenmail. V6 nennt für K31 die Blöckeerinnerung, 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 Blockkundenmailals Gegenblock zuteamkopie(genau einer von beiden ist an;privatist in der Teamkopie aus). Vorschlag: V6 umkundenmailergänzen. Kein anderer Strang liest die K31-Blöcke (L3 ruft nursendeAuftragMailauf). - 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 Blockprojekt“. - Teamkopie von K31. Sie geht als eine Mail über
sendAdminNoticean alleCHAT_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,mailStatusbleibtgesendet). Vorschlag: so lassen.