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: 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 }