Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt

K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:00:17 +00:00
1 parent ea4457e6a4
commit c7cd436ec2
34 files changed
+359 -354

No files matched your search

+3 -3
View File
@@ -828,7 +828,7 @@ async function ticketNachZahlung(doc, note) {
/**
* Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt
* holeZeitenNach nach (Neubau, Einbau-Ticket, K24 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
* holeZeitenNach nach (Neubau, Einbau-Ticket, K27 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
* erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen ->
* zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es).
*/
@@ -868,7 +868,7 @@ async function ersetzeGeloeschtesTicket(doc, note) {
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
* Ticket + K15 + T16 genau einmal (ticketNachZahlung); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt
* holeZeitenNach Neubau, Ticket und K24 nach (ticketNachHolen);
* holeZeitenNach Neubau, Ticket und K27 nach (ticketNachHolen);
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
* (c) Ticket vorhanden: nichts, created false.
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
@@ -1483,7 +1483,7 @@ async function verwerfeBehaltenSitzung(session) {
/**
* Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte
* nach (Neubau, Einbau-Ticket, K24; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in
* nach (Neubau, Einbau-Ticket, K27; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in
* activateClosedTickets: dort zaehlen nur Dokumente MIT Ticket. Jedes Dokument fuer sich abgesichert (ein Fehler stoppt weder die
* uebrigen noch den Job; der naechste Lauf versucht es erneut), das Log nennt Dokument-ID und Fehler, nie Kundendaten.
* Dynamischer Import: zeiten/* importiert dieses Modul.
+41 -41
View File
@@ -7,7 +7,7 @@ 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 { k27Mail, k29Mail, k30Mail, sendeZeitenMail } from './mails.js'
import { stosseNeubauAn } from './neubau.js'
import { ladeZeiten, loescheZeiten } from './speicher.js'
@@ -20,25 +20,25 @@ import { ladeZeiten, loescheZeiten } from './speicher.js'
* 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
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K29 (Variante nach art; Frist = Berliner Tag von
* aboEndeAt + 30 Tage)
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', dann das Ersatz-Ticket des Teams
* (nachAbschaltung), dann K27 (Löschdatum = Abschalten + 30 Tage)
* (nachAbschaltung), dann K30 (Löschdatum = Abschalten + 30 Tage)
* 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)
* K27 "wieder" ohne den Kasten "Wie geht es weiter?" (k27Mail 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"
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K27 "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, activatedAt, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
* nach disabledAt einschaltMailAt, activatedAt, 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.
* in K29 und K30.
*
* 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
@@ -49,12 +49,12 @@ import { ladeZeiten, loescheZeiten } from './speicher.js'
* - 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 (nur ein Marker, der nicht vor disabledAt liegt: k27Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K29, nach dem
* Abschalten der K30 (nur ein Marker, der nicht vor disabledAt liegt: k30Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es
* könnte scheitern). 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). Die K27 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es
* weder K27 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K27 so höchstens einmal). 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)
* nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Die K30 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es
* weder K30 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K30 so höchstens einmal). Ein Kunde ohne
* Adresse bekommt den Marker (es gibt nichts zu senden). K27 "wieder" geht nur an Kunden, die K29 oder K30 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.
@@ -97,27 +97,27 @@ 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.
* 'ende' | 'k29' | 'abschalten' | 'k30' | '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 k27Gesendet(doc) ? null : 'k27'
return k30Gesendet(doc) ? null : 'k30'
}
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'
return doc.aboEndeMailAt ? null : 'k29'
}
/**
* Hat die K27 ihren Marker? aboEndeMailAt gilt der K27 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K26 (aus der Frist)
* ist älter als disabledAt. So hängt die K27 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte
* (scheiterte dieses Schreiben, blieb der K26-Marker stehen und K27 samt Ersatz-Ticket liefen nie).
* Hat die K30 ihren Marker? aboEndeMailAt gilt der K30 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K29 (aus der Frist)
* ist älter als disabledAt. So hängt die K30 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte
* (scheiterte dieses Schreiben, blieb der K29-Marker stehen und K30 samt Ersatz-Ticket liefen nie).
*/
const k27Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt))
const k30Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt))
/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
async function datenDa(featureId) {
@@ -131,7 +131,7 @@ async function loesche(frisch, ctx) {
...stateFields('off'),
ticketId: '',
einbau: '',
k24At: null,
einschaltMailAt: null,
activatedAt: null, // ein späteres Einschalten beginnt wieder in setup (sonst käme es durch das alte activatedAt zu früh auf active)
aboEndeAt: null,
behaltenSessionId: '',
@@ -157,17 +157,17 @@ async function unterSperre(doc, ctx) {
case 'ende': {
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
erg.aboEnde += 1
return { aktion: 'k26', doc: neu, lage }
return { aktion: 'k29', doc: neu, lage }
}
case 'k26':
case 'k27':
case 'k29':
case 'k30':
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 (k27Gesendet: der K26-Marker ist älter als disabledAt, kein Zurücksetzen nötig)
return { aktion: 'k27', doc: aus, lage }
// ab jetzt gilt der Marker der K30, nicht mehr der der K29 (k30Gesendet: der K29-Marker ist älter als disabledAt, kein Zurücksetzen nötig)
return { aktion: 'k30', doc: aus, lage }
}
case 'loeschen':
await loesche(frisch, ctx)
@@ -221,9 +221,9 @@ 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).
* `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K27: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem
* `now` des Laufs schreibt; ein früherer Marker gälte k27Gesendet nicht und die K27 ginge jeden Tag erneut raus).
* K29 oder K30 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt).
* `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K30: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem
* `now` des Laufs schreibt; ein früherer Marker gälte k30Gesendet nicht und die K30 ginge jeden Tag erneut raus).
*/
async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
const customer = await customerOf(doc.customerId)
@@ -238,33 +238,33 @@ async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: marker })
}
/** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
/** K27 "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 }))
const { sent, reason } = await sendeZeitenMail(customer, k27Mail({ 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.
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K29 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K30 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
* und K27 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K27 "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)
case 'k29':
await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
return
case 'k27':
// Erst das Ersatz-Ticket, dann K27 mit Marker. Scheitert das Ticket, gibt es weder K27 noch Marker: der nächste Lauf wiederholt beides
// (das Ticket ist über den Hash idempotent, die K27 ging noch nicht raus und geht so höchstens einmal raus)
case 'k30':
// Erst das Ersatz-Ticket, dann K30 mit Marker. Scheitert das Ticket, gibt es weder K30 noch Marker: der nächste Lauf wiederholt beides
// (das Ticket ist über den Hash idempotent, die K30 ging noch nicht raus und geht so höchstens einmal raus)
if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') {
ctx.erg.fehler += 1
return
}
await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt)
await sendeMitMarker(doc, ({ kunde, projekt }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt)
return
case 'zurueck': {
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
@@ -323,8 +323,8 @@ async function bearbeiteKunde(customerId, dokumente, ctx) {
* @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
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K27 "wieder", K29 und
* K30; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
*/
export async function gleicheZeitenAboAb(now) {
pruefeNow(now, 'gleicheZeitenAboAb')
+14 -14
View File
@@ -5,7 +5,7 @@ import { featureConfig, ticketTexte } from '../featureRegistry.js'
import { withLocks } from '../keyedLock.js'
import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js'
import { createFeatureTicket } from '../ticketAdmin.js'
import { k24Mail, sendeZeitenMail } from './mails.js'
import { k27Mail, sendeZeitenMail } from './mails.js'
import { stosseNeubauAn } from './neubau.js'
import { ladeZeiten } from './speicher.js'
import { hauptplan, statusZu } from './zeitlogik.js'
@@ -16,8 +16,8 @@ import { hauptplan, statusZu } from './zeitlogik.js'
*
* einschaltenZeiten Schritte 1-4 unter der Sperre `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout, damit sich
* Bestellen und Einschalten ausschliessen): Abo frisch pruefen, Zustand pruefen (idempotent), Nachweis speichern,
* Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K24).
* holeZeitenNach Schritte 5 und 6, jeder fuer sich nur, wenn er noch fehlt: Neubau (einbau), Einbau-Ticket, K24, dann k24At.
* Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K27).
* holeZeitenNach Schritte 5 und 6, jeder fuer sich nur, wenn er noch fehlt: Neubau (einbau), Einbau-Ticket, K27, dann einschaltMailAt.
* Eigene Sperre `zeiten-nachholen:<dokument>`: ein Doppelklick und der 5-Minuten-Job legen nie zwei Tickets an.
*
* Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn nimmt seine eigene Sperre `zeiten-neubau`, darum laufen Neubau, Ticket
@@ -47,8 +47,8 @@ function notizDesTeams(staff, bisherige, now) {
* Oeffnungszeiten live fuer ein Projekt einschalten (Spec 3.2, Schritte 1 bis 6).
* @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null,
* nachweis?: (() => Promise<unknown>)|null, now?: Date }} p
* by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K24 geht an den Kunden.
* by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K24; staff ergibt die Notiz am Dokument.
* by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K27 geht an den Kunden.
* by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K27; staff ergibt die Notiz am Dokument.
* nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert.
* @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf
* @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen)
@@ -97,7 +97,7 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s
return { doc: gespeichert, changed: true }
})
// 5 und 6 nach der Sperre: Neubau, Ticket, K24
// 5 und 6 nach der Sperre: Neubau, Ticket, K27
try {
return { doc: await holeZeitenNach(doc, { now }), changed }
} catch (err) {
@@ -112,11 +112,11 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s
* vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung):
* 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau
* 2. ticketId leer => Einbau-Ticket aus dem Register (Text mit dem frischen einbau), ticketId
* 3. unlockedBy abo und k24At leer => K24 an den Kunden, danach k24At. Gesendet wird erst nach dem Ticket (die Mail nennt es);
* nicht gesendet (Fehler) laesst k24At leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt k24At (es gibt nichts zu senden).
* 3. unlockedBy abo und einschaltMailAt leer => K27 an den Kunden, danach einschaltMailAt. Gesendet wird erst nach dem Ticket (die Mail nennt es);
* nicht gesendet (Fehler) laesst einschaltMailAt leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt einschaltMailAt (es gibt nichts zu senden).
* Jeder Schritt erst nach dem Erfolg des vorigen. Liest das Dokument unter der Sperre frisch; andere Zustaende bleiben unberuehrt.
* Wirft bei Lesefehlern und wenn das Ticket nicht angelegt werden kann (der Aufrufer faengt und versucht es spaeter); eine
* nicht gesendete K24 wirft nicht.
* nicht gesendete K27 wirft nicht.
* @param {{ $id: string }} doc portalFeatures-Dokument der Zeiten (nur $id zaehlt)
* @param {{ now?: Date }} [options]
* @returns {Promise<object>} das aktuelle Dokument
@@ -126,9 +126,9 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) {
return withLocks([`zeiten-nachholen:${doc.$id}`], async () => {
let frisch = await getDocument(config.collections.portalFeatures, doc.$id)
if (frisch.state !== 'setup' || unlockedByVon(frisch) === 'kauf') return frisch
const mitK24 = unlockedByVon(frisch) === 'abo'
const mitK27 = unlockedByVon(frisch) === 'abo'
// nichts fehlt: weder Projekt noch Kunde lesen (der 5-Minuten-Job fragt jedes Dokument in setup)
if (frisch.einbau && frisch.ticketId && !(mitK24 && !frisch.k24At)) return frisch
if (frisch.einbau && frisch.ticketId && !(mitK27 && !frisch.einschaltMailAt)) return frisch
const feature = featureConfig(KEY)
// das Projekt frisch: der Neubau und der Text des Tickets brauchen den Stand nach dem Speichern des Zustands
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
@@ -145,8 +145,8 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) {
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId })
}
if (mitK24 && !frisch.k24At) {
const nachricht = k24Mail({
if (mitK27 && !frisch.einschaltMailAt) {
const nachricht = k27Mail({
kunde: customer,
projekt: project?.projectName || project?.subdomain || '',
einbau: frisch.einbau,
@@ -155,7 +155,7 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) {
})
const { sent, reason } = await sendeZeitenMail(customer, nachricht)
if (sent || reason === 'keine_adresse') {
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { k24At: now.toISOString() })
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einschaltMailAt: now.toISOString() })
}
}
return frisch
+5 -5
View File
@@ -4,7 +4,7 @@ import { createDocument, deleteDocument, getDocument, listAllDocuments, Query }
import { customerOf } from '../siteFeatures.js'
import { heute, plusTage } from '../termine/zeit.js'
import { pruefeNow } from '../termine/util.js'
import { k25Mail, sendeZeitenMail } from './mails.js'
import { k28Mail, sendeZeitenMail } from './mails.js'
import { zeitenFuerKunde, zeitenModus } from './schalter.js'
import { ladeZeiten } from './speicher.js'
import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.js'
@@ -22,7 +22,7 @@ import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.js'
* an denen der Hauptplan zu ist, eine Ausnahme gilt oder der Kunde schon eine Abweichung gespeichert hat).
*
* Höchstens einmal: je Tag zuerst der Merker (zeitenErinnerungen, Dokument-ID = sha1(`featureId|datum`), 36 Zeichen); 409 = der Tag wurde
* schon erinnert, er fehlt in der Mail. Danach EINE Mail K25 je Dokument mit allen neu vorgemerkten Tagen, ohne Kopie ans Team
* schon erinnert, er fehlt in der Mail. Danach EINE Mail K28 je Dokument mit allen neu vorgemerkten Tagen, ohne Kopie ans Team
* (bccTeam false, R17c). Der Merker steht VOR der Mail: Stirbt der Server dazwischen oder scheitert der Versand, geht für diese Tage nie
* eine Mail raus ("lieber einmal zu wenig als doppelt"); die Mail wird nicht wiederholt, der Fehler steht im Log und in `fehler`.
* Hat der Kunde keine Adresse (Kundendokument fehlt oder ohne E-Mail), wird gar kein Merker angelegt: sobald es eine Adresse gibt,
@@ -82,7 +82,7 @@ async function merkeVor(doc, tage, ctx) {
return neu
}
/** Ein Dokument: Zeiten lesen, Tage bestimmen, Merker anlegen, K25 senden. Wirft bei Fehlern (der Aufrufer zählt fehler). */
/** Ein Dokument: Zeiten lesen, Tage bestimmen, Merker anlegen, K28 senden. Wirft bei Fehlern (der Aufrufer zählt fehler). */
async function bearbeiteDokument(doc, ctx) {
let kunde // undefined = noch nicht gelesen
const holeKunde = async () => {
@@ -111,7 +111,7 @@ async function bearbeiteDokument(doc, ctx) {
if (neu.length === 0) return undefined // alles schon erinnert
const projekt = await projektName(doc.projectId)
const { sent, reason } = await sendeZeitenMail(k, k25Mail({ kunde: k, projekt, tage: neu }), { bccTeam: false })
const { sent, reason } = await sendeZeitenMail(k, k28Mail({ kunde: k, projekt, tage: neu }), { bccTeam: false })
if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung
return undefined
@@ -145,7 +145,7 @@ async function raeumeAuf(ctx) {
* @param {Date} now Zeitpunkt des Laufs (Fenster, Tage und Aufräumen zählen nach der Berliner Zeit von now)
* @returns {Promise<{ geprueft: number, mails: number, merker: number, fehler: number }>}
* geprueft = Funktionen mit gespeicherten Zeiten, die in diesem Lauf bewertet wurden (freigegebener Kunde, Zustand setup/active);
* mails = versendete K25; merker = in diesem Lauf neu vorgemerkte Tage; fehler = Dokumente und Schritte, die scheiterten, sowie
* mails = versendete K28; merker = in diesem Lauf neu vorgemerkte Tage; fehler = Dokumente und Schritte, die scheiterten, sowie
* gescheiterte Mails
*/
export async function erinnereFeiertage(now) {
+20 -20
View File
@@ -3,11 +3,11 @@ import { sendCustomerMail } from '../mailer.js'
import { datumKurz, wochentag } from '../termine/zeit.js'
/**
* Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K24 bis K27):
* K24 eingeschaltet (Gratisweg im Wartungs-Abo; mit Block `wieder` nach der Rückkehr des Abos)
* K25 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
* K26 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
* K27 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
* Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K27 bis K30):
* K27 eingeschaltet (Gratisweg im Wartungs-Abo; mit Block `wieder` nach der Rückkehr des Abos)
* K28 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
* K29 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
* K30 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
* siteFeatures.bestellMail: die Werte decken genau die Platzhalter der Vorlage ab. sendeZeitenMail verschickt sie.
*
@@ -35,7 +35,7 @@ function datum(wert, feld) {
}
/**
* K24: Öffnungszeiten live ist eingeschaltet.
* K27: Öffnungszeiten live ist eingeschaltet.
* @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.
@@ -44,13 +44,13 @@ function datum(wert, feld) {
* 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 }) {
export function k27Mail({ 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',
template: 'K27',
values: {
kunde_name: kundenName(kunde),
projekt,
@@ -73,14 +73,14 @@ function tagesZeile(eintrag) {
}
/**
* K25: Feiertage stehen an (eine Mail für alle neuen Tage; Aufrufer: Erinnerungs-Job, ohne BCC ans Team).
* K28: Feiertage stehen an (eine Mail für alle neuen Tage; Aufrufer: Erinnerungs-Job, ohne BCC ans Team).
* @param {{ kunde: string | { name?: string }, projekt: string, tage: Array<{ tag: string, name?: string, art?: 'feiertag' | 'pruefen' }> }} p
* tage: [{ tag: 'YYYY-MM-DD', name, art }] wie zeitlogik.erinnerungsTage(); Blöcke: pruefen (mindestens ein „bitte prüfen“-Tag), mehrere (> 1 Tag).
*/
export function k25Mail({ kunde, projekt, tage }) {
export function k28Mail({ kunde, projekt, tage }) {
const liste = Array.isArray(tage) ? tage : []
return {
template: 'K25',
template: 'K28',
values: {
kunde_name: kundenName(kunde),
projekt,
@@ -92,27 +92,27 @@ export function k25Mail({ kunde, projekt, tage }) {
}
/**
* K26: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
* K29: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
* @param {{ kunde: string | { name?: string }, projekt: string, art?: string, frist: Date, preis: string }} p
* art: Abo-Lage aus aboStatus.aboLage(): 'hosting' (das Abo ist ein Hosting-Abo), 'wechsel_widerrufen' (Wechsel ins Wartungs-Abo
* widerrufen), sonst (auch 'beendet' und Unbekanntes) 'abo_ende'. frist: Tag des Abschaltens (aboEndeAt + 30 Tage).
*/
export function k26Mail({ kunde, projekt, art, frist, preis }) {
export function k29Mail({ kunde, projekt, art, frist, preis }) {
return {
template: 'K26',
template: 'K29',
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() },
blocks: { abo_ende: art !== 'hosting' && art !== 'wechsel_widerrufen', hosting: art === 'hosting', wechsel_widerrufen: art === 'wechsel_widerrufen' },
}
}
/**
* K27: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
* K30: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
* @param {{ kunde: string | { name?: string }, projekt: string, loeschdatum: Date, preis: string }} p
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden (30 Tage nach dem Abschalten); preis: Preis zum Wiedereinschalten.
*/
export function k27Mail({ kunde, projekt, loeschdatum, preis }) {
export function k30Mail({ kunde, projekt, loeschdatum, preis }) {
return {
template: 'K27',
template: 'K30',
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() },
}
}
@@ -129,10 +129,10 @@ function fehlerText(err, customer) {
}
/**
* Eine der Mails K24 bis K27 an die E-Mail-Adresse des Kundenkontos senden. Wirft nie; das Log nennt weder Adresse noch Namen.
* Eine der Mails K27 bis K30 an die E-Mail-Adresse des Kundenkontos senden. Wirft nie; das Log nennt weder Adresse noch Namen.
* @param {{ email?: string, name?: string } | null | undefined} customer Kundendokument
* @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k24Mail ... k27Mail
* @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K25)
* @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k27Mail ... k30Mail
* @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K28)
* @returns {Promise<{ sent: boolean, reason?: 'keine_adresse' | 'fehler' }>}
*/
export async function sendeZeitenMail(customer, nachricht, { bccTeam = true } = {}) {
+2 -2
View File
@@ -53,14 +53,14 @@ export const ZEITEN_COLLECTIONS = {
* - aboEndeAt: Zeitpunkt, an dem der Abgleich das Ende des Wartungs-Abos erkannt hat
* - einbau: 'angestossen' | 'manuell' (Ergebnis des Neubau-Anstosses)
* - behaltenSessionId: offene Behalten-Zahlung (Stripe-Sitzung)
* - k24At: Marker der Mail K24 (Selbstheilung)
* - einschaltMailAt: Marker der Mail K27 (Selbstheilung)
*/
export const ZEITEN_FEATURE_ATTRIBUTES = [
str('unlockedBy', 8),
dt('aboEndeAt'),
str('einbau', 12),
str('behaltenSessionId', 128),
dt('k24At'),
dt('einschaltMailAt'),
]
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */