From c74f7eeea7480bdd809015bc8d8c258e61cb93f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:14:29 +0000 Subject: [PATCH] AB-Kern: Senden unter Sperre, Fehlerpfade, Dateirest und Mail-Fehler Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/auftraege.js | 89 ++++++++++++++----- test/auftraegeSenden.test.js | 113 +++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 21 deletions(-) diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js index 0ba4915..8287000 100644 --- a/server/services/auftraege/auftraege.js +++ b/server/services/auftraege/auftraege.js @@ -10,13 +10,13 @@ import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAd import { getSettings } from '../finance/settings.js' import { withLocks } from '../keyedLock.js' import { kundeSnapshot } from './auftragSicht.js' -import { speicherePdf } from './ablage.js' +import { entfernePdf, speicherePdf } from './ablage.js' import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' import { vergibNummer } from './nummern.js' import { TEXTE_FASSUNG } from './texte.js' import { - AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, + AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, } from './konstanten.js' // --------------------------------------------------------------------------------------------------------------------- @@ -374,26 +374,73 @@ function pruefeKundeFuerVersand(kundeDoc) { } } +/** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */ +async function raeumePdfAuf(auftragId, pdfDatei) { + try { + const aktuell = await getAuftrag(auftragId) + if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei) + } catch (err) { + logFehler('PDF-Rest entfernen', err) + } +} + +/** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */ +function mailFehlerText(err) { + const code = err?.code + return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code) + ? `E-Mail konnte nicht gesendet werden (${code}).` + : 'E-Mail konnte nicht gesendet werden.' +} + +/** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */ +async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) { + let mailPatch + try { + await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung }) + mailPatch = { mailStatus: 'gesendet', mailFehler: '' } + } catch (err) { + logFehler('Mailversand', err) + mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) } + } + const final = await updateAuftrag(gespeichert.id, mailPatch) + return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' } +} + export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { + void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail']) - const entwurf = await ladeAuftrag(id) - if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.') - pruefeEingabe(eingabeAusAuftrag(entwurf)) - const kundeDoc = await ladeKunde(entwurf.kundenId) - pruefeKundeFuerVersand(kundeDoc) - const kunde = kundeSnapshot(kundeDoc) - const { steuernummer, bankDetailsText } = await getSettings() - const { nummer, jahr, laufnummer } = await vergibNummer(jetzt) - const gesendetAm = jetzt.toISOString() - const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG } - const pdf = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } }) - const { pdfDatei, pdfSha256 } = await speicherePdf(entwurf.kundenId, entwurf.id, pdf) - const { token, hash, gueltigBis } = d.neuerToken(jetzt) - const gespeichert = await updateAuftrag(entwurf.id, { - status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG, - pdfDatei, pdfSha256, gesendetAn: kunde.email, tokenHash: hash, tokenGueltigBis: gueltigBis.toISOString(), + // Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten + const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => { + const entwurf = await ladeAuftrag(id) + if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.') + pruefeEingabe(eingabeAusAuftrag(entwurf)) + const kundeDoc = await ladeKunde(entwurf.kundenId) + pruefeKundeFuerVersand(kundeDoc) + const kunde = kundeSnapshot(kundeDoc) + const { steuernummer, bankDetailsText } = await getSettings() + const { nummer, jahr, laufnummer } = await vergibNummer(jetzt) + const gesendetAm = jetzt.toISOString() + const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG } + const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } }) + let ablage + try { + ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes) + } catch (err) { + // eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt + logFehler('PDF-Ablage', err) + throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.') + } + try { + const neu = d.neuerToken(jetzt) + const gespeichertesDokument = await updateAuftrag(entwurf.id, { + status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG, + pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), + }) + return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token } + } catch (err) { + await raeumePdfAuf(entwurf.id, ablage.pdfDatei) + throw err + } }) - await d.sendeAuftragMail(gespeichert, { pdf, token }) - const final = await updateAuftrag(entwurf.id, { mailStatus: 'gesendet', mailFehler: '' }) - return { auftrag: final, mailFehler: false } + return versendeMail(d, gespeichert, { pdf, token }) } diff --git a/test/auftraegeSenden.test.js b/test/auftraegeSenden.test.js index 24d5285..0b6d55c 100644 --- a/test/auftraegeSenden.test.js +++ b/test/auftraegeSenden.test.js @@ -114,3 +114,116 @@ test('Senden: nur Entwürfe (409), unbekannte Aufträge (404) und Kunden (404)', await deleteDocument(config.collections.customers, KUNDE_ID) await abgelehnt(sende(zweiter.id, deps), 404, MELDUNG.kundeFehlt) }) + +test('Senden: scheitert die PDF-Erzeugung (Fehler, leer oder unvollständiger Entwurf), bleibt alles wie vorher und die Nummer ist nicht verbraucht', async () => { + mock.method(console, 'error', () => {}) + const entwurf = await neuerEntwurf() + const faelle = [ + [baueDeps({ pdfFehler: new Error('pdfkit kaputt') }), 500, MELDUNG.pdf], + [baueDeps({ pdf: Buffer.alloc(0) }), 500, MELDUNG.pdf], + [baueDeps({ pdfFehler: new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.') }), 409, 'Entwurf unvollständig: Anschrift fehlt.'], + ] + for (const [fehlerhaft, status, meldung] of faelle) { + await abgelehnt(sende(entwurf.id, fehlerhaft.deps), status, meldung) + assert.deepEqual([fehlerhaft.calls.token.length, fehlerhaft.calls.mail.length], [0, 0]) + } + assert.deepEqual(dateienIn(uploadDir), []) + const danach = await getAuftrag(entwurf.id) + assert.deepEqual([danach.status, danach.nummer, danach.jahr], ['entwurf', '', null]) + assert.ok(!danach.pdfDatei && !danach.tokenHash) + assert.equal((await sende(entwurf.id, baueDeps().deps)).auftrag.nummer, 'AB-2026-0001') +}) + +test('Senden: eine schon vorhandene Zieldatei wird nie überschrieben – Fehler, Entwurf und Datei bleiben', async () => { + mock.method(console, 'error', () => {}) + const entwurf = await neuerEntwurf() + const ziel = path.join(uploadDir, KUNDE_ID, `${entwurf.id}.pdf`) + fs.mkdirSync(path.dirname(ziel), { recursive: true }) + fs.writeFileSync(ziel, 'Rest eines Absturzes') + const { deps, calls } = baueDeps() + await abgelehnt(sende(entwurf.id, deps), 500, MELDUNG.pdf) + assert.equal(fs.readFileSync(ziel, 'utf8'), 'Rest eines Absturzes') + assert.equal(calls.mail.length, 0) + assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf') +}) + +test('Senden: scheitert das Speichern der Auftragsbestätigung, wird die eben abgelegte Datei entfernt und das Senden lässt sich wiederholen', async () => { + mock.method(console, 'error', () => {}) + const entwurf = await neuerEntwurf() + const { deps, calls } = baueDeps() + aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500 }) + await assert.rejects(sende(entwurf.id, deps), (err) => err.status === 500) + assert.equal(calls.mail.length, 0) + assert.deepEqual(dateienIn(uploadDir), []) + assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf') + const { auftrag } = await sende(entwurf.id, deps) + assert.equal(auftrag.nummer, 'AB-2026-0001') + assert.deepEqual(dateienIn(uploadDir), [`${KUNDE_ID}/${entwurf.id}.pdf`]) +}) + +test('Senden: scheitert nur die Mail, bleibt die AB versendet (mailStatus fehler) und der Fehlertext verrät nichts Internes', async () => { + mock.method(console, 'error', () => {}) + const [e1, e2] = [await neuerEntwurf(), await neuerEntwurf()] + const mitCode = baueDeps({ mailFehler: Object.assign(new Error('535 Login smtp.example.invalid'), { code: 'EAUTH' }) }) + const r1 = await sende(e1.id, mitCode.deps) + assert.equal(r1.mailFehler, true) + assert.deepEqual( + [r1.auftrag.status, r1.auftrag.nummer, r1.auftrag.mailStatus, r1.auftrag.mailFehler], + ['versendet', 'AB-2026-0001', 'fehler', 'E-Mail konnte nicht gesendet werden (EAUTH).'], + ) + assert.ok(r1.auftrag.pdfDatei && r1.auftrag.tokenHash) + assert.deepEqual(await getAuftrag(e1.id), r1.auftrag) + const ohneCode = baueDeps({ mailFehler: new Error('Verbindung zu mail.example.invalid abgebrochen') }) + const r2 = await sende(e2.id, ohneCode.deps) + assert.deepEqual([r2.auftrag.nummer, r2.auftrag.mailStatus, r2.auftrag.mailFehler], ['AB-2026-0002', 'fehler', 'E-Mail konnte nicht gesendet werden.']) +}) + +test('Senden: zwei Entwürfe gleichzeitig bekommen verschiedene Nummern', async () => { + const [a, b] = [await neuerEntwurf(), await neuerEntwurf()] + const { deps } = baueDeps({ pdfWarten: warte(30) }) + const ergebnisse = await Promise.all([sende(a.id, deps), sende(b.id, deps)]) + assert.deepEqual(ergebnisse.map((e) => e.auftrag.nummer).sort(), ['AB-2026-0001', 'AB-2026-0002']) + assert.equal(new Set(ergebnisse.map((e) => e.auftrag.pdfDatei)).size, 2) +}) + +test('Senden: dieselbe AB zweimal gleichzeitig (Doppelklick) – einmal gesendet, das zweite Mal 409, genau eine Nummer und eine Mail', async () => { + const entwurf = await neuerEntwurf() + const { deps, calls } = baueDeps({ pdfWarten: warte(30) }) + const ergebnisse = await Promise.allSettled([sende(entwurf.id, deps), sende(entwurf.id, deps)]) + assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected']) + const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason + assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.nurEntwurfSenden]) + assert.deepEqual([calls.pdf.length, calls.mail.length], [1, 1]) + assert.deepEqual((await listAuftraege({ jahr: 2026 })).map((a) => a.nummer), ['AB-2026-0001']) +}) + +test('Senden hält die Sperre: Ändern und Löschen währenddessen warten und scheitern danach mit 409', async () => { + const entwurf = await neuerEntwurf() + const pdfTor = tor() + const { deps, calls } = baueDeps({ pdfWarten: pdfTor.promise }) + const senden = ergebnis(sende(entwurf.id, deps)) + await wartebis(() => calls.pdf.length === 1) // das Senden hält die Sperre und wartet auf das PDF + const aendern = ergebnis(aendereEntwurf(entwurf.id, { titel: 'Heimlich geändert' })) + const loeschen = ergebnis(loescheEntwurf(entwurf.id)) + await warte(30) // beide sind unterwegs, dürfen aber noch nichts geschrieben haben + assert.equal((await getAuftrag(entwurf.id)).titel, 'Website Beispiel') + pdfTor.freigeben() + const [s, a, l] = await Promise.all([senden, aendern, loeschen]) + assert.equal(s.wert.auftrag.status, 'versendet') + assert.deepEqual([a.fehler?.status, a.fehler?.message], [409, MELDUNG.nurEntwurfAendern]) + assert.deepEqual([l.fehler?.status, l.fehler?.message], [409, MELDUNG.nurEntwurfLoeschen]) + const danach = await getAuftrag(entwurf.id) + assert.deepEqual([danach.titel, danach.status], ['Website Beispiel', 'versendet']) +}) + +test('Senden: während die Mail unterwegs ist, ist die Sperre schon frei (die nächste AB bekommt ihre Nummer)', async () => { + const [a, b] = [await neuerEntwurf(), await neuerEntwurf()] + const mailTor = tor() + const langsam = baueDeps({ mailWarten: mailTor.promise }) + const ersteMail = ergebnis(sende(a.id, langsam.deps)) + await wartebis(() => langsam.calls.mail.length === 1) + const zweite = await sende(b.id, baueDeps().deps) // läuft durch, obwohl die Mail der ersten noch hängt + assert.equal(zweite.auftrag.nummer, 'AB-2026-0002') + mailTor.freigeben() + assert.equal((await ersteMail).wert.auftrag.nummer, 'AB-2026-0001') +})