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, deleteDocument, getDocument, listAllDocuments, updateDocument } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' import { deleteNewsImage } from './newsImages.js' import { listPosts } from './newsPosts.js' import { activate, disable, listCustomerFeatureDocs, stateFields } from './siteFeatures.js' import { pruefeNow } from './termine/util.js' import { loeschTag } from './termine/frist.js' import { ladeEinstellung } from './termine/kontext.js' import { heute } from './termine/zeit.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). * Loeschen News: disabled mit Grund abo_ende, das Abo deckt nicht und disabledAt plus 30 Tage ist erreicht (Berliner Kalendertag wie * termine/frist.js) => alle Beitraege des Projekts samt Bild loeschen, dann das Dokument auf off (Felder ticketId, activatedAt, * aboEndeMailAt, disabledAt, disabledReason geleert); scheitert ein Loeschen, bleibt das Dokument disabled (fehler, der naechste * Lauf wiederholt). Deckt das Abo wieder, gilt die Rueckkehr vor der Loeschung. * Zuruecksetzen Webshop und Buchungskalender: haben die bestehenden Jobs (shop/jobs.js, unterkunft/aufbewahrung.js) nach 90 Tagen die * Daten geloescht und "Shopdaten gelöscht am" bzw. "Buchungsdaten gelöscht am" in adminNote vermerkt, wird das Dokument off * (wie bei News, der Vermerk endet damit), unabhaengig vom Abo. Ohne Vermerk bleibt alles. Der Terminplaner wird von * termine/jobs.js selbst auf off gesetzt. * * Sofortpfad (V11, N27): beendeWartungsFunktionen(customerId) beendet alle Abo-Modell-Funktionen eines Kunden, die nur im Wartungs-Abo stecken, * sofort, wenn dessen Wechsel ins Wartungs-Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Ohne Abo-Pruefung (der Spiegel * steht bis zur Handarbeit des Teams noch auf Wartung); die Analyse gehoert zu Hosting und bleibt. Die Mail K39 geht wie im Tagesabgleich * (meldeBeendet), ausser fuer zeiten: dort sendet der Zeiten-Abgleich K41 samt Ersatz-Ticket. * * 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 NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen' 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: * ein Vermerk der Loeschjobs in der Notiz wird vor der Rueckkehr behandelt (setzeNachLoeschungZurueck), ohne Vermerk sind die Daten da. */ async function datenDa(doc) { if (doc.featureKey === 'termine') return (await ladeEinstellung(doc.$id)) !== null return !hatLoeschVermerk(doc) } const TAG_FELDER_LEER = Object.freeze({ ticketId: null, activatedAt: null, aboEndeMailAt: null, disabledAt: null, disabledReason: null }) /** Vermerk der bestehenden Loeschjobs von Webshop und Buchungskalender in der Notiz. */ const LOESCH_VERMERK = /\s*(?:[-–|]\s*)?(?:Shopdaten|Buchungsdaten) gelöscht am \d{4}-\d{2}-\d{2}/g // E-Fix M10: nur der Vermerk des jeweiligen Loeschjobs mit Datum (shop/jobs.js, unterkunft/aufbewahrung.js: MARKER_RE), nie freier Text des Teams wie "Domain geloescht am Kundenwunsch" const LOESCH_VERMERK_JE_FUNKTION = Object.freeze({ shop: /Shopdaten gelöscht am \d{4}-\d{2}-\d{2}/, unterkunft: /Buchungsdaten gelöscht am \d{4}-\d{2}-\d{2}/ }) export const hatLoeschVermerk = (doc) => LOESCH_VERMERK_JE_FUNKTION[doc.featureKey]?.test(String(doc.adminNote ?? '')) === true /** News: ist disabledAt plus die Loeschfrist (30 Tage) am Berliner Kalendertag erreicht? Ohne gueltiges disabledAt nie. */ function loeschReif(doc, now) { const tag = loeschTag(doc.disabledAt) return tag !== null && tag <= heute(now) } /** 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 }) } /** * News, ein Dokument: unter der Sperre frisch lesen (noch disabled/abo_ende, Frist erreicht, das Abo deckt nicht), zuerst alle Beitraege des * Projekts samt Bild loeschen (erst das Bild, dann der Beitrag, wie retention.js), dann das Dokument auf off. Ein Fehler laesst das * Dokument disabled (fehler, der naechste Lauf wiederholt); die uebrigen Beitraege werden trotzdem versucht. */ async function loescheNews(doc, ctx) { const { erg } = ctx await withLocks([`feature-admin:${doc.projectId}`], async () => { const frisch = await getDocument(portalFeatures(), doc.$id) if (!istAboModell(frisch) || frisch.featureKey !== 'news' || frisch.state !== 'disabled' || frisch.disabledReason !== GRUND) return if (!loeschReif(frisch, ctx.now)) return const abo = await aboProdukt(frisch.customerId, { now: ctx.now }) if (deckt(abo, frisch.featureKey)) return // das Abo ist zurueck: Rueckkehr, keine Loeschung const { hanging, expired } = await listPosts(frisch.projectId, ctx.now) let geloeschtOk = true for (const post of [...hanging, ...expired]) { try { if (post.imageId) await deleteNewsImage(post.projectId || frisch.projectId, post.imageId) // erst das Bild, bei Fehler bleibt der Beitrag await deleteDocument(config.collections.newsPosts, post.$id).catch((err) => { if (err?.status !== 404) throw err }) } catch (err) { geloeschtOk = false console.error(`[abo-funktionen] News-Beitrag ${post.$id} nicht gelöscht, das Dokument bleibt abgeschaltet: ${fehlerText(err)}`) } } if (!geloeschtOk) { erg.fehler += 1 return } await updateDocument(portalFeatures(), frisch.$id, { ...stateFields('off'), ...TAG_FELDER_LEER }) erg.geloescht += 1 }) } /** * Webshop und Buchungskalender, ein Dokument: die bestehenden Jobs haben die Daten geloescht (Vermerk in adminNote), unabhaengig vom Abo * zurueck auf off. Der Vermerk endet damit, damit er nicht in eine spaetere Abschaltung desselben Dokuments hineinreicht. */ async function setzeNachLoeschungZurueck(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 || !hatLoeschVermerk(frisch)) return const adminNote = String(frisch.adminNote ?? '').replace(LOESCH_VERMERK, '').trim() await updateDocument(portalFeatures(), frisch.$id, { ...stateFields('off'), ...TAG_FELDER_LEER, adminNote }) erg.geloescht += 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 (hatLoeschVermerk(doc)) await setzeNachLoeschungZurueck(doc, ctx) // Daten weg: nie Rueckkehr, off else if (gedeckt) await schalteWiederEin(doc, ctx) else if (doc.featureKey === 'news' && loeschReif(doc, ctx.now)) await loescheNews(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 = Dokumente, * die der Loeschschritt in diesem Lauf auf off gesetzt hat (News nach 30 Tagen, Webshop und Buchungskalender nach der Loeschung); 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 } /** Steckt die Funktion nur im Wartungs-Abo (ENTHALTEN_IN genau ['wartung'])? Die Analyse (auch Hosting) nicht. */ const nurImWartungsAbo = (key) => Object.hasOwn(ENTHALTEN_IN, key) && ENTHALTEN_IN[key].length === 1 && ENTHALTEN_IN[key][0] === 'wartung' /** * Sofortpfad beim Widerruf des Wechsels ins Wartungs-Abo (V11, N27): alle Dokumente des Abo-Modells (auch projekt) des Kunden im Zustand * setup oder active, deren Funktion nur im Wartungs-Abo steckt (news, termine, shop, unterkunft, zeiten; nicht analyse), werden sofort * abgeschaltet (Grund abo_ende, Notiz "Abgeschaltet: Wechsel widerrufen"), je Dokument unter feature-admin: nach frischem Lesen * und ohne Abo-Pruefung. Danach, ausserhalb der Sperre, K39 mit dem Hosting-Satz (meldeBeendet, art hosting) fuer alle ausser zeiten (dort * sendet der Zeiten-Abgleich K41 samt Ersatz-Ticket). Ein Fehler bei einem Dokument stoppt die uebrigen nicht; was misslingt, holt der * Tagesabgleich nach (aboProdukt kennt den widerrufenen Wechsel). Wirft nie; das Log nennt weder Namen noch Adressen. * @param {string} customerId * @param {{ now?: Date }} [options] Zeitpunkt des Widerrufs (Marker aboEndeMailAt) * @returns {Promise<{ beendet: string[] }>} die IDs der abgeschalteten Funktionsdokumente */ export async function beendeWartungsFunktionen(customerId, { now = new Date() } = {}) { const beendet = [] if (!customerId) return { beendet } try { const kandidaten = (await listCustomerFeatureDocs(customerId)).filter( (doc) => istAboModell(doc) && doc.projectId && (doc.state === 'setup' || doc.state === 'active') && nurImWartungsAbo(doc.featureKey) ) for (const doc of kandidaten) { try { const abgeschaltet = 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 // das Team war schneller const aus = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ_WECHSEL }) if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller if (aus.featureKey === 'unterkunft' && aus.siteKey) leereCache(aus.siteKey) return aus }) if (!abgeschaltet) continue beendet.push(abgeschaltet.$id) if (abgeschaltet.featureKey !== 'zeiten') await meldeBeendet(abgeschaltet, { art: 'hosting', now }) // nach der Sperre; bei Misserfolg wiederholt der Tagesabgleich } catch (err) { console.error(`[abo-funktionen] Sofortpfad: Funktion ${doc.$id} nicht vollständig beendet, der Tagesabgleich holt nach: ${fehlerText(err)}`) } } } catch (err) { console.error(`[abo-funktionen] Sofortpfad: Funktionen eines Kunden nicht lesbar, nichts beendet: ${fehlerText(err)}`) } return { beendet } } let tagesJobLaeuft = false /** * Tagesjob (server/index.js): der Abo-Ende-Abgleich ohne Ueberlappung. Laeuft noch ein Lauf (Merker im Speicher des Prozesses, im finally * geloescht), kehrt der Aufruf sofort mit { uebersprungen: true } zurueck. Wirft nie: ein unerwarteter Fehler steht im Log, das Ergebnis * ist { fehler: 1 }. Das Log nennt weder Namen noch Adressen. * @param {Date} [now] * @returns {Promise | { uebersprungen: true } | { fehler: 1 }>} */ export async function aboFunktionenTagesJob(now = new Date()) { if (tagesJobLaeuft) return { uebersprungen: true } tagesJobLaeuft = true try { return await gleicheAboFunktionenAb(now) } catch (err) { console.error(`[abo-funktionen] Tagesjob fehlgeschlagen: ${fehlerText(err)}`) return { fehler: 1 } } finally { tagesJobLaeuft = false } }