From 503337d7ae359960bbae75fbfc15c9539364914b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:15:22 +0000 Subject: [PATCH] =?UTF-8?q?Auftr=C3=A4ge=20L5:=20Portal-Routen=20f=C3=BCr?= =?UTF-8?q?=20Auftr=C3=A4ge=20(eigene=20gesendete=20ABs,=20PDF,=20Best?= =?UTF-8?q?=C3=A4tigen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/auftraegeKunde.js | 85 +++++++++++++++++- test/auftraegeKundeRoutes.test.js | 143 ++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 test/auftraegeKundeRoutes.test.js diff --git a/server/routes/auftraegeKunde.js b/server/routes/auftraegeKunde.js index 11c7852..445047f 100644 --- a/server/routes/auftraegeKunde.js +++ b/server/routes/auftraegeKunde.js @@ -1,12 +1,89 @@ /** - * Gerüst von Strang L0: Aufträge des angemeldeten Kunden. Mount: /api/auftraege. requireSession schützt jede später - * ergänzte Route (fail-closed). L5 ergänzt GET /, GET /:id/pdf, POST /:id/bestaetigen; das Bestätigen sperrt der - * Wächter der Kundenansicht von selbst, es kommt kein Eintrag in KUNDENANSICHT_ALLOWED. + * Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede + * Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe); + * in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst + * (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED). */ import { Router } from 'express' -import { requireSession } from '../middleware/session.js' +import { config } from '../config.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { isKundenansicht } from '../services/kundenansicht.js' +import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js' +import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js' +import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js' +import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js' +import { auftraegeAktiv } from '../services/auftraege/schalter.js' +import { sendePdf } from './auftrag.js' + +const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' +const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ const router = Router() router.use(requireSession) +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +const ansicht = (a, req) => ({ + ...oeffentlicheAnsicht(a), + id: a.id, + kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)), +}) + +/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */ +async function ladeEigene(req, res) { + const nichtGefunden = () => { + res.status(404).json({ error: NICHT_GEFUNDEN }) + return null + } + if (!ID_RE.test(String(req.params.id))) return nichtGefunden() + const kundenId = getSessionCustomerId(req) + const kunde = await getDocument(config.collections.customers, kundenId) + if (!auftraegeAktiv(kunde)) return nichtGefunden() + const a = await getAuftrag(req.params.id) + if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden() + return a +} + +router.get('/', async (req, res) => { + try { + const kundenId = getSessionCustomerId(req) + const kunde = await getDocument(config.collections.customers, kundenId) + if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] }) + const liste = await listAuftraege({ kundenId }) + res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) }) + } catch (err) { + sendeFehler(res, 'auftraege', err) + } +}) + +router.get('/:id/pdf', async (req, res) => { + try { + const a = await ladeEigene(req, res) + if (a) await sendePdf(res, a) + } catch (err) { + sendeFehler(res, 'auftraege', err) + } +}) + +router.post('/:id/bestaetigen', async (req, res) => { + try { + const a = await ladeEigene(req, res) + if (!a) return + if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] }) + const body = req.body && typeof req.body === 'object' ? req.body : {} + if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN }) + const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true }) + res.json({ auftrag: ansicht(neu, req) }) + } catch (err) { + if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) { + return res.status(410).json({ error: err.message }) + } + sendeFehler(res, 'auftraege', err) + } +}) export default router diff --git a/test/auftraegeKundeRoutes.test.js b/test/auftraegeKundeRoutes.test.js new file mode 100644 index 0000000..2b29d01 --- /dev/null +++ b/test/auftraegeKundeRoutes.test.js @@ -0,0 +1,143 @@ +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) +})