From 30701ba2770253a3bc65bbb1e2f29adf43471e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:57:41 +0000 Subject: [PATCH] =?UTF-8?q?L=C3=B6schung=20nach=2030=20Tagen:=20Abo-Vorpr?= =?UTF-8?q?=C3=BCfung,=20Funktionsdokument=20danach=20auf=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/services/termine/jobs.js | 155 +++++++++++++---- test/termineJobs.test.js | 300 ++++++++++++++++++++++++++++++-- 2 files changed, 408 insertions(+), 47 deletions(-) diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 01a113d..a9651da 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -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:. 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: (wie Aktivieren und Abschalten im WOMS) und termine-cfg: 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:; 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:); 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: 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 diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index c1e832f..694f6da 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -37,6 +37,7 @@ const { speichereEinstellung, resetKontextForTests } = await import('../server/s const { belegeZellen } = await import('../server/services/termine/zellen.js') const { bestaetige } = await import('../server/services/termine/ablauf.js') const { disable } = await import('../server/services/siteFeatures.js') +const { withLocks } = await import('../server/services/keyedLock.js') const J = await import('../server/services/termine/jobs.js') const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J after(() => setMailTransportForTests(null)) @@ -1439,7 +1440,7 @@ test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei assert.equal(mit, ohne.replace(', 9 Fehler', ', Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, 9 Fehler')) }) -test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg; alles andere bleibt', async () => { +test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg, das Funktionsdokument danach auf off; alles andere bleibt', async () => { const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz)) await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) }) await richteFunktionEin(F2, { state: 'disabled', disabledAt: seit(30, 1) }) // noch knapp unter 30 Tagen @@ -1447,6 +1448,8 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen await richteFunktionEin('f_p_projekt04_termine', { state: 'disabled', disabledAt: seit(90), featureKey: 'news' }) // andere Funktion await richteFunktionEin('f_p_projekt05_termine', { state: 'disabled' }) // kein disabledAt const funktionen = [F1, F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine'] + const andere = funktionen.slice(1) + const vorher = Object.fromEntries(andere.map((id) => [id, structuredClone(aw.docs('portalFeatures').get(id))])) for (const [i, featureId] of funktionen.entries()) { await legeBuchung(`b_fest_${i}`, { featureId, status: 'bestaetigt' }) await legeBuchung(`b_alt_${i}`, { featureId, status: 'erledigt', ende: plus(JETZT, -2 * TAG) }) @@ -1462,8 +1465,33 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') assert.equal(buchungen().length, 8, 'die Buchungen der anderen vier Funktionen bleiben') for (const featureId of [F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine']) assert.ok(aw.docs('termineConfig').get(featureId), `${featureId}: Einstellung bleibt`) - assert.ok(aw.docs('portalFeatures').get(F1), 'das Funktionsdokument selbst bleibt') - assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal') + // das Funktionsdokument selbst bleibt, steht danach aber auf off (der Entwurf einer Neubestellung); der siteKey und damit das eingebettete Snippet bleiben gültig + const doc = aw.docs('portalFeatures').get(F1) + assert.ok(doc, 'das Funktionsdokument selbst bleibt') + assert.equal(doc.state, 'off') + assert.equal(doc.enabled, false) + assert.equal(doc.unlockedByPurchase, false) + assert.equal(doc.purchaseStatus, 'zurueckgesetzt') + assert.equal(doc.zahlungMeldung, '') + assert.equal(doc.ticketId, '') + assert.equal(doc.activatedAt, null) + assert.equal(doc.disabledAt, null) + assert.equal(doc.disabledReason, null) + assert.equal(doc.aboEndeMailAt, null) + assert.equal(doc.adminNote, 'Daten gelöscht (Löschfrist abgelaufen)') + assert.equal(doc.siteKey, 'abcdefghijklmnop', 'siteKey unverändert') + for (const id of andere) assert.deepEqual(aw.docs('portalFeatures').get(id), vorher[id], `${id}: Dokument unverändert`) + + // zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal, das zurückgesetzte Dokument wird nicht mehr gelistet und nicht mehr gelesen + const leseF1 = new RegExp(`/collections/portalFeatures/documents/${F1}(\\?|$)`) + let zweiter + const zugriffeAufF1 = await zaehleAnfragen(async () => { + zweiter = await loescheAbgeschalteteFunktionen(JETZT) + }, leseF1) + assert.deepEqual(zweiter, { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + assert.equal(zugriffeAufF1, 0, 'keine tägliche Neuprüfung des zurückgesetzten Dokuments') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') + assert.equal(fehlerLog().length, 0) }) test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => { @@ -1473,9 +1501,12 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei await loescheAbgeschalteteFunktionen(JETZT) assert.ok(buchung('b_eins')) assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Dokument bleibt disabled, solange Buchungen übrig sind') assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }) assert.equal(buchung('b_eins'), undefined) assert.equal(aw.docs('termineConfig').get(F1), undefined) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'der nächste Lauf löscht und setzt zurück') + assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt') }) test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOMS, Kundenlöschung) verschiebt die 30 Tage nicht', async () => { @@ -1493,8 +1524,8 @@ test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOM }) /** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `000…`; mit zellen bekommt jede ihre Zellen. */ -async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab' } = {}) { - await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) }) +async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab', funktion = {} } = {}) { + await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)), ...funktion }) for (let i = 0; i < n; i++) await legeBuchung(`${praefix}${String(i).padStart(3, '0')}`, { featureId, start: plus(JETZT, TAG + i * 2 * STUNDE), zellen }) } /** Das Personal schaltet die Funktion wieder ein: siteFeatures.activate sperrt nicht und lässt disabledAt stehen. */ @@ -1544,6 +1575,7 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion nach 50 Buchungen wieder assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 }) assert.equal(buchungen().length, 10) assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt') }) test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung wieder eingeschaltet, bleibt die Einstellung', async () => { @@ -1553,6 +1585,9 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') assert.equal(erg.funktionen, 1) assert.equal(erg.buchungen, 2, 'beide Buchungen waren in einem Block von 50, vor der nächsten Prüfung gelöscht') + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt') + assert.notEqual(doc.purchaseStatus, 'zurueckgesetzt') }) test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht gelesen werden, wird nichts gelöscht (Fehler im Log)', async () => { @@ -1574,9 +1609,10 @@ test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen, assert.ok(zellenProBuchung > 0) const funktion = () => aw.docs('portalFeatures').get(F1) const vorher = { state: funktion().state, disabledAt: funktion().disabledAt } - const stimmt = (uebrig) => { + const stimmt = (uebrig, { zurueckgesetzt = false } = {}) => { assert.equal(buchungen().length, uebrig, 'Rest') assert.equal(aw.docs('termineClaims').size, uebrig * zellenProBuchung, 'Zellen passen zum Rest') + if (zurueckgesetzt) return assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, { state: 'off', disabledAt: null }, 'erst der letzte Lauf setzt das Dokument zurück') assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, vorher, 'Zustand und disabledAt bleiben, die nächste Frist beginnt nicht neu') } @@ -1597,7 +1633,7 @@ test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen, log.warn.mock.resetCalls() erg = await loescheAbgeschalteteFunktionen(JETZT) assert.deepEqual(erg, { funktionen: 1, buchungen: 30, gedeckelt: 0 }, 'Lauf 3: die letzten 30') - stimmt(0) + stimmt(0, { zurueckgesetzt: true }) assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung erst jetzt gelöscht') assert.equal(deckelWarnungen().length, 0) assert.equal(fehlerLog().length, 0) @@ -1745,6 +1781,251 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion unmittelbar vor dem Lös assert.equal(fehlerLog().length, 0) }) +// Löschung nach 30 Tagen (E8): frische Abo-Prüfung vor jedem unumkehrbaren Schritt, danach das Funktionsdokument auf off + +/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */ +const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over })) +/** Alle Anfragen an die Abo-Collections (Spiegel und Ereignisse), die der Abo-Baustein stellt. */ +const ABO_ANFRAGEN = /\/collections\/(subscriptions|contractEvents)\// +/** Die Anfrage, die die Einstellung (termineConfig) löscht. */ +const einstellungLoeschen = (url, methode) => methode === 'DELETE' && /\/collections\/termineConfig\/documents\//.test(url) +/** Terminplaner seit 31 Tagen abgeschaltet, mit allem, was der Kauf und die Einrichtung am Dokument hinterlassen haben. */ +const eingerichtet = (over = {}) => ({ + ...BEZAHLT, + disabledAt: iso(plus(JETZT, -31 * TAG)), + activatedAt: iso(plus(JETZT, -200 * TAG)), + ticketId: 't_einrichtung', + zahlungMeldung: 'gemeldet', + aboEndeMailAt: iso(plus(JETZT, -31 * TAG)), + adminNote: 'Abgeschaltet: Wartungs-Abo beendet', + stripeSessionId: 'cs_test_alt', + invoiceUrl: 'https://invoice.stripe.example/alt', + ...over, +}) + +test('Löschung nach 30 Tagen, Grund abo_ende: läuft das Wartungs-Abo wieder, wird nichts gelöscht (Warnung im Log, Dokument bleibt disabled)', async () => { + await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + wartung('active') + + const erg = await loescheAbgeschalteteFunktionen(JETZT) + assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + assert.equal(buchungen().length, 3, 'Buchungen bleiben') + for (const b of buchungen()) assert.ok(claims(b.$id).length > 0, `${b.$id}: Zellen bleiben`) + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'disabled') + assert.equal(doc.disabledReason, 'abo_ende') + assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)), 'Warnung im Log') + assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|c_kunde|Müller/, 'keine Kennung im Log') + assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf') +}) + +test('Löschung nach 30 Tagen, Grund abo_ende: endet das Abo wirklich (beendet), wird gelöscht', async () => { + await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + wartung('canceled', { endedAt: iso(plus(JETZT, -31 * TAG)) }) + + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 3, gedeckelt: 0 }) + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineConfig').get(F1), undefined) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') + assert.equal(fehlerLog().length, 0) +}) + +for (const [name, grund] of [ + ['team', { disabledReason: 'team' }], + ['kunde_geloescht', { disabledReason: 'kunde_geloescht' }], + ['abo_ende_vor_einrichtung', { disabledReason: 'abo_ende_vor_einrichtung' }], + ['Altbestand ohne Grund', {}], +]) { + test(`Löschung nach 30 Tagen, ${name}: gelöscht und zurückgesetzt, ohne eine einzige Abfrage auf das Abo (auch wenn es läuft)`, async () => { + await abgeschaltetMitBuchungen(3, { funktion: eingerichtet(grund) }) + wartung('active') + + let erg + const aboAnfragen = await zaehleAnfragen(async () => { + erg = await loescheAbgeschalteteFunktionen(JETZT) + }, ABO_ANFRAGEN) + assert.equal(aboAnfragen, 0, 'keine Abfrage auf subscriptions oder contractEvents') + assert.deepEqual(erg, { funktionen: 1, buchungen: 3, gedeckelt: 0 }) + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineClaims').size, 0) + assert.equal(aw.docs('termineConfig').get(F1), undefined) + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'off', 'das Zurücksetzen gilt für jeden Grund') + assert.equal(doc.purchaseStatus, 'zurueckgesetzt') + assert.equal(fehlerLog().length, 0) + }) +} + +test('Löschung nach 30 Tagen: das zurückgesetzte Dokument behält siteKey sowie Bestell- und Rechnungsfelder, alles Übrige ist leer', async () => { + await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + const vorher = structuredClone(aw.docs('portalFeatures').get(F1)) + + await loescheAbgeschalteteFunktionen(JETZT) + const doc = aw.docs('portalFeatures').get(F1) + assert.deepEqual( + { ...doc, $updatedAt: undefined }, + { + ...vorher, + $updatedAt: undefined, + state: 'off', + enabled: false, + unlockedByPurchase: false, + purchaseStatus: 'zurueckgesetzt', + zahlungMeldung: '', + ticketId: '', + activatedAt: null, + disabledAt: null, + disabledReason: null, + aboEndeMailAt: null, + adminNote: 'Daten gelöscht (Löschfrist abgelaufen)', + }, + 'genau diese Felder ändern sich; siteKey, stripeSessionId, invoiceUrl und alles andere bleiben', + ) + assert.equal(doc.siteKey, 'abcdefghijklmnop') + assert.equal(doc.stripeSessionId, 'cs_test_alt') +}) + +test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo erst nach der 50. Löschung zurück, endet die Löschung dort und die Einstellung bleibt', async () => { + await abgeschaltetMitBuchungen(60, { zellen: false, funktion: { disabledReason: 'abo_ende' } }) + const echt = globalThis.fetch + let geloescht = 0 + globalThis.fetch = async (url, optionen) => { + const antwort = await echt(url, optionen) + if (buchungLoeschen(String(url), optionen?.method ?? 'GET')) { + geloescht += 1 + if (geloescht === 50) wartung('active') + } + return antwort + } + let erg + try { + erg = await loescheAbgeschalteteFunktionen(JETZT) + } finally { + globalThis.fetch = echt + } + assert.equal(geloescht, 50, 'nach der Rückkehr des Abos kommt keine Löschung mehr') + assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 }) + assert.equal(buchungen().length, 10) + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled') + assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile))) + assert.equal(fehlerLog().length, 0) +}) + +test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo zurück, nachdem die letzte Buchung weg ist, bleibt die Einstellung (Prüfung vor dem Löschen der Einstellung)', async () => { + await abgeschaltetMitBuchungen(2, { zellen: false, funktion: { disabledReason: 'abo_ende' } }) + let listen = 0 + const leereSeite = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2 + const erg = await mitEinwurf(leereSeite, async () => wartung('active'), () => loescheAbgeschalteteFunktionen(JETZT)) + assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 }) + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'kein Zurücksetzen, solange die Einstellung da ist') + assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile))) + assert.equal(fehlerLog().length, 0) +}) + +test('Löschung nach 30 Tagen, Grund abo_ende: Lesefehler auf subscriptions ⇒ nichts gelöscht, fehler gezählt; der nächste Lauf löscht', async () => { + await abgeschaltetMitBuchungen(3, { funktion: abgeschaltetBezahlt(40) }) + // 1. Lesezugriff auf subscriptions = Abo-Abgleich (Vorauswahl), 2. = Prüfung der Löschung + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) + + let erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1 }) + assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 }) + assert.equal(erg.fehler, 1, 'der Lesefehler wird gezählt') + assert.equal(fehlerLog().filter((zeile) => /Abo vor dem Löschen nicht gelesen/.test(zeile)).length, 1) + assert.equal(buchungen().length, 3, 'im Zweifel wird nichts gelöscht') + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled') + + erg = await taeglicherJob(JETZT) + assert.equal(erg.fehler, 0) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 3 }) + assert.equal(aw.docs('termineConfig').get(F1), undefined) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') +}) + +test('Zurücksetzen scheitert (PATCH auf portalFeatures) ⇒ Einstellung ist weg, Dokument noch disabled; der nächste Lauf setzt zurück, auch wenn das Abo inzwischen läuft', async () => { + await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'Nachbau kaputt' }) + + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 2, gedeckelt: 0 }) + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung ist weg') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen ist gescheitert') + assert.equal(fehlerLog().length, 1) + + wartung('active') // jetzt ist nichts Unumkehrbares mehr offen: das Abo spielt keine Rolle + log.error.mock.resetCalls() + let erg + const aboAnfragen = await zaehleAnfragen(async () => { + erg = await loescheAbgeschalteteFunktionen(JETZT) + }, ABO_ANFRAGEN) + assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'ein reines Zurücksetzen zählt nicht') + assert.equal(aboAnfragen, 0, 'keine Abo-Prüfung') + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'off') + assert.equal(doc.purchaseStatus, 'zurueckgesetzt') + assert.equal(fehlerLog().length, 0) +}) + +test('Zurücksetzen: war die Einstellung schon weg (404) und gibt es keine Buchungen, setzt der Lauf nur das Dokument zurück – ohne Abo-Prüfung, auch bei laufendem Abo', async () => { + await abgeschaltetMitBuchungen(0, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + await deleteDocument('termineConfig', F1) + wartung('active') + + let erg + const aboAnfragen = await zaehleAnfragen(async () => { + erg = await loescheAbgeschalteteFunktionen(JETZT) + }, ABO_ANFRAGEN) + assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + assert.equal(aboAnfragen, 0, 'nichts Unumkehrbares mehr offen: keine Abo-Abfrage') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') + assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt') + assert.equal(fehlerLog().length, 0) +}) + +test('Zurücksetzen: läuft unter der Sperre feature-admin: – solange das Team sie hält, bleibt das Dokument disabled', async () => { + await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) }) + let lauf + await withLocks([`feature-admin:${PROJEKT}`], async () => { + lauf = loescheAbgeschalteteFunktionen(JETZT) + for (let i = 0; i < 200 && aw.docs('termineConfig').get(F1); i += 1) await new Promise((fertig) => setTimeout(fertig, 10)) // bis die Einstellung weg ist + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Buchungen und Einstellung sind gelöscht') + await new Promise((fertig) => setTimeout(fertig, 100)) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen wartet auf die Sperre') + }) + assert.deepEqual(await lauf, { funktionen: 1, buchungen: 1, gedeckelt: 0 }) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off') +}) + +for (const [was, einwurf] of [ + ['vom Team aktiviert', () => schalteEin()], + ['frisch neu abgeschaltet (disabledAt jünger als 30 Tage)', () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), disabledAt: iso(plus(JETZT, -TAG)) })], +]) { + test(`Zurücksetzen: wird das Dokument zwischen dem Löschen der Einstellung und dem Zurücksetzen ${was}, bleibt es so (frisch gelesen, nicht überschrieben)`, async () => { + await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) }) + + const erg = await mitEinwurf(einstellungLoeschen, async () => einwurf(), () => loescheAbgeschalteteFunktionen(JETZT)) + assert.deepEqual(erg, { funktionen: 1, buchungen: 1, gedeckelt: 0 }) + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'die Einstellung war schon gelöscht') + const doc = aw.docs('portalFeatures').get(F1) + assert.notEqual(doc.state, 'off', 'nicht zurückgesetzt') + assert.equal(doc.unlockedByPurchase, true) + assert.equal(doc.purchaseStatus, 'paid') + assert.equal(doc.ticketId, 't_einrichtung') + assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet') + assert.equal(fehlerLog().length, 0) + }) +} + +test('Zurücksetzen: nach dem Zurücksetzen steht das Dokument auf off und ist wieder bestellbar (markOrdered: Test in Block B)', async () => { + await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) }) + await loescheAbgeschalteteFunktionen(JETZT) + const { state, enabled } = aw.docs('portalFeatures').get(F1) + assert.deepEqual({ state, enabled }, { state: 'off', enabled: false }) +}) + // ---------------------------------------------------------------- Tagesjob test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => { @@ -2051,8 +2332,6 @@ test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später const K22_BETREFF = /^Dein Terminplaner wurde beendet/ const K23_BETREFF = /^Dein Terminplaner läuft wieder/ const kundenMails = (betreff) => sink.sent.filter((mail) => betreff.test(mail.subject)) -/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */ -const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over })) /** Bezahlter, seit `tage` Tagen wegen des Abo-Endes abgeschalteter Terminplaner; K22 ging schon (Marker), der Abgleich holt nichts nach. */ const abgeschaltetBezahlt = (tage, over = {}) => ({ state: 'disabled', @@ -2182,7 +2461,8 @@ test('Modus off, bezahlter Terminplaner abgeschaltet seit 31 Tagen: Buchungen, Z assert.equal(buchung('b_fest'), undefined) assert.equal(claims('b_fest').length, 0, 'Zellen frei') assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') - // (Dokument selbst auf `off` setzen: E8) + assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'das Dokument steht danach auf off: der Guard findet keinen bezahlten Terminplaner mehr') + assert.equal(aw.docs('portalFeatures').get(F1).unlockedByPurchase, false) // die Tagesjob-Meldung und die Statusroute lesen daraus Zahlen, nie undefined assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 })