import { config } from '../../config.js' import { Query, deleteDocument, listDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { listCustomerFeatureDocs } from '../siteFeatures.js' import { ersteBuchungen } from './buchungen.js' import { holeOderNull } from './kontext.js' import { pruefeNow, pruefeText, zeitMs } from './util.js' /** * Entwuerfe des Terminplaners aufbewahren und loeschen (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 3.7). * * Ein Entwurf ist ein Funktionsdokument im Zustand `off` (portalFeatures, featureKey termine) samt Einstellung und Fragebogen * (termineConfig, gleiche ID), die ein Kunde vor der Bestellung ausfuellt. Bestellt er nie, bleiben diese Angaben nicht ewig liegen: * * - loescheEntwuerfe (Baustein des Tagesjobs, jobs.js): ein Entwurf, dessen letzte Aenderung laenger als ENTWURF_TAGE (90) Tage her * ist, verliert seine Einstellung und, wenn es nie in Betrieb oder bezahlt war, auch das Funktionsdokument. Die letzte Aenderung * ist der juengere Zeitpunkt aus `$updatedAt` des Funktionsdokuments (Zustandswechsel, z. B. nach einer abgebrochenen Zahlung) und * `termineConfig.updatedAt` (Speichern der Einstellung oder des Fragebogens; PUT /fragebogen aendert nur das termineConfig-Dokument). * - loescheEntwuerfeVonKunde (Kundenloeschung, customerAdmin.js): alle `off`-Entwuerfe des Kunden sofort und vollstaendig. * * Das Funktionsdokument bleibt, sobald es einmal in Betrieb oder bezahlt war (activatedAt, ticketId, disabledAt, unlockedByPurchase, * purchaseStatus paid), und ebenso das von E8 zurueckgesetzte (purchaseStatus zurueckgesetzt, dauerhafte Marke der Loeschung nach dem * Abo-Ende): sein siteKey steckt im Snippet auf der Website des Kunden und muss gueltig bleiben. Solche Dokumente verlieren hoechstens * ihre Einstellung. Nie angefasst werden ordered, setup, active und disabled (die Abfrage liest nur `off`). * * Sicherheit gegen Rennen: Die Liste ist nur die Vorauswahl. Vor jedem Loeschen werden unter den Sperren * feature-order:: (Bestellung), feature-admin: (Team) und termine-cfg: (Speichern der Einstellung) das * Funktionsdokument und die Einstellung frisch gelesen und alle Bedingungen erneut geprueft (noch `off`, Frist noch ueberschritten, * keine Buchung zur Funktion). Im Zweifel (Lesefehler, unlesbares Datum) wird nichts geloescht. * * Deckel: eine Seite der Liste hat hoechstens SEITE (100) Dokumente (sortiert nach $id, per cursorAfter). Die Liste wird ganz gelesen, * bevor das erste Dokument geloescht wird (ein geloeschtes Dokument taugt nicht mehr als Cursor); nach MAX_GEPRUEFT (2000) Dokumenten * endet sie mit einer Logzeile. Danach werden hoechstens MAX_GELOESCHT (100) Entwuerfe je Lauf geloescht; die uebrigen faelligen * zaehlen als `uebrig`. Geloeschte Entwuerfe fehlen am naechsten Tag in der Liste, der Rest laeuft also an Folgetagen weiter. * Einzelfehler zaehlen in `fehler` und brechen den Lauf nicht ab (Log ohne Kennungen: weder Kunden-, Projekt- und Funktions-IDs noch * siteKeys). */ export const ENTWURF_TAGE = 90 export const MAX_GEPRUEFT = 2000 export const MAX_GELOESCHT = 100 const FUNKTION = 'termine' const SEITE = 100 const TAG_MS = 24 * 60 * 60 * 1000 /** So viele Einzelfehler stehen je Lauf im Log; die Zahl steht danach in einer Schlusszeile (fehler im Ergebnis zaehlt alle). */ const MAX_FEHLER_IM_LOG = 5 const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}` /** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */ const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' /** Dokument loeschen. => true, wenn es geloescht wurde, false, wenn es schon weg war (404 ist kein Fehler). */ async function loescheOderWeg(collectionId, id) { try { await deleteDocument(collectionId, id) return true } catch (err) { if (dokumentFehlt(err)) return false throw err } } /** * Zeitpunkt (ms) der letzten Aenderung eines Entwurfs: der juengere aus $updatedAt des Funktionsdokuments und updatedAt der * Einstellung (fehlt das Attribut, deren $updatedAt). NaN, wenn sich ein vorhandener Zeitpunkt nicht lesen laesst (dann gilt der * Entwurf nie als faellig). */ function letzteAenderung(funktion, einstellung) { const zeiten = [zeitMs(funktion.$updatedAt)] if (einstellung) { const eigene = zeitMs(einstellung.updatedAt) zeiten.push(Number.isFinite(eigene) ? eigene : zeitMs(einstellung.$updatedAt)) } return zeiten.every(Number.isFinite) ? Math.max(...zeiten) : Number.NaN } /** Darf auch das Funktionsdokument weg? Nur, wenn es nie in Betrieb oder bezahlt war und nicht von E8 zurueckgesetzt wurde (siteKey). */ const nieInBetrieb = (funktion) => !funktion.activatedAt && !funktion.ticketId && !funktion.disabledAt && !funktion.unlockedByPurchase && funktion.purchaseStatus !== 'paid' && funktion.purchaseStatus !== 'zurueckgesetzt' /** * Einen Entwurf entfernen: unter den drei Sperren frisch lesen, pruefen, dann die Einstellung loeschen und, wenn es zulaessig ist, * das Funktionsdokument. `grenze` (ms): nur Entwuerfe, deren letzte Aenderung davor liegt; null = Kundenloeschung, alles was `off` * ist, ohne Frist und ohne Pruefung auf Spuren (der siteKey stirbt mit dem Kunden). Buchungen zur Funktion halten den Entwurf in * beiden Faellen zurueck (ein `off`-Dokument hat keine; waeren doch welche da, blieben sonst Gastdaten ohne Funktion zurueck). * Die Einstellung wird zuerst geloescht: scheitert danach das Dokument, holt der naechste Lauf es nach (die Frist ist unveraendert). * Ein Lesefehler oder ein Fehler beim Loeschen wirft; der Aufrufer zaehlt ihn. * => true, wenn etwas geloescht wurde (Einstellung oder Funktionsdokument), sonst false */ function entferne(kandidat, grenze) { const { $id: id, customerId, projectId } = kandidat if (!id || !customerId || !projectId) throw new Error('Entwurf ohne Kunde oder Projekt') return withLocks([`feature-order:${customerId}:${projectId}`, `feature-admin:${projectId}`, `termine-cfg:${id}`], async () => { const funktion = await holeOderNull(config.collections.portalFeatures, id) if (!funktion || funktion.featureKey !== FUNKTION || funktion.state !== 'off') return false if (funktion.customerId !== customerId || funktion.projectId !== projectId) return false const einstellung = await holeOderNull(config.collections.termineConfig, id) if (grenze !== null && !(letzteAenderung(funktion, einstellung) < grenze)) return false if ((await ersteBuchungen(id, 1)).length > 0) return false let geloescht = false if (einstellung && (await loescheOderWeg(config.collections.termineConfig, id))) geloescht = true if ((grenze === null || nieInBetrieb(funktion)) && (await loescheOderWeg(config.collections.portalFeatures, id))) geloescht = true return geloescht }) } /** * Entwuerfe nach 90 Tagen loeschen (Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung). Wirft nie wegen eines * einzelnen Dokuments oder einer nicht lesbaren Liste (das zaehlt in `fehler`); nur eine ungueltige Uhrzeit ist ein TypeError. * @param {Date} now * @returns {Promise<{ geprueft: number, geloescht: number, uebrig: number, fehler: number }>} * geprueft = gelesene Kandidaten (hoechstens MAX_GEPRUEFT), geloescht = Entwuerfe, bei denen Einstellung oder Funktionsdokument * geloescht wurden (hoechstens MAX_GELOESCHT), uebrig = faellig wirkende Kandidaten, die der Deckel aufhielt, fehler = Einzelfehler */ export async function loescheEntwuerfe(now) { const nowMs = pruefeNow(now, 'loescheEntwuerfe') const grenze = nowMs - ENTWURF_TAGE * TAG_MS const erg = { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 } const melde = (was, err) => { erg.fehler += 1 if (erg.fehler <= MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${was}: ${fehlerText(err)}`) } // 1 Vorauswahl: die ganze Liste lesen, bevor etwas geloescht wird. $updatedAt des Funktionsdokuments kann nur wachsen, ein // Dokument, das schon hier juenger als die Frist ist, bleibt also auch nach dem frischen Lesen juenger. const faellig = [] let cursor = null liste: for (;;) { let seite try { seite = await listDocuments(config.collections.portalFeatures, [ Query.equal('featureKey', FUNKTION), Query.equal('state', 'off'), Query.orderAsc('$id'), Query.limit(SEITE), ...(cursor ? [Query.cursorAfter(cursor)] : []), ]) } catch (err) { melde('Liste der Entwürfe nicht gelesen', err) break } for (const kandidat of seite) { if (erg.geprueft >= MAX_GEPRUEFT) { console.warn(`[termine-entwuerfe] Prüfung endet nach ${MAX_GEPRUEFT} Dokumenten, der Rest folgt an den nächsten Tagen`) break liste } erg.geprueft += 1 if (zeitMs(kandidat.$updatedAt) < grenze) faellig.push(kandidat) } if (seite.length < SEITE) break cursor = seite[seite.length - 1].$id } // 2 Loeschen, je Kandidat unter den Sperren mit frischem Lesen (entferne); am Deckel zaehlt der Rest als uebrig for (const kandidat of faellig) { if (erg.geloescht >= MAX_GELOESCHT) { erg.uebrig += 1 continue } try { if (await entferne(kandidat, grenze)) erg.geloescht += 1 } catch (err) { melde('Entwurf nicht bearbeitet', err) } } if (erg.uebrig > 0) { console.warn(`[termine-entwuerfe] Löschung endet am Deckel von ${MAX_GELOESCHT} je Lauf, ${erg.uebrig} Entwürfe offen, der Rest folgt an den nächsten Tagen`) } if (erg.fehler > MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${erg.fehler - MAX_FEHLER_IM_LOG} weitere Fehler (nicht einzeln protokolliert)`) return erg } /** * Kunde wird geloescht: alle seine Entwuerfe (Terminplaner im Zustand `off`) vollstaendig entfernen, Funktionsdokument und * Einstellung, ohne Frist. Andere Zustaende bleiben (disableCustomerFeatures schaltet sie ab, die Loeschfrist von 30 Tagen * entscheidet), ebenso andere Funktionen und andere Kunden. Scheitert ein Entwurf, werden die uebrigen trotzdem entfernt und danach der * erste Fehler weitergereicht (der Aufrufer protokolliert ihn und loescht den Kunden trotzdem). * @param {string} customerId * @returns {Promise<{ geloescht: number }>} */ export async function loescheEntwuerfeVonKunde(customerId) { pruefeText(customerId, 'customerId') const entwuerfe = (await listCustomerFeatureDocs(customerId)).filter( (doc) => doc.featureKey === FUNKTION && doc.state === 'off' && doc.customerId === customerId, ) let geloescht = 0 let ersterFehler = null for (const entwurf of entwuerfe) { try { if (await entferne(entwurf, null)) geloescht += 1 } catch (err) { ersterFehler ??= err } } if (ersterFehler) throw ersterFehler return { geloescht } }