Kundenansicht P6: Abo-Ende-Abgleich für News, Terminplaner, Webshop und Buchungskalender

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:42:06 +00:00
1 parent de8b0c0fca
commit 92ac61dffd
3 files changed
+374 -1

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
import { config } from '../config.js'
import { ENTHALTEN_IN, aboProdukt } from './aboFunktionen.js'
import { ohneAboBisher } from './aboAufbauphase.js'
import { istAboModell } from './aboModell.js'
import { meldeBeendet } from './aboFunktionenMeldung.js'
import { Query, getDocument, listAllDocuments, updateDocument } from './appwriteAdmin.js'
import { withLocks } from './keyedLock.js'
import { activate, disable, stateFields } from './siteFeatures.js'
import { pruefeNow } from './termine/util.js'
import { ladeEinstellung } from './termine/kontext.js'
import { leereCache } from './unterkunft/kontext.js'
/**
* Abo-Ende-Abgleich der Funktionen des Abo-Modells (Kundenansicht neu, P6, V11, N31): News-Pinnwand, Terminplaner, Webshop und
* Buchungskalender gehoeren zum Abo und enden mit ihm, spaetestens am Folgetag (Tagesjob, angeschlossen in Task 11). Die Oeffnungszeiten
* live bleiben bei zeiten/aboAbgleich.js (30 Tage Frist, Task 10). Nur Dokumente des Abo-Modells (istAboModell: unlockedBy UND
* einrichtungAbrechnung aus abo oder projekt) fasst dieser Abgleich an; Dokumente ohne Marker (Kauf-Bestand, Team, Altbestand) gehoeren dem
* alten Terminplaner-Abgleich (termine/aboAbgleich.js) und bleiben unberuehrt.
*
* Abschalten setup|active und das Abo deckt die Funktion nicht (aboProdukt/ENTHALTEN_IN; gekuendigt und past_due laufen noch, nichts
* abschalten) => disable (Grund abo_ende), danach K39 (meldeBeendet) und der Marker aboEndeMailAt.
* Aufbauphase (E9, E21): ein Dokument mit unlockedBy 'projekt' bleibt, solange der Kunde noch nie ein Abo hatte
* (ohneAboBisher, abgebrochene Bestellungen zaehlen nicht): die Website ist noch im Bau. Ab dem ersten nicht deckenden Abo
* gilt fuer 'projekt' dieselbe Regel wie fuer 'abo'. ohneAboBisher wird nur gelesen, wenn ein projekt-Dokument vom Abo
* nicht gedeckt ist (je Kunde hoechstens einmal je Lauf) und unter der Sperre noch einmal frisch.
* Nachholen disabled mit Grund abo_ende ohne Marker => K39 erneut (Mailfehler, Kunde ohne Adresse); disabledAt bleibt, die Loeschfrist waechst nie.
* Rueckkehr disabled mit Grund abo_ende und das Abo deckt wieder, solange die Daten da sind (News: das Dokument ist noch disabled;
* Terminplaner: die Einstellung ist gespeichert; Webshop und Buchungskalender: die Notiz nennt keine Loeschung "gelöscht am")
* => wieder active (activate), ohne activatedAt wieder setup. Still, ohne Mail (K39 hat es angekuendigt).
*
* Regeln wie termine/aboAbgleich.js: eine Listenabfrage fuer alle Kunden; das Abo je Kunde einmal fuer die Vorauswahl lesen. Geschrieben
* wird nur unter der Sperre feature-admin:<projectId> nach frischem Lesen von Dokument UND Abo (nie aus der Vorauswahl); die Mail und der
* Marker folgen nach der Sperre. Fail-closed: ist etwas nicht lesbar (Liste, Abo, Dokument, Aufbauphase), wird nichts abgeschaltet; Kunde
* bzw. Dokument werden uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen.
*/
/** Funktionen dieses Abgleichs; zeiten gehoert zeiten/aboAbgleich.js (Task 10). */
export const ABGLEICH_KEYS = Object.freeze(['news', 'termine', 'shop', 'unterkunft'])
const GRUND = 'abo_ende'
const NOTIZ = 'System: Abgeschaltet: Abo beendet'
const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Abo läuft wieder'
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
/** Fehlertext fuers Log: Name und gekuerzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}`
const portalFeatures = () => config.collections.portalFeatures
/** Deckt das Abo (Ergebnis von aboProdukt) diese Funktion? Gekuendigt und past_due laufen noch und zaehlen. */
const deckt = (abo, key) => abo.laeuft === true && ENTHALTEN_IN[key].includes(abo.produkt)
/** Grund fuer K39: das Abo ist ein Hosting-Abo, sonst Abo beendet. */
const artVon = (abo) => (abo.produkt === 'hosting' ? 'hosting' : GRUND)
/**
* Sind die Daten der abgeschalteten Funktion noch da? News: das Dokument ist noch disabled (der Loeschschritt setzt es auf off);
* Terminplaner: die Einstellung ist gespeichert (ladeEinstellung wirft bei Lesefehlern, dann bleibt alles); Webshop und Buchungskalender:
* die Notiz nennt keine Loeschung ("Shopdaten gelöscht am", "Buchungsdaten gelöscht am").
*/
async function datenDa(doc) {
if (doc.featureKey === 'termine') return (await ladeEinstellung(doc.$id)) !== null
if (doc.featureKey === 'shop' || doc.featureKey === 'unterkunft') return !/gelöscht am/.test(String(doc.adminNote ?? ''))
return true
}
/** K39 nach der Sperre; ein Misserfolg zaehlt als Fehler, der naechste Lauf wiederholt (Marker leer). */
async function melde(doc, art, ctx) {
const fertig = await meldeBeendet(doc, { art, now: ctx.now, onGesendet: () => (ctx.erg.mails += 1) })
if (!fertig) ctx.erg.fehler += 1
}
/**
* Abschalten, ein Dokument: unter der Sperre frisch lesen (das Team kann inzwischen abgeschaltet haben), frisch das Abo lesen (es kann
* zurueckgekommen sein) und fuer ein projekt-Dokument frisch die Aufbauphase pruefen, erst dann disable. K39 folgt nach der Sperre.
*/
async function schalteAb(doc, ctx) {
const { erg } = ctx
const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (!istAboModell(frisch) || (frisch.state !== 'setup' && frisch.state !== 'active')) return null
const abo = await aboProdukt(frisch.customerId, { now: ctx.now }) // wirft bei Lesefehler: dann bleibt alles, wie es ist
if (deckt(abo, frisch.featureKey)) return null // das Abo ist zurueck
if (frisch.unlockedBy === 'projekt' && (await ohneAboBisher(frisch.customerId))) return null // Aufbauphase
const zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ })
if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
if (zuMelden.featureKey === 'unterkunft' && zuMelden.siteKey) leereCache(zuMelden.siteKey)
erg.abgeschaltet += 1
return { zuMelden, art: artVon(abo) }
})
if (gefunden) await melde(gefunden.zuMelden, gefunden.art, ctx)
}
/** Nachholen, ein Dokument: unter der Sperre frisch lesen (noch abo_ende-abgeschaltet und ohne Marker, das Abo deckt weiter nicht), K39 danach. */
async function holeMeldungNach(doc, ctx) {
const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (!istAboModell(frisch) || frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) return null
const abo = await aboProdukt(frisch.customerId, { now: ctx.now })
if (deckt(abo, frisch.featureKey)) return null // das Abo ist zurueck: Rueckkehr, nicht melden
return { zuMelden: frisch, art: artVon(abo) }
})
if (gefunden) await melde(gefunden.zuMelden, gefunden.art, ctx)
}
/**
* Rueckkehr, ein Dokument: unter der Sperre frisch lesen (noch disabled/abo_ende), frisch das Abo lesen, die Daten pruefen, dann activate
* (mit activatedAt) oder zurueck auf setup (nie aktiv gewesen: die Abnahme laeuft wie sonst ueber das Ticket). Keine Mail.
*/
async function schalteWiederEin(doc, ctx) {
const { erg } = ctx
await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (!istAboModell(frisch) || frisch.state !== 'disabled' || frisch.disabledReason !== GRUND) return
const abo = await aboProdukt(frisch.customerId, { now: ctx.now })
if (!deckt(abo, frisch.featureKey)) return // das Abo endete zwischen Vorauswahl und Sperre wieder
if (!(await datenDa(frisch))) return // Daten geloescht: Rueckkehr nur, solange sie existieren
if (frisch.activatedAt) {
await activate(frisch.$id, { adminNote: NOTIZ_WIEDER })
} else {
await updateDocument(portalFeatures(), frisch.$id, {
...stateFields('setup'),
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: NOTIZ_WIEDER,
})
}
erg.wiederEin += 1
})
}
async function bearbeiteKunde(customerId, dokumente, ctx) {
const { erg } = ctx
let abo
try {
abo = await aboProdukt(customerId, { now: ctx.now })
} catch (err) {
erg.fehler += 1
console.error(`[abo-funktionen] Abo eines Kunden nicht lesbar, ${dokumente.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
return
}
erg.geprueft += dokumente.length
let aufbauphase = null // Promise-frei: einmal je Kunde und Lauf, nur gelesen, wenn ein projekt-Dokument nicht gedeckt ist
let aufbauphaseFehler = false
const inAufbauphase = async () => {
if (aufbauphaseFehler) return true // nicht lesbar: nichts abschalten (der Fehler ist gezaehlt)
if (aufbauphase === null) {
try {
aufbauphase = await ohneAboBisher(customerId)
} catch (err) {
aufbauphaseFehler = true
erg.fehler += 1
console.error(`[abo-funktionen] Abo-Verlauf eines Kunden nicht lesbar, Projekt-Funktionen übersprungen: ${fehlerText(err)}`)
return true
}
}
return aufbauphase
}
for (const doc of dokumente) {
try {
const gedeckt = deckt(abo, doc.featureKey)
if (doc.state === 'setup' || doc.state === 'active') {
if (gedeckt) continue
if (doc.unlockedBy === 'projekt' && (await inAufbauphase())) continue
await schalteAb(doc, ctx)
} else if (doc.state === 'disabled' && doc.disabledReason === GRUND) {
if (gedeckt) await schalteWiederEin(doc, ctx)
else if (!doc.aboEndeMailAt) await holeMeldungNach(doc, ctx)
}
} catch (err) {
erg.fehler += 1
console.error(`[abo-funktionen] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
}
}
}
/**
* Taeglicher Abgleich der Funktionen des Abo-Modells mit dem Abo (siehe Kopf der Datei). Wirft nie; jedes Problem steht im Log und in `fehler`.
* @param {Date} [now]
* @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>}
* geprueft = bewertete Dokumente des Abo-Modells, deren Kunde lesbar war; abgeschaltet und wiederEin = in diesem Lauf; geloescht = Daten, die
* der Loeschschritt in diesem Lauf entfernt hat (Task 9); mails = versendete K39; fehler = Kunden und Dokumente, die nicht bearbeitet werden
* konnten, sowie ein gescheiterter K39-Versand
*/
export async function gleicheAboFunktionenAb(now = new Date()) {
const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
try {
pruefeNow(now, 'gleicheAboFunktionenAb')
let dokumente
try {
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', [...ABGLEICH_KEYS]), Query.notEqual('state', 'off')])
} catch (err) {
erg.fehler += 1
console.error(`[abo-funktionen] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`)
return erg
}
const nachKunde = new Map()
for (const doc of dokumente.filter(istAboModell)) {
if (!doc.customerId || !doc.projectId) {
// ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst
erg.fehler += 1
console.error(`[abo-funktionen] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`)
continue
}
if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, [])
nachKunde.get(doc.customerId).push(doc)
}
const ctx = { erg, now }
for (const [customerId, liste] of nachKunde) {
try {
await bearbeiteKunde(customerId, liste, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[abo-funktionen] Kunde nicht bearbeitet: ${fehlerText(err)}`)
}
}
} catch (err) {
erg.fehler += 1
console.error(`[abo-funktionen] Abgleich abgebrochen: ${fehlerText(err)}`)
}
return erg
}