/** * 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) }