import { config } from '../../config.js' import { createDocument, deleteDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { vergissZeiten, zeitenCache } from './cache.js' import { normalisiereTeam, normalisiereZeiten } from './pruefen.js' /** * Oeffnungszeiten live: Speicher in Appwrite (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1). * Ein Dokument je Funktion in config.collections.oeffnungszeiten, Dokument-ID = ID des portalFeatures-Dokuments (feature.$id). * * Zwei getrennte Versionen (optimistische Sperre): `version` zaehlt die Speichervorgaenge des Kunden (daten), `teamVersion` die * des Teams (team); ein Kunde im Editor und das Team in WOMS stoeren sich so nicht. 0 = noch nie gespeichert. Speichert das Team, * bevor der Kunde Zeiten eingetragen hat, entsteht das Dokument mit version 0 und ohne daten (ladeZeiten: daten null); der * Kunde speichert danach mit version 0 und bekommt 1. * Weicht die mitgebrachte Version von der gespeicherten ab (auch wenn sie fehlt oder keine ganze Zahl ist), passiert nichts und * es folgt { status: 409, code: 'ZEITEN_VERALTET' }; ungueltige Eingaben ergeben { status: 400, fehler }. * * Alle Schreibzugriffe (Zeiten, Team, Ersatz-Hash, Loeschen) laufen unter der Sperre `zeiten:` (nicht wiederbetretbar: * Aufrufer duerfen sie nicht selbst halten); gelesen und verglichen wird INNERHALB der Sperre. Nach jedem Schreibversuch (auch * einem fehlgeschlagenen: unklar, was in der Datenbank steht) verwirft vergissZeiten die oeffentliche Antwort dieses siteKeys. */ const veraltet = () => Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' }) const ungueltig = (nachricht, fehler) => Object.assign(new Error(nachricht), { status: 400, fehler }) const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) function pruefeText(wert, name) { if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) } function pruefeFeature(feature) { pruefeText(feature?.$id, 'feature.$id') pruefeText(feature.projectId, 'feature.projectId') } function pruefeJetzt(now) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum') return now } /** updatedBy: 'kunde' oder 'team:' (Spalte 64 Zeichen). */ const vonWem = (by, standard) => String(by ?? '').trim().slice(0, 64) || standard /** Sperre aller Schreibzugriffe auf die Zeiten eines Projekts. */ const sperre = (projectId) => `zeiten:${projectId}` /** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt, das ist ein Fehler und wird weitergereicht). */ const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' async function holeOderNull(collectionId, id) { try { return await getDocument(collectionId, id) } catch (err) { if (dokumentFehlt(err)) return null throw err } } /** Dokument loeschen; war es schon weg, ist das kein Fehler. => true, wenn dieser Aufruf es geloescht hat. */ async function loescheOderIgnoriere(collectionId, id) { try { await deleteDocument(collectionId, id) return true } catch (err) { if (dokumentFehlt(err)) return false throw err } } const versionVon = (doc, feld) => (doc && Number.isInteger(doc[feld]) ? doc[feld] : 0) /** JSON-Objekt aus einem Textfeld; fehlt es: null (still), unlesbar: null mit einer Logzeile ohne Inhalt (der gehoert nicht ins Log). */ function liesObjekt(doc, feld) { const roh = doc?.[feld] if (typeof roh !== 'string' || roh === '') return null try { const wert = JSON.parse(roh) if (istObjekt(wert)) return wert } catch { // unten gemeldet } console.error(`[zeiten-speicher] ${feld} von ${doc.$id} ist unlesbar`) return null } const standardTeam = () => normalisiereTeam({}).team /** Team-Einstellungen aus dem Dokument; fehlend, unlesbar oder nicht mehr gueltig (von Hand veraendert) ergibt die Vorgabe. */ function liesTeam(doc) { const roh = liesObjekt(doc, 'team') if (!roh) return standardTeam() const erg = normalisiereTeam(roh) if (erg.fehler) { console.error(`[zeiten-speicher] team von ${doc.$id} ist ungueltig`) return standardTeam() } return erg.team } function grundfelder(feature) { return { customerId: feature.customerId ?? '', projectId: feature.projectId, featureId: feature.$id } } /** * Schreibt felder in das Dokument der Funktion (aendern; gibt es keins, mit den Grundfeldern anlegen, beide Versionen 0, soweit * felder sie nicht setzt). Legt ein anderer Vorgang das Dokument zwischen Lesen und Anlegen an (Appwrite 409), ist der Stand veraltet. * Danach (auch bei Fehlern) ist die oeffentliche Antwort dieses siteKeys vergessen. */ async function schreibe(feature, vorhanden, felder) { const col = config.collections.oeffnungszeiten try { if (vorhanden) { await updateDocument(col, feature.$id, felder) return } try { await createDocument(col, { ...grundfelder(feature), version: 0, teamVersion: 0, ...felder }, feature.$id) } catch (err) { if (err?.status === 409) throw veraltet() throw err } } finally { vergissZeiten(feature.siteKey) } } /** * Die gespeicherten Zeiten einer Funktion. * @param {string} featureId ID des portalFeatures-Dokuments (= ID des oeffnungszeiten-Dokuments) * @returns {Promise<{ daten: object|null, version: number, team: object, teamVersion: number, ersatzHash: string }|null>} * null, wenn es kein Dokument gibt. daten ist null, wenn der Kunde noch nichts gespeichert hat (nur Team-Einstellungen oder * Ersatz-Hash vorhanden) oder die Daten unlesbar sind (version bleibt die des Dokuments: wer sie ueberschreiben will, speichert * mit genau dieser Version). team ist immer vollstaendig (Vorgabe, wenn nichts oder nichts Gueltiges gespeichert ist), * ersatzHash '' ohne Eintrag. */ export async function ladeZeiten(featureId) { pruefeText(featureId, 'featureId') const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId) if (!doc) return null return { daten: liesObjekt(doc, 'daten'), version: versionVon(doc, 'version'), team: liesTeam(doc), teamVersion: versionVon(doc, 'teamVersion'), ersatzHash: typeof doc.ersatzHash === 'string' ? doc.ersatzHash : '', } } /** * Speichert die Zeiten des Kunden (oder des Teams im Namen des Kunden). Pruefen (normalisiereZeiten, mit den gespeicherten Daten als * bisher fuer die unveraenderlichen Kurznamen) und Schreiben laufen unter der Sperre, nach der Versionspruefung: ist die Version * veraltet, gewinnt 409, auch bei falscher Eingabe. * @param {{ feature: { $id: string, customerId: string, projectId: string, siteKey?: string }, * eingabe: object, version: number, by?: string, now?: Date }} p * version = Version, auf der die Aenderung beruht (0 = noch nie gespeichert); by = 'kunde' (Vorgabe) | 'team:' * @returns {Promise<{ daten: object, version: number }>} das Normalisierte, wie gespeichert, mit der neuen Version * @throws {Error} status 400 mit fehler (je Pfad eine Meldung) / status 409 mit code 'ZEITEN_VERALTET' */ export async function speichereZeiten({ feature, eingabe, version, by = 'kunde', now = new Date() } = {}) { pruefeFeature(feature) const jetzt = pruefeJetzt(now) return withLocks([sperre(feature.projectId)], async () => { const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) const aktuell = versionVon(vorhanden, 'version') if (version !== aktuell) throw veraltet() const erg = normalisiereZeiten(eingabe, { bisher: vorhanden ? liesObjekt(vorhanden, 'daten') : null, now: jetzt }) if (erg.fehler) throw ungueltig('Bitte prüfe deine Eingaben.', erg.fehler) await schreibe(feature, vorhanden, { daten: JSON.stringify(erg.daten), version: aktuell + 1, updatedAt: jetzt.toISOString(), updatedBy: vonWem(by, 'kunde'), }) return { daten: erg.daten, version: aktuell + 1 } }) } /** * Speichert die Team-Einstellungen (nur ueber WOMS); eigene Version teamVersion, die Kundendaten und deren Version bleiben. * Gibt es noch kein Dokument, wird eins ohne daten angelegt (version 0). * @param {{ feature: object, eingabe: object, teamVersion: number, by?: string, now?: Date }} p by = 'team:' * @returns {Promise<{ team: object, teamVersion: number }>} * @throws {Error} status 400 mit fehler / status 409 mit code 'ZEITEN_VERALTET' */ export async function speichereTeam({ feature, eingabe, teamVersion, by = 'team', now = new Date() } = {}) { pruefeFeature(feature) const jetzt = pruefeJetzt(now) return withLocks([sperre(feature.projectId)], async () => { const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) const aktuell = versionVon(vorhanden, 'teamVersion') if (teamVersion !== aktuell) throw veraltet() const erg = normalisiereTeam(eingabe) if (erg.fehler) throw ungueltig('Bitte prüfe die Team-Einstellungen.', erg.fehler) await schreibe(feature, vorhanden, { team: JSON.stringify(erg.team), teamVersion: aktuell + 1, updatedAt: jetzt.toISOString(), updatedBy: vonWem(by, 'team'), }) return { team: erg.team, teamVersion: aktuell + 1 } }) } /** * Merkt den Hash des Hauptplan-Wochenplans zum Zeitpunkt des Ersatz-HTMLs (wochenplanHash, '' = zuruecksetzen). Fasst weder * die Versionen noch updatedAt/updatedBy noch Daten und Team an: kein Kunde und kein Team-Editor wird dadurch veraltet. * Gibt es noch kein Dokument, entsteht eins ohne daten (version 0). */ export async function setzeErsatzHash(feature, hash) { pruefeFeature(feature) if (typeof hash !== 'string' || hash.length > 64) throw new TypeError('hash muss ein Text mit hoechstens 64 Zeichen sein') await withLocks([sperre(feature.projectId)], async () => { const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) await schreibe(feature, vorhanden, vorhanden ? { ersatzHash: hash } : { ersatzHash: hash, updatedAt: new Date().toISOString(), updatedBy: 'system' }) }) } /** Loescht alle Erinnerungsmerker einer Funktion (mehrere Seiten moeglich). */ async function loescheMerker(featureId) { const merker = await listAllDocuments(config.collections.zeitenErinnerungen, [Query.equal('featureId', featureId), Query.select(['featureId'])], { pageSize: 100 }) for (const doc of merker) await loescheOderIgnoriere(config.collections.zeitenErinnerungen, doc.$id) } /** Loescht Dokument (unter der Sperre seines Projekts) und Merker einer Funktion. => true, wenn dieser Aufruf das Dokument geloescht hat. */ async function loescheFunktion(featureId, projectId) { const dokumentGeloescht = await withLocks([sperre(projectId)], () => loescheOderIgnoriere(config.collections.oeffnungszeiten, featureId)) await loescheMerker(featureId) return dokumentGeloescht } /** * Loescht die Zeiten einer Funktion samt Erinnerungsmerkern (Abo-Ende nach der Frist). Ein fehlendes Dokument ist kein Fehler. * Die oeffentliche Antwort wird verworfen (der siteKey ist hier nicht bekannt: der ganze Speicher wird geleert, Loeschen ist selten). * @returns {Promise} true, wenn es ein Dokument gab und es geloescht wurde */ export async function loescheZeiten(featureId) { pruefeText(featureId, 'featureId') try { const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId) if (!doc) { await loescheMerker(featureId) return false } return await loescheFunktion(featureId, doc.projectId) } finally { zeitenCache.leere() } } /** * Loescht alle Zeiten-Dokumente eines Kunden samt Erinnerungsmerkern (Kunden-Loeschen). Jedes Dokument unter der Sperre seines * Projekts; ein Fehler bricht nicht ab: alle uebrigen werden trotzdem geloescht und der erste Fehler kommt am Ende (ein neuer * Aufruf raeumt den Rest). * @returns {Promise} Anzahl der geloeschten Dokumente */ export async function loescheZeitenVonKunde(customerId) { pruefeText(customerId, 'customerId') let geloescht = 0 let ersterFehler = null try { const docs = await listAllDocuments(config.collections.oeffnungszeiten, [Query.equal('customerId', customerId), Query.select(['projectId'])], { pageSize: 100 }) for (const doc of docs) { try { if (await loescheFunktion(doc.$id, doc.projectId)) geloescht += 1 } catch (err) { ersterFehler ??= err } } } finally { zeitenCache.leere() } if (ersterFehler) throw ersterFehler return geloescht }