diff --git a/server/services/auftraege/auftragStore.js b/server/services/auftraege/auftragStore.js new file mode 100644 index 0000000..dcb2545 --- /dev/null +++ b/server/services/auftraege/auftragStore.js @@ -0,0 +1,190 @@ +/** + * Speicher der Auftragsbestätigungen (Collection auftraege, Übersicht V1/V3/V4). + * Die Feldlisten stammen aus AUFTRAEGE_COLLECTIONS (Typ und Größe der Spalten); nur vier Spalten sind JSON und + * heißen im Auftrag anders: kundeJson = kunde, zeitquelleJson = zeitquelle, positionenJson = positionen, + * zahlungsplanJson = zahlungsplan. Programmfehler (falsche Typen, ungültige Filter) sind TypeError, ein zu langer + * Text ist ein AuftragError 400. Schreibende Aufrufer halten bei Statusänderungen sperreAuftrag(id) (konstanten.js). + */ +import { config } from '../../config.js' +import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { AuftragError } from './fehler.js' +import { LIMITS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js' +import { AUFTRAEGE_COLLECTIONS } from './schema.js' + +const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' } +const SPALTE_ZU_FELD = (spalte) => JSON_SPALTEN[spalte] || spalte + +const SPALTEN = AUFTRAEGE_COLLECTIONS.auftraege.attributes +const TEXT_SPALTEN = SPALTEN.filter((a) => a.type === 'string' && !JSON_SPALTEN[a.key]) +const ZAHL_SPALTEN = SPALTEN.filter((a) => a.type === 'integer').map((a) => a.key) +const DATUM_SPALTEN = SPALTEN.filter((a) => a.type === 'datetime').map((a) => a.key) +const SPALTEN_NACH_FELD = new Map(SPALTEN.map((a) => [SPALTE_ZU_FELD(a.key), a])) + +// Appwrite-Regel für Dokument-IDs +const DOKUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const gueltigeId = (id) => typeof id === 'string' && DOKUMENT_ID.test(id) +const TOKEN_HASH = /^[0-9a-f]{64}$/ +const AB_NUMMER = /^AB-\d{4}-\d{4}$/ + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function parseJson(text) { + if (typeof text !== 'string' || text === '') return undefined + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +function zeitpunkt(wert) { + if (wert === null || wert === undefined || wert === '') return null + const datum = new Date(wert) + return Number.isNaN(datum.getTime()) ? null : datum.toISOString() +} + +export function docToAuftrag(doc) { + const auftrag = { id: doc.$id } + for (const { key } of TEXT_SPALTEN) auftrag[key] = typeof doc[key] === 'string' ? doc[key] : '' + for (const key of ZAHL_SPALTEN) auftrag[key] = Number.isSafeInteger(doc[key]) ? doc[key] : null + auftrag.zahlungszielTage = Number.isSafeInteger(doc.zahlungszielTage) ? doc.zahlungszielTage : LIMITS.standardZahlungszielTage + auftrag.fruehbeginn = doc.fruehbeginn === true + for (const key of DATUM_SPALTEN) auftrag[key] = zeitpunkt(doc[key]) + auftrag.erstelltAm = zeitpunkt(doc.erstelltAm) ?? zeitpunkt(doc.$createdAt) + auftrag.geaendertAm = zeitpunkt(doc.geaendertAm) ?? zeitpunkt(doc.$updatedAt) + + const kunde = parseJson(doc.kundeJson) + auftrag.kunde = istObjekt(kunde) ? kunde : null + const zeitquelle = parseJson(doc.zeitquelleJson) + auftrag.zeitquelle = Array.isArray(zeitquelle) + ? zeitquelle.filter((id) => typeof id === 'string' && id.trim() !== '').map((id) => id.trim()) + : [] + const positionen = parseJson(doc.positionenJson) + auftrag.positionen = Array.isArray(positionen) ? positionen.filter(istObjekt) : [] + const plan = parseJson(doc.zahlungsplanJson) + auftrag.zahlungsplan = istObjekt(plan) + ? { + art: ZAHLUNGSPLAN_ARTEN.includes(plan.art) ? plan.art : 'nach_abnahme', + prozent: typeof plan.prozent === 'number' && Number.isFinite(plan.prozent) ? plan.prozent : null, + text: typeof plan.text === 'string' ? plan.text : '', + } + : { art: 'nach_abnahme', prozent: null, text: '' } + return auftrag +} + +function pruefeLaenge(feld, text, spalte) { + if (text.length > spalte.size) throw new AuftragError(400, `Das Feld ${feld} ist zu lang.`) + return text +} + +/** + * Wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag): id, $… und + * fremde Schlüssel bleiben weg, undefined fehlt, null löscht (Text → '', Zahl und Datum → null). + */ +export function auftragToDoc(auftrag) { + const doc = {} + for (const [feld, wert] of Object.entries(auftrag)) { + const spalte = SPALTEN_NACH_FELD.get(feld) + if (!spalte || wert === undefined) continue + const name = spalte.key + if (JSON_SPALTEN[name]) { + doc[name] = wert === null ? '' : pruefeLaenge(feld, JSON.stringify(wert), spalte) + } else if (spalte.type === 'string') { + if (wert === null) doc[name] = '' + else if (typeof wert === 'string') doc[name] = pruefeLaenge(feld, wert, spalte) + else throw new TypeError(`Auftrag.${feld} muss Text sein.`) + } else if (spalte.type === 'integer') { + if (wert !== null && !Number.isSafeInteger(wert)) throw new TypeError(`Auftrag.${feld} muss eine ganze Zahl oder null sein.`) + doc[name] = wert + } else if (spalte.type === 'boolean') { + if (typeof wert !== 'boolean') throw new TypeError(`Auftrag.${feld} muss ein Wahrheitswert sein.`) + doc[name] = wert + } else if (spalte.type === 'datetime') { + if (wert === null) doc[name] = null + else { + const iso = typeof wert === 'string' && wert !== '' ? zeitpunkt(wert) : wert instanceof Date ? zeitpunkt(wert) : null + if (!iso) throw new TypeError(`Auftrag.${feld} muss ein Zeitpunkt oder null sein.`) + doc[name] = iso + } + } + } + return doc +} + +const nichtGefunden = () => new AuftragError(404, 'Auftragsbestätigung nicht gefunden.') + +export async function getAuftrag(id) { + if (!gueltigeId(id)) return null + try { + return docToAuftrag(await getDocument(config.collections.auftraege, id)) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +function filterAbfragen(filter, { text = [], zahl = [] }) { + const abfragen = [] + for (const key of text) { + if (filter[key] === undefined) continue + if (typeof filter[key] !== 'string' || filter[key] === '') throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`) + abfragen.push(Query.equal(key, filter[key])) + } + for (const key of zahl) { + if (filter[key] === undefined) continue + if (!Number.isSafeInteger(filter[key])) throw new TypeError(`Filter ${key} muss eine ganze Zahl sein.`) + abfragen.push(Query.equal(key, filter[key])) + } + return abfragen +} + +/** Neueste zuerst. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */ +export async function listAuftraege(filter = {}) { + const abfragen = filterAbfragen(filter, { text: ['kundenId', 'projektId'], zahl: ['jahr'] }) + const docs = await listAllDocuments(config.collections.auftraege, abfragen) + return docs + .map(docToAuftrag) + .sort((a, b) => (b.erstelltAm || '').localeCompare(a.erstelltAm || '') || b.id.localeCompare(a.id)) +} + +export async function createAuftrag(daten) { + const jetzt = new Date().toISOString() + const doc = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt }) + return docToAuftrag(await createDocument(config.collections.auftraege, doc)) +} + +export async function updateAuftrag(id, patch) { + if (!gueltigeId(id)) throw nichtGefunden() + const doc = auftragToDoc({ ...patch, geaendertAm: new Date().toISOString() }) + try { + return docToAuftrag(await updateDocument(config.collections.auftraege, id, doc)) + } catch (err) { + if (err?.status === 404) throw nichtGefunden() + throw err + } +} + +export async function deleteAuftrag(id) { + if (!gueltigeId(id)) return + try { + await deleteDocument(config.collections.auftraege, id) + } catch (err) { + if (err?.status !== 404) throw err + } +} + +async function findeEine(feld, wert) { + const docs = await listDocuments(config.collections.auftraege, [Query.equal(feld, wert), Query.limit(1)]) + return docs.length ? docToAuftrag(docs[0]) : null +} + +/** Nur ein 64-stelliger Kleinbuchstaben-Hex-Hash führt zu einer Abfrage (ein leerer Hash träfe jeden Entwurf). */ +export async function findAuftragByTokenHash(hash) { + if (typeof hash !== 'string' || !TOKEN_HASH.test(hash)) return null + return findeEine('tokenHash', hash) +} + +export async function findAuftragByNummer(nummer) { + if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null + return findeEine('nummer', nummer) +} diff --git a/test/auftraegeAuftragStore.test.js b/test/auftraegeAuftragStore.test.js new file mode 100644 index 0000000..89bb61c --- /dev/null +++ b/test/auftraegeAuftragStore.test.js @@ -0,0 +1,152 @@ +// test/auftraegeAuftragStore.test.js +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite +// ("+00:00") und dem Standard-Limit 25. +const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } = + await import('../server/services/auftraege/auftragStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') + +const COL = config.collections.auftraege +beforeEach(async () => { + for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id) +}) + +const ENTWURF = { + kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }], + summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com', +} +const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm' +const HASH = 'a'.repeat(64) +const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder }) + +test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => { + const vor = Date.now() + const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' }) + assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3') + assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + assert.equal(auftrag.status, 'entwurf') + assert.deepEqual( + { nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn }, + { nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false }, + ) + assert.deepEqual(auftrag.positionen, ENTWURF.positionen) + assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan) + assert.deepEqual(auftrag.zeitquelle, ['t1']) + assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null]) + assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + assert.equal(auftrag.geaendertAm, auftrag.erstelltAm) + assert.ok(Date.parse(auftrag.erstelltAm) >= vor) + const doc = aw.docs(COL).get(auftrag.id) + assert.equal(doc.status, 'entwurf') + assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen)) + assert.equal(doc.zeitquelleJson, '["t1"]') + assert.deepEqual(await getAuftrag(auftrag.id), auftrag) +}) + +test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => { + const auftrag = docToAuftrag({ + $id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3, + kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]', + gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000, + $createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00', + }) + assert.equal(auftrag.id, 'x1') + assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000]) + assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']]) + assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' }) + assert.equal(auftrag.zahlungszielTage, 14) + assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null]) + assert.equal(auftrag.fruehbeginn, true) + assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z']) +}) + +test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => { + const doc = auftragToDoc({ + id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' }, + zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' }, + gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null, + }) + assert.deepEqual(doc, { + status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]', + zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '', + }) + assert.equal(auftragToDoc({ kunde: null }).kundeJson, '') +}) + +test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => { + assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message)) + assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message)) + assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) })) + for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) { + assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch)) + } +}) + +test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => { + seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' }) + assert.equal((await getAuftrag('u1')).titel, 'Alt') + for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id)) + + const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' }) + assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet']) + assert.equal(auftrag.positionen[0].menge, 2) + assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z') + assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z') + assert.equal(aw.docs(COL).get('u1').titel, 'Neu') + for (const id of ['gibtsnicht', '../x']) { + await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id) + } + + await deleteAuftrag('u1') + assert.equal(aw.docs(COL).size, 0) + await deleteAuftrag('u1') + await deleteAuftrag('../x') +}) + +test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => { + seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' }) + seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' }) + seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' }) + seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' }) + const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id) + assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1']) + assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1']) + assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1']) + assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), []) + for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` }) + assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30) +}) + +test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => { + seed('a1') + for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) { + await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter)) + } +}) + +test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => { + seed('e1', { nummer: '', tokenHash: '' }) + seed('e2') + seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH }) + assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1') + assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1') + assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null) + assert.equal(await findAuftragByNummer('AB-2026-0002'), null) + for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash)) + for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer)) +})