Files
Webklar-Kundenbereich/server/services/zeiten/speicher.js
T
ClaudeandClaude Opus 5.5 097036c9a5 Öffnungszeiten: Speicher mit Versionen und Cache
LRU-Speicher mit Ablaufzeit (lru.js), Cache-Instanz der öffentlichen Antwort
(cache.js: 500 Einträge, 30 s) und Appwrite-Speicher mit getrennten Versionen
für Kundendaten und Team-Einstellungen (speicher.js), alle Schreibzugriffe
unter der Sperre zeiten:<projectId>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:50:41 +00:00

285 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<projectId>` (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:<staffId>' (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:<staffId>'
* @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:<staffId>'
* @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<boolean>} 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<number>} 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
}