Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off

Bei Grund abo_ende prüft die Löschung vor jedem unumkehrbaren Schritt frisch,
ob das Wartungs-Abo wieder läuft (Lesefehler: nichts löschen). Sind alle
Buchungen und die Einstellung weg, wird das Funktionsdokument unter
feature-admin und termine-cfg auf off zurückgesetzt (siteKey bleibt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 17:57:41 +00:00
1 parent 91e7f7d8a3
commit 30701ba277
2 files changed
+408 -47

No files matched your search

+118 -37
View File
@@ -1,6 +1,8 @@
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments } from '../appwriteAdmin.js'
import { aboLage } from '../aboStatus.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { stateFields } from '../siteFeatures.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import { gleicheAboAb } from './aboAbgleich.js'
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
@@ -16,6 +18,7 @@ import {
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
@@ -39,6 +42,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt,
* nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler,
* Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js)
* Abgeschaltete Funktionen: nach LOESCHFRIST_TAGE (30) Tagen Buchungen, Zellen, Einstellung loeschen
* (Grund abo_ende: vor jedem unumkehrbaren Schritt frisch pruefen, ob das Wartungs-Abo wieder laeuft),
* danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt)
*
* Gemeinsame Regeln:
* - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige
@@ -64,6 +70,7 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt;
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
* Das Funktionsdokument wird danach auf `off` gesetzt und fehlt damit in der Liste des naechsten Tages (loescheAbgeschaltete).
* Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens
* config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`.
*/
@@ -73,7 +80,8 @@ const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Frist zwischen dem Abschalten (disabledAt) und der Loeschung: eine Zahl fuer Anzeige, Mails, Rechtstexte und diesen Job (frist.js). */
const ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
@@ -522,35 +530,109 @@ async function loeschfristen(lauf, now) {
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate leert disabledAt) und ein Wartungs-Abo zurueckkommen
* kann: featureKey termine, state disabled und disabledAt vor der Grenze. Bei Grund `abo_ende` (nur dort haengt die Loeschung am Abo)
* zusaetzlich frisch aboLage (nie der Memo): laeuft das Wartungs-Abo wieder, gilt sie nicht als abgeschaltet. Fuer `team`,
* `kunde_geloescht`, `abo_ende_vor_einrichtung` und Altbestand ohne Grund gibt es keine Abo-Abfrage. mitAbo false: nichts
* Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst false mit Log, ohne Wurf – auch bei einem
* Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht.
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
async function nochAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) {
let frisch
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
frisch = await getDocument(config.collections.portalFeatures, funktionId)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
return false
}
if (!(frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
return false
}
if (!mitAbo || frisch.disabledReason !== 'abo_ende') return true
try {
if (!frisch.customerId) throw new Error('Funktion ohne Kunde')
if (!(await aboLage(frisch.customerId)).laeuft) return true
console.warn(`[termine-jobs] ${lauf.job}: Abo läuft wieder, Löschung abgebrochen`)
} catch (err) {
melde(lauf, 'Abo vor dem Löschen nicht gelesen', err)
}
return false
}
/**
* Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:<id>. Ist sie noch da, kommt vor
* dem unumkehrbaren Loeschen die letzte Pruefung (nochAbgeschaltet, bei Grund abo_ende auch das frische Abo); ist sie schon weg (404),
* ist nichts Unumkehrbares mehr offen und die Abo-Pruefung entfaellt. Ein Lesefehler wirft (der Aufrufer meldet ihn).
* => 'geloescht' (in diesem Lauf) | 'weg' (war schon weg) | 'bleibt' (die Funktion gilt nicht mehr als abgeschaltet)
*/
function loescheEinstellung(lauf, funktionId, grenze) {
const schonWeg = (err) => err?.status === 404 && !fehlendeCollection(err)
return withLocks([`termine-cfg:${funktionId}`], async () => {
try {
await getDocument(config.collections.termineConfig, funktionId)
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return 'bleibt'
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return 'geloescht'
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
})
}
/**
* Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren
* feature-admin:<projectId> (wie Aktivieren und Abschalten im WOMS) und termine-cfg:<id> frisch lesen und nochmals pruefen
* (nochAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es.
* purchaseStatus 'zurueckgesetzt' ist die dauerhafte Marke "hier wurde nach dem Abo-Ende geloescht" (weder paid noch Meldezustand;
* Block C6 loescht ein so markiertes Funktionsdokument nie, damit der siteKey besteht). siteKey sowie Bestell- und Rechnungsfelder
* bleiben stehen bis zur naechsten Bestellung.
* => true, wenn zurueckgesetzt
*/
function setzeFunktionZurueck(lauf, funktion, grenze) {
return withLocks([`feature-admin:${funktion.projectId}`, `termine-cfg:${funktion.$id}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false }))) return false
await updateDocument(config.collections.portalFeatures, funktion.$id, {
...stateFields('off'),
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
activatedAt: null,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
})
return true
})
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie nicht mehr als abgeschaltet, endet die
* Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der Lauf mit Warnung (der Rest folgt am
* naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht, mit letzter Pruefung unter der Sperre
* termine-cfg:<id>; sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter.
* => { geloescht, gedeckelt, einstellungWeg }
* (nochAbgeschaltet, bei Grund abo_ende samt frischem Abo) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie
* nicht mehr als abgeschaltet, endet die Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der
* Lauf mit Warnung (der Rest folgt am naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht
* (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:<id>); sind alle Buchungen weg und die Einstellung geloescht
* oder schon nicht mehr da, setzt der Lauf das Funktionsdokument auf `off` zurueck (setzeFunktionZurueck, fuer jeden Grund, ohne
* Abo-Pruefung). Sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter; scheitert nur das
* Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr).
* => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht)
*/
async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) {
const funktionId = funktion.$id
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
@@ -587,32 +669,31 @@ async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktionId}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
const stand = await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => loescheEinstellung(lauf, funktionId, grenze), 'bleibt')
einstellungWeg = stand === 'geloescht'
if (stand !== 'bleibt') {
await schritt(
lauf,
'Funktion zurücksetzen',
async () => {
if (!funktion.projectId) throw new Error('Funktion ohne Projekt') // ohne Projekt gibt es die Sperre feature-admin:<projectId> nicht: im Zweifel nichts schreiben
return setzeFunktionZurueck(lauf, funktion, grenze)
},
false,
)
}
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion). Endet eine Funktion am Deckel oder an einem Fehler,
* bleibt sie `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung), gedeckelt die Funktionen, die am
* Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
* Funktionen im Zustand `disabled` seit mehr als LOESCHFRIST_TAGE (30) Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die
* Einstellung loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion), danach das Funktionsdokument auf `off`
* zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch
* geprueft, ob das Wartungs-Abo wieder laeuft (dann bleibt alles). Endet eine Funktion am Deckel oder an einem Fehler, bleibt sie
* `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung; ein reines Zuruecksetzen zaehlt nicht),
* gedeckelt die Funktionen, die am Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 0 }
@@ -620,7 +701,7 @@ async function loescheAbgeschaltete(lauf) {
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const weg = await loescheAbgeschalteteFunktion(lauf, funktion.$id, grenze)
const weg = await loescheAbgeschalteteFunktion(lauf, funktion, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1