Öffnungszeiten: Abo-Abgleich mit 30 Tagen Frist
gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten (unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26), nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket), bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24 wieder), 30 Tage nach dem Abschalten Daten löschen und state off. Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen. R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503). R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab, K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53253f0451
commit
efc6dcf332
8 files changed
+1285
-10
No files matched your search
@@ -462,6 +462,10 @@ export async function activate(docId, { by, adminNote } = {}) {
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
aboEndeMailAt: null,
|
||||
// Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
|
||||
// Aktivierung (die Felder legt scripts/zeiten-setup.mjs an; News und Terminplaner teilen die Collection)
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { config } from '../../config.js'
|
||||
import { aboLage, aboMemo } from '../aboStatus.js'
|
||||
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon } from '../siteFeatures.js'
|
||||
import { LOESCHFRIST_TAGE } from '../termine/frist.js'
|
||||
import { pruefeNow, zeitMs } from '../termine/util.js'
|
||||
import { heute, plusTage, zuUtc } from '../termine/zeit.js'
|
||||
import { nachAbschaltung } from './abschalten.js'
|
||||
import { k24Mail, k26Mail, k27Mail, sendeZeitenMail } from './mails.js'
|
||||
import { stosseNeubauAn } from './neubau.js'
|
||||
import { ladeZeiten, loescheZeiten } from './speicher.js'
|
||||
|
||||
/**
|
||||
* Abo-Abgleich von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 5 "Abo-Abgleich" und Z11):
|
||||
* Die Funktion gehört im Wartungs-Abo ohne Aufpreis dazu und endet mit ihm, aber nicht sofort: 30 Tage Frist zum Behalten (30 €), danach
|
||||
* ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in
|
||||
* Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen.
|
||||
*
|
||||
* Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo
|
||||
* wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel):
|
||||
*
|
||||
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K26 (Variante nach art; Frist = Berliner Tag von
|
||||
* aboEndeAt + 30 Tage)
|
||||
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', K27 (Löschdatum = Abschalten + 30 Tage), danach
|
||||
* das Ersatz-Ticket des Teams (nachAbschaltung)
|
||||
* setup/active, Abo läuft, aboEndeAt gesetzt Schritt 3a: das Abo ist vor dem Abschalten zurück: aboEndeAt, Marker und offene
|
||||
* Behalten-Sitzung leeren, Zustand bleibt, KEIN Neubau (die Leisten waren nie weg),
|
||||
* K24 "wieder" ohne den Kasten "Wie geht es weiter?" (k24Mail einbau null, R17b)
|
||||
* disabled/'abo_ende', Abo läuft Schritt 3b: zurück auf active (war die Funktion schon einmal freigeschaltet:
|
||||
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K24 "wieder"
|
||||
* mit dem echten einbau. Solange die Eingaben noch da sind: auch am Tag der Löschung
|
||||
* geht die Rückkehr der Löschung vor (die Rückkehr wird je Lauf zuerst entschieden).
|
||||
* disabled/'abo_ende', Abo läuft nicht, 30 Tage Schritt 4: loescheZeiten (daten, team, Merker) und state 'off'; ticketId, einbau,
|
||||
* nach disabledAt k24At, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
|
||||
* Zeiten-Dokument). Grund 'team' (und jeder andere Grund) wird nie gelöscht.
|
||||
*
|
||||
* Tage zählen nach BERLINER KALENDERTAGEN (plusTage, nie Vielfache von 24 Stunden): "Tag 30" ist der 30. Berliner Tag nach dem Tag, an dem
|
||||
* das Abo-Ende erkannt wurde, egal um welche Uhrzeit der Tagesjob an diesem Tag läuft (er läuft im 24-Stunden-Takt ab dem Start des Servers
|
||||
* und kann je Tag ein paar Minuten früher oder später liegen; eine Zeitumstellung verschöbe die Frist um eine Stunde). Dieselben Tage stehen
|
||||
* in K26 und K27.
|
||||
*
|
||||
* Regeln (wie termine/aboAbgleich.js):
|
||||
* - Eine Listenabfrage für alle Kunden (Entwürfe 'off' bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird für die
|
||||
* Vorauswahl nur einmal gelesen; Dokumente, bei denen nichts zu tun ist (Abo läuft, kein Abo-Ende), kosten weder Sperre noch Schreiben.
|
||||
* - 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): der frische Stand entscheidet, nicht die Vorauswahl. Mails, Neubau und Ersatz-Ticket folgen NACH
|
||||
* der Sperre (Netzwerk; Sperren sind nicht wiederbetretbar).
|
||||
* - Fail-closed: ist das Abo nicht lesbar (Vorauswahl oder unter der Sperre), wird nichts abgeschaltet, nichts gelöscht und nichts
|
||||
* eingeschaltet. Der Kunde wird übersprungen, `fehler` zählt, der nächste Lauf versucht es wieder. Ein Dokument mit unlesbarem Datum
|
||||
* wird übersprungen (`fehler`), die übrigen laufen weiter. Das Log nennt weder Namen noch Adressen.
|
||||
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K26, nach dem
|
||||
* Abschalten der K27 (er wird beim Abschalten zurückgesetzt). Scheitert der Versand, bleibt der Marker leer, der nächste Lauf sendet nach
|
||||
* (dieselben Daten; die Frist wächst nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Ein Kunde ohne Adresse bekommt den
|
||||
* Marker (es gibt nichts zu senden). K24 "wieder" geht nur an Kunden, die K26 oder K27 bekommen haben (Marker, vor dem Leeren gelesen)
|
||||
* und hat keinen Marker: höchstens einmal je Rückkehr (scheitert der Versand, steht es im Log und in `fehler`).
|
||||
* - Ruling R25: die Rückkehr leert aboEndeAt und behaltenSessionId; eine noch offene Behalten-Zahlung bucht danach nicht mehr zum Kauf
|
||||
* (bucheBehalten: Sitzung passt nicht, T19), und starteBehalten lehnt bei laufendem Abo ab.
|
||||
*/
|
||||
|
||||
const KEY = 'zeiten'
|
||||
const GRUND = 'abo_ende'
|
||||
/** Frist zum Behalten nach dem erkannten Abo-Ende (Z11); die Datenfrist nach dem Abschalten ist LOESCHFRIST_TAGE (30). */
|
||||
const FRIST_TAGE = 30
|
||||
|
||||
const NOTIZ_ABGESCHALTET = 'System: Abgeschaltet: Wartungs-Abo beendet, Frist abgelaufen'
|
||||
const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Wartungs-Abo läuft wieder'
|
||||
const NOTIZ_OHNE_NEUBAU = 'System: Wartungs-Abo läuft wieder, Abo-Ende zurückgenommen'
|
||||
const NOTIZ_GELOESCHT = `System: Öffnungszeiten gelöscht, ${LOESCHFRIST_TAGE} Tage nach dem Abschalten`
|
||||
|
||||
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
|
||||
|
||||
/** Fehlertext fürs Log: Name und gekürzte 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
|
||||
|
||||
const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active'
|
||||
const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND
|
||||
/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */
|
||||
const istGekoppelt = (doc) => unlockedByVon(doc) === 'abo' && (istLaufend(doc) || istAbgeschaltet(doc))
|
||||
|
||||
/** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */
|
||||
function tagVon(wert, feld) {
|
||||
const t = zeitMs(wert)
|
||||
if (!Number.isFinite(t)) throw new Error(`${feld} nicht lesbar`)
|
||||
return heute(new Date(t))
|
||||
}
|
||||
/** Tag, an dem abgeschaltet wird: Berliner Tag von aboEndeAt + 30 Tage. */
|
||||
const fristTag = (doc) => plusTage(tagVon(doc.aboEndeAt, 'aboEndeAt'), FRIST_TAGE)
|
||||
/** Tag, an dem die Eingaben gelöscht werden: Berliner Tag von disabledAt + 30 Tage. */
|
||||
const loeschTag = (doc) => plusTage(tagVon(doc.disabledAt, 'disabledAt'), LOESCHFRIST_TAGE)
|
||||
/** Ein Date mitten an einem Berliner Tag: die Mail-Builder zeigen den Berliner Tag dieses Zeitpunkts (kein Springen über Mitternacht). */
|
||||
const mittags = (tag) => zuUtc(tag, '12:00')
|
||||
|
||||
/**
|
||||
* Was mit dem Dokument bei dieser Abo-Lage und diesem Tag zu tun ist (reine Entscheidung, null = nichts):
|
||||
* 'ende' | 'k26' | 'abschalten' | 'k27' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum.
|
||||
*/
|
||||
function entscheide(doc, lage, now) {
|
||||
const tag = heute(now)
|
||||
if (istAbgeschaltet(doc)) {
|
||||
if (lage.laeuft) return 'zurueck'
|
||||
if (tag >= loeschTag(doc)) return 'loeschen'
|
||||
return doc.aboEndeMailAt ? null : 'k27'
|
||||
}
|
||||
if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null
|
||||
if (!doc.aboEndeAt) return 'ende'
|
||||
if (tag >= fristTag(doc)) return 'abschalten'
|
||||
return doc.aboEndeMailAt ? null : 'k26'
|
||||
}
|
||||
|
||||
/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
|
||||
async function datenDa(featureId) {
|
||||
return (await ladeZeiten(featureId))?.daten != null
|
||||
}
|
||||
|
||||
/** Daten löschen (unter der Sperre feature-admin:<projectId>), danach das Dokument auf 'off' und leer. */
|
||||
async function loesche(frisch, ctx) {
|
||||
await loescheZeiten(frisch.$id)
|
||||
await updateDocument(portalFeatures(), frisch.$id, {
|
||||
...stateFields('off'),
|
||||
ticketId: '',
|
||||
einbau: '',
|
||||
k24At: null,
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`),
|
||||
})
|
||||
ctx.erg.geloescht += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Dokument unter der Sperre feature-admin:<projectId>: frisch lesen, frisch das Abo lesen, den frischen Stand entscheiden lassen und
|
||||
* schreiben. Liefert, was nach der Sperre zu tun ist ({ aktion, doc, lage, hatteMail }), oder null (nichts mehr zu tun, das Team war
|
||||
* schneller). Wirft bei Lesefehlern (Dokument oder Abo): dann ist nichts geschrieben.
|
||||
*/
|
||||
async function unterSperre(doc, ctx) {
|
||||
const { erg, jetzt, now } = ctx
|
||||
const frisch = await getDocument(portalFeatures(), doc.$id)
|
||||
if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo)
|
||||
const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
|
||||
const aktion = entscheide(frisch, lage, now)
|
||||
const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr
|
||||
|
||||
switch (aktion) {
|
||||
case 'ende': {
|
||||
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
|
||||
erg.aboEnde += 1
|
||||
return { aktion: 'k26', doc: neu, lage }
|
||||
}
|
||||
case 'k26':
|
||||
case 'k27':
|
||||
return { aktion, doc: frisch, lage }
|
||||
case 'abschalten': {
|
||||
const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) })
|
||||
if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
|
||||
erg.abgeschaltet += 1
|
||||
// ab jetzt gilt der Marker der K27, nicht mehr der der K26
|
||||
return { aktion: 'k27', doc: await updateDocument(portalFeatures(), frisch.$id, { aboEndeMailAt: null }), lage }
|
||||
}
|
||||
case 'loeschen':
|
||||
await loesche(frisch, ctx)
|
||||
return null
|
||||
case 'zurueck': {
|
||||
// Löschtag erreicht und die Daten sind schon weg (ein früherer Lauf wurde nach dem Löschen unterbrochen): nichts einzuschalten
|
||||
if (heute(now) >= loeschTag(frisch) && !(await datenDa(frisch.$id))) {
|
||||
await loesche(frisch, ctx)
|
||||
return null
|
||||
}
|
||||
const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`)
|
||||
const zurueck = frisch.activatedAt
|
||||
? await activate(frisch.$id, { adminNote: note })
|
||||
: await updateDocument(portalFeatures(), frisch.$id, {
|
||||
...stateFields('setup'),
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
aboEndeMailAt: null,
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: note,
|
||||
})
|
||||
erg.wiederEin += 1
|
||||
return { aktion, doc: zurueck, lage, hatteMail }
|
||||
}
|
||||
case 'zurueck_ohne_neubau': {
|
||||
const zurueck = await updateDocument(portalFeatures(), frisch.$id, {
|
||||
aboEndeAt: null,
|
||||
aboEndeMailAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_OHNE_NEUBAU}`),
|
||||
})
|
||||
erg.wiederEin += 1
|
||||
return { aktion, doc: zurueck, lage, hatteMail }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Das Projekt für Neubau und Mailtext; nicht lesbar oder gelöscht ergibt null (der Neubau meldet dann 'manuell', der Name fehlt). */
|
||||
async function ladeProjekt(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) console.error(`[zeiten-abo] Projekt ${projectId} nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
return null
|
||||
}
|
||||
}
|
||||
const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || ''
|
||||
const preis = () => config.features.zeiten.priceLabel
|
||||
|
||||
/** K26 oder K27 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt). */
|
||||
async function sendeMitMarker(doc, bauen, ctx) {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
const projekt = projektName(await ladeProjekt(doc.projectId))
|
||||
const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt }))
|
||||
if (sent) ctx.erg.mails += 1
|
||||
else if (reason !== 'keine_adresse') {
|
||||
ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach
|
||||
return
|
||||
}
|
||||
await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: ctx.jetzt })
|
||||
}
|
||||
|
||||
/** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
|
||||
async function sendeWiederMail(doc, einbau, ctx) {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
const projekt = projektName(await ladeProjekt(doc.projectId))
|
||||
const { sent, reason } = await sendeZeitenMail(customer, k24Mail({ kunde: customer, projekt, einbau, wieder: true }))
|
||||
if (sent) ctx.erg.mails += 1
|
||||
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K26 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
|
||||
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K27 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
|
||||
* und K24 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K24 "wieder" mit einbau null.
|
||||
*/
|
||||
async function danach({ aktion, doc, lage, hatteMail }, ctx) {
|
||||
switch (aktion) {
|
||||
case 'k26':
|
||||
await sendeMitMarker(doc, ({ kunde, projekt }) => k26Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
|
||||
return
|
||||
case 'k27':
|
||||
if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') ctx.erg.fehler += 1
|
||||
await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx)
|
||||
return
|
||||
case 'zurueck': {
|
||||
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
|
||||
try {
|
||||
await updateDocument(portalFeatures(), doc.$id, { einbau })
|
||||
} catch (err) {
|
||||
console.error(`[zeiten-abo] einbau für Funktion ${doc.$id} nicht gespeichert: ${fehlerText(err)}`)
|
||||
}
|
||||
if (hatteMail) await sendeWiederMail(doc, einbau, ctx)
|
||||
return
|
||||
}
|
||||
case 'zurueck_ohne_neubau':
|
||||
if (hatteMail) await sendeWiederMail(doc, null, ctx)
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Dokument: unter der Sperre schreiben, danach die Folgen. Wirft bei Fehlern (der Aufrufer zählt fehler). */
|
||||
async function bearbeiteDokument(doc, ctx) {
|
||||
const folge = await withLocks([`feature-admin:${doc.projectId}`], () => unterSperre(doc, ctx))
|
||||
if (folge) await danach(folge, ctx)
|
||||
}
|
||||
|
||||
async function bearbeiteKunde(customerId, dokumente, ctx) {
|
||||
const { erg, now } = ctx
|
||||
const gekoppelt = dokumente.filter(istGekoppelt)
|
||||
if (gekoppelt.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(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
|
||||
return
|
||||
}
|
||||
erg.geprueft += gekoppelt.length
|
||||
|
||||
for (const doc of gekoppelt) {
|
||||
// je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf
|
||||
try {
|
||||
if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage
|
||||
await bearbeiteDokument(doc, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Täglicher Abgleich von Öffnungszeiten live 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 Zeitpunkt des Laufs (die Tage zählen nach dem Berliner Kalendertag von now)
|
||||
* @returns {Promise<{ geprueft: number, aboEnde: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>}
|
||||
* geprueft = ans Abo gekoppelte Dokumente (unlockedBy abo, setup/active oder abgeschaltet mit Grund abo_ende), deren Kunde lesbar war;
|
||||
* aboEnde = in diesem Lauf als beendet erkannt (Schritt 1); abgeschaltet = in diesem Lauf abgeschaltet (2); wiederEin = in diesem Lauf
|
||||
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K24 "wieder", K26 und
|
||||
* K27; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
|
||||
*/
|
||||
export async function gleicheZeitenAboAb(now) {
|
||||
pruefeNow(now, 'gleicheZeitenAboAb')
|
||||
const erg = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
|
||||
try {
|
||||
let dokumente
|
||||
try {
|
||||
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.notEqual('state', 'off')])
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-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 gälte "kein Abo": nie abschalten oder löschen, was sich nicht zuordnen lässt
|
||||
if (unlockedByVon(doc) === 'abo') {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-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, jetzt: now.toISOString(), memo: aboMemo() }
|
||||
for (const [customerId, liste] of nachKunde) {
|
||||
try {
|
||||
await bearbeiteKunde(customerId, liste, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Abgleich abgebrochen: ${fehlerText(err)}`)
|
||||
}
|
||||
return erg
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { config } from '../../config.js'
|
||||
import { aboLage } from '../aboStatus.js'
|
||||
import { getDocument, updateDocument } from '../appwriteAdmin.js'
|
||||
import { featureConfig } from '../featureRegistry.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
@@ -29,6 +30,8 @@ const KEY = 'zeiten'
|
||||
const konflikt = (text) => Object.assign(new Error(text), { status: 409 })
|
||||
|
||||
const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.'
|
||||
const ABO_LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
const ABO_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'
|
||||
const ZAHLUNG_LAEUFT = 'Deine Zahlung wird gerade bestätigt. Bitte schau in einigen Minuten noch einmal vorbei.'
|
||||
const STRIPE_NICHT_ERREICHBAR = 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
|
||||
|
||||
@@ -48,6 +51,22 @@ async function behaltenMoeglich(doc) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft das Wartungs-Abo wieder, ist die Funktion darin enthalten und der Abo-Abgleich nimmt das Abo-Ende zurück (zeiten/aboAbgleich.js):
|
||||
* in der Zeit dazwischen soll niemand 30 € für etwas bezahlen, das ohnehin enthalten ist (Ruling R25). Frisch gelesen (aboLage direkt, nie
|
||||
* ein Zwischenspeicher); ein Lesefehler ist 503 (fail-closed: unklar, ob das Abo läuft), im Log stehen nur Art und Status.
|
||||
*/
|
||||
async function pruefeAboLaeuftNicht(customerId) {
|
||||
let lage
|
||||
try {
|
||||
lage = await aboLage(customerId)
|
||||
} catch (err) {
|
||||
console.error(`[zeiten] Behalten: Wartungs-Abo nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
throw Object.assign(new Error(ABO_NICHT_PRUEFBAR), { status: 503 })
|
||||
}
|
||||
if (lage.laeuft === true) throw konflikt(ABO_LAEUFT)
|
||||
}
|
||||
|
||||
/** Die gespeicherte Behalten-Sitzung bei Stripe: offen => { url }; abgelaufen oder unbekannt => null (neue Sitzung); sonst 409 bzw. 503. */
|
||||
async function offeneSitzung(sessionId) {
|
||||
let session
|
||||
@@ -73,7 +92,7 @@ async function offeneSitzung(sessionId) {
|
||||
* @param {{ customer: { $id: string }, projectId: string, nachweis: (sessionId: string) => Promise<unknown>, now?: Date }} p
|
||||
* customer: Kundendokument; nachweis: speichert den Zustimmungsnachweis zur Stripe-Sitzung; now: Zeit des Klicks (Metadaten consentAt)
|
||||
* @returns {Promise<{ url: string }>}
|
||||
* @throws {Error} 409 (nicht moeglich, Zahlung laeuft), 503 (Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis
|
||||
* @throws {Error} 409 (nicht moeglich, Wartungs-Abo laeuft wieder, Zahlung laeuft), 503 (Abo oder Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis
|
||||
*/
|
||||
export async function starteBehalten({ customer, projectId, nachweis, now = new Date() } = {}) {
|
||||
if (typeof customer?.$id !== 'string' || customer.$id === '') throw new TypeError('starteBehalten: customer fehlt')
|
||||
@@ -84,6 +103,8 @@ export async function starteBehalten({ customer, projectId, nachweis, now = new
|
||||
return withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, KEY)
|
||||
if (!(await behaltenMoeglich(doc))) throw konflikt(NICHT_MOEGLICH)
|
||||
// läuft das Abo wieder (der Abgleich hat das Abo-Ende noch nicht zurückgenommen), gibt es weder eine neue noch die alte Zahlungsseite
|
||||
await pruefeAboLaeuftNicht(customerId)
|
||||
|
||||
// eine schon begonnene Zahlung: dieselbe Seite nochmal, nie eine zweite Sitzung daneben
|
||||
if (doc.behaltenSessionId) {
|
||||
|
||||
@@ -39,12 +39,16 @@ function datum(wert, feld) {
|
||||
* @param {{ kunde: string | { name?: string }, projekt: string, einbau?: string | null, wieder?: boolean, ticketNr?: string | number | null }} p
|
||||
* einbau: 'angestossen' (Neubau läuft: Leisten erscheinen nach dem nächsten Veröffentlichen) oder 'manuell' (das Team baut ein);
|
||||
* alles andere, auch ein fehlender Wert, sagt das Vorsichtige: das Team baut ein.
|
||||
* Ausnahme: einbau null zusammen mit wieder = es gab keinen Neubau (das Abo kehrte vor dem Abschalten zurück, die Leisten waren nie weg):
|
||||
* beide Einbau-Blöcke und der ganze Kasten „Wie geht es weiter?“ (Block weiter) entfallen.
|
||||
* wieder: das Wartungs-Abo läuft wieder und die Funktion wurde automatisch wieder eingeschaltet.
|
||||
* ticketNr: Nummer des Einbau-Tickets; ohne sie steht kein Ticket in der Mail.
|
||||
*/
|
||||
export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = null }) {
|
||||
const nr = String(ticketNr ?? '').trim().replace(/^#/, '')
|
||||
const keinNeubau = wieder === true && einbau === null
|
||||
const angestossen = einbau === 'angestossen'
|
||||
const manuell = !keinNeubau && !angestossen
|
||||
return {
|
||||
template: 'K24',
|
||||
values: {
|
||||
@@ -54,7 +58,7 @@ export function k24Mail({ kunde, projekt, einbau, wieder = false, ticketNr = nul
|
||||
ticket: nr ? ` (Ticket #${nr})` : '',
|
||||
portal_url: portalUrl(),
|
||||
},
|
||||
blocks: { angestossen, manuell: !angestossen, wieder: Boolean(wieder) },
|
||||
blocks: { angestossen, manuell, wieder: Boolean(wieder), weiter: angestossen || manuell },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user