Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen
gleicheAboAb(now) liest alle nicht abgeschalteten Terminplaner-Funktionen einmal, bewertet je Kunde die Abo-Lage (aboMemo) und schaltet aktive, bezahlte Funktionen ohne laufendes Wartungs-Abo ab (disabledReason abo_ende). Geschrieben wird nur unter feature-admin:<projectId> nach frischem Lesen von Dokument und Abo; bei Lesefehlern bleibt alles aktiv. Pilot- und Kulanz-Dokumente zaehlen als ausgenommen. meldeAboEnde sendet K22 und setzt aboEndeMailAt erst nach Erfolg. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
973135d2de
commit
17f933dbc7
2 files changed
+780
No files matched your search
@@ -0,0 +1,205 @@
|
||||
import { config } from '../../config.js'
|
||||
import { aboLage, aboMemo } from '../aboStatus.js'
|
||||
import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendCustomerMail } from '../mailer.js'
|
||||
import { customerOf, disable, projectName } from '../siteFeatures.js'
|
||||
import { loeschzeit } from './frist.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
import { datumKurz } from './zeit.js'
|
||||
|
||||
/**
|
||||
* Abo-Abgleich des Terminplaners (Terminplaner TP2, Block E): der Terminplaner gehoert zum Wartungs-Abo und endet mit ihm.
|
||||
* gleicheAboAb(now) ist der Baustein fuer den Tagesjob (angeschlossen in E7); er kennt weder Schalter noch Ueberlappungssperre.
|
||||
*
|
||||
* Schritt 1 aktive, bezahlte Terminplaner (state active, unlockedByPurchase true) ohne laufendes Wartungs-Abo => abschalten
|
||||
* (disabledReason 'abo_ende'; ab disabledAt laeuft die 30-Tage-Frist bis zur Loeschung, jobs.js)
|
||||
* Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' ohne aboEndeMailAt die Mail K22 nachholen (auch fuer die
|
||||
* eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem Versand gesetzt
|
||||
*
|
||||
* Regeln (Block E):
|
||||
* - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird
|
||||
* fuer die Vorauswahl nur einmal gelesen. Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz: unlockedByPurchase
|
||||
* nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage.
|
||||
* - Geschrieben wird nur unter der Sperre feature-admin:<projectId> (wie die Team-Route) und nach frischem Lesen von Dokument UND
|
||||
* Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten.
|
||||
* - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden
|
||||
* uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen.
|
||||
* - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt K22 fehl, wiederholt der naechste Tag den Versand mit
|
||||
* denselben Daten (Enddatum, Loeschdatum), die Loeschfrist waechst nicht.
|
||||
* - Nur die Gruende aus siteFeatures.DISABLED_REASONS gelten. 'team', 'kunde_geloescht' und Altbestand ohne Grund fasst der
|
||||
* Abgleich nie an; 'abo_ende_vor_einrichtung' gehoert zu E5, ordered/setup ebenfalls (sie zaehlen hier nur als geprueft).
|
||||
*/
|
||||
|
||||
const GRUND = 'abo_ende'
|
||||
const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet'
|
||||
|
||||
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 istAusgenommen = (doc) => doc.state === 'active' && doc.unlockedByPurchase !== true
|
||||
const istBewertet = (doc) =>
|
||||
(doc.state === 'active' && doc.unlockedByPurchase === true) ||
|
||||
doc.state === 'ordered' ||
|
||||
doc.state === 'setup' ||
|
||||
(doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende'))
|
||||
|
||||
const portalFeatures = () => config.collections.portalFeatures
|
||||
|
||||
/**
|
||||
* Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie K22 einzeln):
|
||||
* K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now.
|
||||
* Grund 'abo_ende': Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen': das
|
||||
* Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach.
|
||||
* Der Aufrufer liest `doc` frisch und haelt die Sperre feature-admin:<projectId> (diese Funktion sperrt nicht, damit der Sofortpfad
|
||||
* sie aus seiner eigenen Sperre rufen kann). Ohne Kunde oder ohne E-Mail gibt es nichts zu senden: der Marker wird gesetzt, damit
|
||||
* es kein Wiederholen gibt. Wirft nie.
|
||||
* @param {object} doc portalFeatures-Dokument (disabled)
|
||||
* @param {{ lage: { art: string }, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen
|
||||
* @returns {Promise<boolean>} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker
|
||||
* gescheitert (der naechste Tag wiederholt)
|
||||
*/
|
||||
export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) {
|
||||
const nowMs = pruefeNow(now, 'meldeAboEnde')
|
||||
if (doc.disabledReason !== GRUND || doc.aboEndeMailAt) return true // E5 ergaenzt abo_ende_vor_einrichtung; mit Marker ist alles gesagt
|
||||
const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() })
|
||||
let gesendet = false
|
||||
try {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
if (!customer?.email) {
|
||||
console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt')
|
||||
await setzeMarker()
|
||||
return true
|
||||
}
|
||||
const projekt = (await projectName(doc.projectId)) || 'Dein Projekt'
|
||||
const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten
|
||||
await sendCustomerMail(customer.email, {
|
||||
template: 'K22',
|
||||
values: {
|
||||
kunde_name: customer.name || '',
|
||||
projekt,
|
||||
enddatum: datumKurz(beginn),
|
||||
loeschdatum: datumKurz(loeschzeit(beginn)),
|
||||
preis: config.features.termine.priceLabel,
|
||||
portal_url: `${config.publicBaseUrl}/dashboard.html#termine`,
|
||||
},
|
||||
blocks: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true },
|
||||
})
|
||||
gesendet = true
|
||||
onGesendet?.()
|
||||
await setzeMarker()
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[termine-abo] K22 für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Dokument unter seiner Sperre: frisch lesen, frisch das Abo lesen, erst dann schreiben.
|
||||
* Dokument war in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten, dann K22.
|
||||
* Dokument war in der Liste abgeschaltet (abo_ende) => noch so und ohne Marker, Abo laeuft weiter nicht => K22 nachholen.
|
||||
*/
|
||||
async function bearbeiteDokument(doc, ctx) {
|
||||
const { erg, now } = ctx
|
||||
const warAktiv = doc.state === 'active'
|
||||
await withLocks([`feature-admin:${doc.projectId}`], async () => {
|
||||
const frisch = await getDocument(portalFeatures(), doc.$id)
|
||||
if (warAktiv) {
|
||||
if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return // das Team war schneller
|
||||
} else if (frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) {
|
||||
return
|
||||
}
|
||||
const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
|
||||
if (lage.laeuft) return // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6)
|
||||
|
||||
let zuMelden = frisch
|
||||
if (warAktiv) {
|
||||
zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ })
|
||||
if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return // jemand war zwischen Lesen und Schreiben schneller
|
||||
erg.abgeschaltet += 1
|
||||
}
|
||||
const fertig = await meldeAboEnde(zuMelden, { lage, now, onGesendet: () => (erg.mails += 1) })
|
||||
if (!fertig) erg.fehler += 1
|
||||
})
|
||||
}
|
||||
|
||||
async function bearbeiteKunde(customerId, dokumente, ctx) {
|
||||
const { erg } = ctx
|
||||
erg.ausgenommen += dokumente.filter(istAusgenommen).length
|
||||
const bewertet = dokumente.filter(istBewertet)
|
||||
if (bewertet.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen
|
||||
|
||||
let lage
|
||||
try {
|
||||
lage = await ctx.memo.lage(customerId)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${bewertet.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
|
||||
return
|
||||
}
|
||||
erg.geprueft += bewertet.length
|
||||
if (lage.laeuft) return // laeuft weiter (E6 schaltet abgeschaltete wieder ein)
|
||||
|
||||
for (const doc of bewertet) {
|
||||
const aktiv = doc.state === 'active'
|
||||
const nachholen = doc.state === 'disabled' && doc.disabledReason === GRUND && !doc.aboEndeMailAt
|
||||
if (!aktiv && !nachholen) continue
|
||||
try {
|
||||
await bearbeiteDokument(doc, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taeglicher Abgleich des Terminplaners mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie
|
||||
* alle Termine-Jobs); jedes Problem steht im Log und in `fehler`.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>}
|
||||
* geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde
|
||||
* lesbar war), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = wieder eingeschaltet (E6; hier immer 0), mails = versendete
|
||||
* K22, ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz), fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten
|
||||
*/
|
||||
export async function gleicheAboAb(now) {
|
||||
pruefeNow(now, 'gleicheAboAb')
|
||||
const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }
|
||||
try {
|
||||
let dokumente
|
||||
try {
|
||||
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')])
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`)
|
||||
return erg
|
||||
}
|
||||
const nachKunde = new Map()
|
||||
for (const doc of dokumente) {
|
||||
if (!doc.customerId || !doc.projectId) {
|
||||
// ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] 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, memo: aboMemo() }
|
||||
for (const [customerId, liste] of nachKunde) {
|
||||
try {
|
||||
await bearbeiteKunde(customerId, liste, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[termine-abo] Abgleich abgebrochen: ${fehlerText(err)}`)
|
||||
}
|
||||
return erg
|
||||
}
|
||||
Reference in new issue
Block a user