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:
1 parent
66ec8e0a44
commit
7d3114d7a3
2 files changed
+215
-2
No files matched your search
@@ -1,11 +1,97 @@
|
|||||||
/**
|
/**
|
||||||
* Gerüst von Strang L0: Auftragsbestätigungen (Admin). Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router).
|
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
|
||||||
* L3 füllt: Liste, Anlegen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren.
|
* 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 { Router } from 'express'
|
||||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
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()
|
const router = Router()
|
||||||
router.use(requireAppwriteStaff)
|
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
|
export default router
|
||||||
@@ -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.' }])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user