Ö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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 03:16:56 +00:00
1 parent 53253f0451
commit efc6dcf332
8 files changed
+1285 -10

No files matched your search

@@ -8,9 +8,10 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{ticket}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angestossen, manuell, wieder
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angestossen, manuell, wieder, weiter
Geht an Kunden, die Öffnungszeiten live im Wartungs-Abo selbst einschalten (Gratisweg), und, mit dem Block wieder, wenn die Funktion nach einem Abo-Ende wieder eingeschaltet wird.
Genau einer der Blöcke angestossen (der Neubau der Website läuft, die Leisten erscheinen nach dem nächsten Veröffentlichen) oder manuell (das Team baut ein) ist an; wieder kommt dazu, wenn das Wartungs-Abo zurückgekehrt ist.
weiter umschließt den ganzen Kasten „Wie geht es weiter?“ und ist an, wenn angestossen oder manuell an ist. Kehrt das Abo zurück, bevor die Funktion abgeschaltet wurde, gab es keinen Neubau: dann sind angestossen und manuell aus, der Kasten entfällt und nur der Block wieder erklärt die Lage.
ticket = Hinweis auf das Einbau-Ticket mit führendem Leerzeichen, zum Beispiel „ (Ticket #100123)“, oder leer.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
@@ -40,7 +41,9 @@ a{color:#111111;}
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Öffnungszeiten live ist eingeschaltet</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>Öffnungszeiten live ist für Dein Projekt {{projekt}} eingeschaltet. Die Funktion ist in Deinem Wartungs-Abo ohne Aufpreis enthalten.<!-- WENN wieder --> Dein Wartungs-Abo läuft wieder, deshalb haben wir sie automatisch und ohne Gebühr wieder eingeschaltet. Deine Zeiten und Einstellungen sind unverändert da.<!-- ENDE wieder --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Öffnungszeiten live</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<!-- WENN weiter -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;"><!-- WENN angestossen -->Die Hinweis-Leiste und die Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen Deiner Website, meist in wenigen Minuten. Die Zeiten-Tabelle setzt unser Team zusätzlich per Ticket an die passende Stelle Deiner Seite{{ticket}}.<!-- ENDE angestossen --><!-- WENN manuell -->Deine Website können wir nicht automatisch aktualisieren. Deshalb baut unser Team die Hinweis-Leiste, die Kontakt-Leiste und die Zeiten-Tabelle von Hand in Deine Seite ein{{ticket}}.<!-- ENDE manuell --></div><div class="mut" style="color:#4a4843;padding-top:8px;">Im Kundenportal trägst Du Deine Öffnungszeiten ein und änderst sie jederzeit. Ohne gespeicherte Zeiten kann die Tabelle nicht eingebaut werden.</div></td></tr></table></td></tr>
<!-- ENDE weiter -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zeiten bearbeiten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
+4
View File
@@ -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 } : {}),
})
}
+348
View File
@@ -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
}
+22 -1
View File
@@ -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) {
+5 -1
View File
@@ -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 },
}
}