AB-Kern: Admin-Routen zum Lesen, Anlegen, Ändern, Löschen und für das PDF

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:16:02 +00:00
1 parent 66ec8e0a44
commit 7d3114d7a3
2 files changed
+215 -2

No files matched your search

+88 -2
View File
@@ -1,11 +1,97 @@
/**
* Gerüst von Strang L0: Auftragsbestätigungen (Admin). Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router).
* L3 füllt: Liste, Anlegen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren.
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
*/
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
import {
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
} from '../../services/auftraege/auftraege.js'
const router = Router()
router.use(requireAppwriteStaff)
async function ladeKundeMitSchalter(kundenId) {
const kunde = await ladeKunde(kundenId)
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
return kunde
}
async function ladeMitSchalter(id) {
const auftrag = await ladeAuftrag(id)
await ladeKundeMitSchalter(auftrag.kundenId)
return auftrag
}
const vonAdmin = (req) => req.appwriteUser?.email
router.get('/', async (req, res) => {
try {
const kundenId = req.query.kunde
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
await ladeKundeMitSchalter(kundenId)
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
}
})
router.post('/', async (req, res) => {
try {
const kundenId = req.body?.kundenId
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
}
})
router.get('/:id', async (req, res) => {
try {
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung laden', err)
}
})
router.patch('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
}
})
router.delete('/:id', async (req, res) => {
try {
await ladeMitSchalter(req.params.id)
await loescheEntwurf(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const auftrag = await ladeMitSchalter(req.params.id)
const entwurf = auftrag.status === 'entwurf'
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
return res.send(buffer)
} catch (err) {
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
}
})
export default router
+127
View File
@@ -0,0 +1,127 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, FREMD, VON, MELDUNG, festpreisEingabe, aufwandEingabe, kundeDoc, baueDeps } from './helpers/auftraegeAb.js'
const umg = await starteUmgebung()
const { aw, config } = umg
const { startTestApp, send } = await import('./support/testApp.js')
const { default: router } = await import('../server/routes/admin/auftraege.js')
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
const { legeEntwurfAn, sendeAuftrag, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
const app = await startTestApp({ '/api/admin/finanzen/auftraege': router })
after(async () => {
await app.close()
await umg.schliesse()
})
let kontext // Attrappen für PDF, Token und Mail des aktuellen Tests
beforeEach(async () => {
await umg.zuruecksetzen()
kontext = baueDeps()
setzeDepsFuerTests(kontext.deps)
})
const BASIS = '/api/admin/finanzen/auftraege'
const api = (methode, pfad, body, jwt = 'jwt-admin') => send(methode, BASIS + pfad, { headers: { Authorization: `Bearer ${jwt}` }, body })
const entwurfAnlegen = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })
const ZUGRIFF = [
['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/'], ['GET', '/a_1'], ['PATCH', '/a_1'], ['DELETE', '/a_1'], ['GET', '/a_1/pdf'],
['POST', '/a_1/senden'], ['POST', '/a_1/erinnern'], ['POST', '/a_1/neue-fassung'], ['POST', '/a_1/stornieren'], ['POST', '/a_1/projekt'],
]
test('Zugriff: ohne JWT 401, ohne Admin-Label 403 – bei jeder Route', async () => {
for (const [methode, pfad] of ZUGRIFF) {
const body = ['GET', 'DELETE'].includes(methode) ? undefined : {}
assert.equal((await send(methode, BASIS + pfad, { body })).status, 401, `${methode} ${pfad} ohne JWT`)
for (const jwt of ['jwt-staff', 'jwt-kunde']) assert.equal((await api(methode, pfad, body, jwt)).status, 403, `${methode} ${pfad} mit ${jwt}`)
}
})
test('Schalter: ist die Funktion für den Kunden aus, antworten Liste, Anlegen, Lesen, Ändern, Löschen und PDF mit 404 und der festen Meldung', async () => {
const a = await entwurfAnlegen() // angelegt, solange der Schalter an ist
config.auftraege.mode = 'off'
const routen = [['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/', festpreisEingabe()], ['GET', `/${a.id}`], ['PATCH', `/${a.id}`, { titel: 'x' }], ['DELETE', `/${a.id}`], ['GET', `/${a.id}/pdf`]]
for (const [methode, pfad, body] of routen) {
const r = await api(methode, pfad, body)
assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], `${methode} ${pfad}`)
}
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') // nichts wurde geändert oder gelöscht
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['anna@example.invalid'] // E-Mail des Kunden
assert.equal((await api('GET', `/${a.id}`)).status, 200)
config.auftraege.pilotEmails = ['jemand@example.invalid']
assert.equal((await api('GET', `/${a.id}`)).status, 404)
})
test('GET Liste: Aufträge des Kunden, neueste zuerst, ohne interne Felder; 400 ohne Kunde, 404 bei unbekanntem Kunden', async () => {
const a = await entwurfAnlegen()
const b = await legeEntwurfAn(aufwandEingabe(), { von: VON })
const r = await api('GET', `/?kunde=${KUNDE_ID}`)
assert.equal(r.status, 200)
assert.deepEqual(r.body.auftraege.map((x) => x.id), [b.id, a.id])
for (const x of r.body.auftraege) {
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in x, false, feld)
assert.equal(typeof x.pdfVorhanden, 'boolean')
}
const ohneKunde = await api('GET', '/')
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde fehlt.' }])
assert.equal((await api('GET', '/?kunde=c_gibtsnicht')).status, 404)
assert.equal((await api('GET', '/?kunde=../x')).status, 404)
})
test('POST: legt einen Entwurf an (200, AuftragTeam mit Texten); Fehler als { error } mit festen Meldungen', async () => {
const r = await api('POST', '/', festpreisEingabe())
assert.equal(r.status, 200)
const a = r.body.auftrag
assert.deepEqual([a.status, a.titel, a.summeCents, a.erstelltVon], ['entwurf', 'Website Beispiel', 80000, 'kenso@webklar.com'])
assert.deepEqual([typeof a.zahlungsplanText, a.pdfVorhanden, 'tokenHash' in a], ['string', false, false])
const ohnePositionen = await api('POST', '/', festpreisEingabe({ positionen: [] }))
assert.deepEqual([ohnePositionen.status, ohnePositionen.body], [400, { error: MELDUNG.positionen }])
const ohneKunde = await api('POST', '/', festpreisEingabe({ kundenId: undefined }))
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde: Bitte einen Kunden angeben.' }])
assert.equal((await api('POST', '/', festpreisEingabe({ kundenId: 'c_gibtsnicht' }))).status, 404)
})
test('GET, PATCH, DELETE: Entwurf lesen, ändern, löschen; gesendete AB ist unveränderlich (409); ungültige Kennungen 404', async () => {
const a = await entwurfAnlegen()
const gelesen = await api('GET', `/${a.id}`)
assert.deepEqual([gelesen.status, gelesen.body.auftrag.id], [200, a.id])
const geaendert = await api('PATCH', `/${a.id}`, { titel: 'Neu' })
assert.deepEqual([geaendert.status, geaendert.body.auftrag.titel], [200, 'Neu'])
const falsch = await api('PATCH', `/${a.id}`, { zahlungszielTage: 99 })
assert.deepEqual([falsch.status, falsch.body], [400, { error: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.' }])
const geloescht = await api('DELETE', `/${a.id}`)
assert.deepEqual([geloescht.status, geloescht.body], [200, { ok: true }])
assert.deepEqual((await api('GET', `/${a.id}`)).body, { error: MELDUNG.auftragFehlt })
for (const kennung of ['a%20b', 'x'.repeat(40), '..%2F..']) assert.equal((await api('GET', `/${kennung}`)).status, 404, kennung)
const b = await entwurfAnlegen()
await sendeAuftrag(b.id, { von: VON })
const patch = await api('PATCH', `/${b.id}`, { titel: 'Nachträglich' })
assert.deepEqual([patch.status, patch.body], [409, { error: MELDUNG.nurEntwurfAendern }])
const loeschen = await api('DELETE', `/${b.id}`)
assert.deepEqual([loeschen.status, loeschen.body], [409, { error: MELDUNG.nurEntwurfLoeschen }])
})
test('GET pdf: Entwurf als Vorschau, gesendete AB aus der gespeicherten Datei, mit Kopfzeilen', async () => {
const entwurf = await entwurfAnlegen()
const vorschau = await api('GET', `/${entwurf.id}/pdf`)
assert.equal(vorschau.status, 200)
assert.equal(vorschau.body, '%PDF-1.4 Entwurf')
assert.match(vorschau.headers.get('content-type'), /^application\/pdf/)
assert.equal(vorschau.headers.get('content-disposition'), 'inline; filename="Auftragsbestaetigung-Entwurf.pdf"')
assert.equal(vorschau.headers.get('cache-control'), 'no-store')
const { auftrag } = await sendeAuftrag(entwurf.id, { von: VON }) // Attrappen aus setzeDepsFuerTests
const gespeichert = await api('GET', `/${entwurf.id}/pdf`)
assert.equal(gespeichert.body, `%PDF-1.4 ${auftrag.nummer}`)
assert.equal(gespeichert.headers.get('content-disposition'), `inline; filename="${auftrag.nummer}.pdf"`)
assert.equal((await api('GET', '/a_gibtsnicht/pdf')).status, 404)
})
test('Unerwartete Fehler: 500 mit fester Meldung, nichts von Appwrite gelangt an die Seite', async () => {
mock.method(console, 'error', () => {})
const a = await entwurfAnlegen()
aw.failNext({ collection: config.collections.auftraege, method: 'GET', status: 500, message: 'GEHEIM-Appwrite-Detail' })
const r = await api('GET', `/${a.id}`)
assert.deepEqual([r.status, r.body], [500, { error: 'Interner Fehler.' }])
})