Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen
Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage => Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie). Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen (noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000 geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)". loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig, Fehler werden nur protokolliert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29a38a9592
commit
91b1a3bcd8
6 files changed
+827
-24
No files matched your search
@@ -0,0 +1,204 @@
|
||||
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:<kunde>:<projekt> (Bestellung), feature-admin:<projekt> (Team) und termine-cfg:<id> (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 }
|
||||
}
|
||||
Reference in new issue
Block a user