diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js index 8287000..815cd77 100644 --- a/server/services/auftraege/auftraege.js +++ b/server/services/auftraege/auftraege.js @@ -5,12 +5,13 @@ * über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter * sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre. */ +import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { getSettings } from '../finance/settings.js' import { withLocks } from '../keyedLock.js' import { kundeSnapshot } from './auftragSicht.js' -import { entfernePdf, speicherePdf } from './ablage.js' +import { entfernePdf, lesePdf, speicherePdf } from './ablage.js' import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' import { vergibNummer } from './nummern.js' @@ -444,3 +445,38 @@ export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { }) return versendeMail(d, gespeichert, { pdf, token }) } + +// --------------------------------------------------------------------------------------------------------------------- +// Erinnern („Erinnerung senden“ und „Erneut senden“) +// --------------------------------------------------------------------------------------------------------------------- + +/** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */ +export async function ladeGespeichertesPdf(auftrag) { + if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.') + const buffer = await lesePdf(auftrag.pdfDatei) + if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) { + console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id) + throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.') + } + return buffer +} + +/** + * Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten + * PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse. + */ +export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) { + const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail']) + const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => { + const alt = await ladeAuftrag(id) + if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.') + const kundeDoc = await ladeKunde(alt.kundenId) + if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.') + // vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten + const pdfBytes = await ladeGespeichertesPdf(alt) + const neu = d.neuerToken(jetzt) + const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() }) + return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' } + }) + return versendeMail(d, gespeichert, { pdf, token, erinnerung }) +} diff --git a/test/auftraegeAbFolge.test.js b/test/auftraegeAbFolge.test.js new file mode 100644 index 0000000..a19f48c --- /dev/null +++ b/test/auftraegeAbFolge.test.js @@ -0,0 +1,121 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { + starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc, + baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis, +} from './helpers/auftraegeAb.js' + +const umg = await starteUmgebung() +const { aw, config, uploadDir } = umg +after(() => umg.schliesse()) +beforeEach(umg.zuruecksetzen) +const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js') +const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const { lesePdf } = await import('../server/services/auftraege/ablage.js') +const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js') + +const JETZT = new Date('2026-10-06T10:00:00Z') +/** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */ +async function gesendet(deps, patch) { + const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON }) + return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag +} + +/** + * Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre + * steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben. + */ +async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) { + const sperre = tor() + const haelt = withLocks(sperreAuftrag(id), () => sperre.promise) + const lauf = ergebnis(aktion()) + await wartebis(bereit) + await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben + await waehrend() + sperre.freigeben() + await haelt + return lauf +} + +test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => { + const { deps, calls } = baueDeps() + const erste = await gesendet(deps) + const spaeter = new Date('2026-10-20T08:00:00Z') + const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter }) + assert.equal(mailFehler, false) + assert.equal(calls.token.length, 2) + assert.notEqual(auftrag.tokenHash, erste.tokenHash) + assert.equal(auftrag.tokenHash, sha256(calls.token[1])) + assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000) + assert.equal(await findAuftragByTokenHash(erste.tokenHash), null) + assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id) + assert.deepEqual( + [auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus], + ['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'], + ) + assert.equal(calls.pdf.length, 1) // kein neues PDF + assert.equal(calls.mail.length, 2) + assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei)) + assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true]) + assert.deepEqual(await getAuftrag(erste.id), auftrag) +}) + +test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => { + mock.method(console, 'error', () => {}) + const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps) + assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid']) + aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert + const { deps, calls } = baueDeps() + const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps }) + assert.equal(mailFehler, false) + assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid']) + assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid']) +}) + +test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => { + mock.method(console, 'error', () => {}) + const erste = await gesendet(baueDeps().deps) + const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) }) + const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps }) + assert.equal(mailFehler, true) + assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).']) + assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0])) + assert.equal(await findAuftragByTokenHash(erste.tokenHash), null) +}) + +test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => { + mock.method(console, 'error', () => {}) + const { deps } = baueDeps() + const a = await gesendet(deps) + for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) { + await updateAuftrag(a.id, { status }) + await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status) + } + await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt) + await updateAuftrag(a.id, { status: 'versendet' }) + aw.seed(config.collections.customers, kundeDoc({ email: ' ' })) + await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail) + aw.seed(config.collections.customers, kundeDoc()) + fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert') + await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme) + assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) +}) + +test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => { + const { deps, calls } = baueDeps() + const a = await gesendet(deps) + const lauf = await unterFremderSperre(a.id, { + aktion: () => erinnereAuftrag(a.id, { deps }), + waehrend: async () => { + assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token + await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' }) + }, + }) + assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung]) + assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens + assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) +})