Ö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:
1 parent
ea4457e6a4
commit
c7cd436ec2
34 files changed
+359
-354
No files matched your search
@@ -58,7 +58,7 @@ Jeder Platzhalter behält eine **statische Ersatz-Tabelle**: Sie steht ohne Java
|
||||
1. WOMS → Funktionen → Ersatz-HTML kopieren (je Plan ein Knopf; bei mehreren Plänen mit Namen dahinter). Erst nach dem ersten Speichern des Kunden; vorher meldet das WOMS „Der Kunde hat noch keine Zeiten eingetragen“ (die Schnittstelle antwortet 409: `GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=…&plan=<kurz>`).
|
||||
2. In jeden Platzhalter einsetzen. Das Ersatz-HTML ist eine schlichte Wochentabelle als reines ASCII mit Entities (`ä`, `–`), also in jeder Dateikodierung sicher. Ein Fensterende um Mitternacht steht als `00:00` („18:00–00:00“), ein Fenster von 00:00 bis 24:00 als „rund um die Uhr“.
|
||||
3. Die Tabelle zeigt den Wochenplan **zum Zeitpunkt des Einbaus**. Ändert der Kunde seinen Wochenplan später, bleibt das Ersatz-HTML zunächst stehen (mit Skript unsichtbar).
|
||||
4. **Beim Abschalten** der Funktion, wenn der aktuelle Wochenplan des Hauptplans vom Stand beim Einbau abweicht, entsteht ein WOMS-Ticket **„Ersatz-Tabelle aktualisieren – <Projekt>“** mit dem aktuellen Ersatz-HTML. Ersetze die Tabelle in allen Platzhaltern (gehört zum enthaltenen Einbau, Leistungsbeschreibung Nr. 4 Abs. 3). Die Mail K27 sagt dem Kunden, dass wieder feste Zeiten auf der Seite stehen. Liegt die Seite woanders, schicke dem Kunden den Text.
|
||||
4. **Beim Abschalten** der Funktion, wenn der aktuelle Wochenplan des Hauptplans vom Stand beim Einbau abweicht, entsteht ein WOMS-Ticket **„Ersatz-Tabelle aktualisieren – <Projekt>“** mit dem aktuellen Ersatz-HTML. Ersetze die Tabelle in allen Platzhaltern (gehört zum enthaltenen Einbau, Leistungsbeschreibung Nr. 4 Abs. 3). Die Mail K30 sagt dem Kunden, dass wieder feste Zeiten auf der Seite stehen. Liegt die Seite woanders, schicke dem Kunden den Text.
|
||||
|
||||
## 5. Header-Selektor (`navSelektor`)
|
||||
|
||||
@@ -108,7 +108,7 @@ Zur Abnahme außerdem: Seite in der Vorschau öffnen und mit ausgeschaltetem Jav
|
||||
## 10. Freischalten und Ende
|
||||
|
||||
- **Freischalten:** Der Job setzt `active` (und sendet K16), wenn das Ticket geschlossen ist und der Kunde Zeiten eingetragen hat (Hauptplan gespeichert, mindestens ein Tag nicht „zu“). Sonst bleibt `setup`, das WOMS zeigt „wartet auf die Zeiten des Kunden“. Solange der Kunde nichts eingetragen hat, kannst Du die Tabelle nicht einbauen.
|
||||
- **Ende des Abos** (Gratisweg): K26 mit Frist zum Behalten (30 Tage), danach `disabled` und K27, Eingaben noch 30 Tage, dann gelöscht. Das Ticket „Ersatz-Tabelle aktualisieren“ gehört zum Abschalten (Abschnitt 4).
|
||||
- **Ende des Abos** (Gratisweg): K29 mit Frist zum Behalten (30 Tage), danach `disabled` und K30, Eingaben noch 30 Tage, dann gelöscht. Das Ticket „Ersatz-Tabelle aktualisieren“ gehört zum Abschalten (Abschnitt 4).
|
||||
- **Abschalten durch das Team** (Grund `team`): keine automatische Löschung.
|
||||
|
||||
## 11. Häufige Fehlerbilder
|
||||
|
||||
@@ -26,18 +26,18 @@ Warum so:
|
||||
|
||||
- Der Satz ist bedingt („Haben Sie … eingeschaltet“), weil der Block bei jeder Kündigung eines Wartungs-Abos erscheint, auch bei Kunden ohne die Funktion.
|
||||
- Wer die Funktion für 30 € bestellt hat, behält sie ohne Abo (Leistungsbeschreibung Nr. 9 Abs. 2 letzter Satz); für ihn gilt der Satz nicht, was die Bedingung „im Wartungs-Abo eingeschaltet“ ausdrückt. Ein eigener Satz dazu wäre in einer Kündigungsbestätigung verwirrend.
|
||||
- Die Einzelheiten (Frist mit Datum, Knopf „Behalten“) sagt K26 („Wartungs-Abo beendet“), sobald das Ende des Abos erkannt ist; K02 und K03 sagen nur, dass es das gibt.
|
||||
- Die Einzelheiten (Frist mit Datum, Knopf „Behalten“) sagt K29 („Wartungs-Abo beendet“), sobald das Ende des Abos erkannt ist; K02 und K03 sagen nur, dass es das gibt.
|
||||
- „Abschalten“ bedeutet: Hinweis-Leiste, Kontakt-Leiste, Status und Google-Angaben verschwinden, die feste Ersatz-Tabelle der Website bleibt stehen (Leistungsbeschreibung Nr. 9 Abs. 4).
|
||||
|
||||
Im HTML ändert sich nur der Inhalt der Zelle zwischen `<!-- WENN funktionen -->` und `<!-- ENDE funktionen -->`; Aufbau und Blockkommentare bleiben. Die Kopfkommentare beider Vorlagen sagen dann „Website-Analyse, Terminplaner und Öffnungszeiten live“.
|
||||
|
||||
## Alternative
|
||||
|
||||
Ein eigener Block `zeiten` (nur für Kunden, bei denen die Funktion im Abo läuft): kein Text für Kunden ohne Funktion und die Zeile steht ohne „Haben Sie …“. Dafür braucht das Programm in `server/routes/abo.js` an beiden Stellen eine zusätzliche Abfrage der Funktion, beide Vorlagen einen neuen Block, und die Pins im Mail-Pin-Test (K01 bis K27) ändern sich. Nur sinnvoll, wenn der Inhaber den bedingten Satz nicht möchte.
|
||||
Ein eigener Block `zeiten` (nur für Kunden, bei denen die Funktion im Abo läuft): kein Text für Kunden ohne Funktion und die Zeile steht ohne „Haben Sie …“. Dafür braucht das Programm in `server/routes/abo.js` an beiden Stellen eine zusätzliche Abfrage der Funktion, beide Vorlagen einen neuen Block, und die Pins im Mail-Pin-Test (K01 bis K30) ändern sich. Nur sinnvoll, wenn der Inhaber den bedingten Satz nicht möchte.
|
||||
|
||||
## Beim Übernehmen
|
||||
|
||||
1. Vorlagen K02 und K03 ändern (Zelle des Blocks, Kopfkommentar).
|
||||
2. `test/mailTemplates.test.js` (K02/K03-Test): zusätzlicher Wortlaut-Pin für den neuen Satz; die bestehenden Prüfungen bleiben.
|
||||
3. Den Mail-Pin-Test (K01 bis K27, ebenfalls `test/mailTemplates.test.js`) und, falls sich der Block ändert, die Prüfung in `test/publicForms.test.js` nachziehen.
|
||||
3. Den Mail-Pin-Test (K01 bis K30, ebenfalls `test/mailTemplates.test.js`) und, falls sich der Block ändert, die Prüfung in `test/publicForms.test.js` nachziehen.
|
||||
4. Kanzlei-Frage `ZQ5` (Ende mit dem Abo, 30 Tage zum Behalten) in `zeiten-kanzlei-fragen.md` abwarten, weil sie den Inhalt des Satzes betrifft.
|
||||
@@ -60,13 +60,13 @@ Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block
|
||||
|
||||
### ZQ6: Mails zur Funktion: Erinnerung vor Feiertagen und Zustandsmails
|
||||
|
||||
**Frage.** WEBklar sendet dem Kunden E-Mails: K24 (eingeschaltet), K25 (Erinnerung 1 bis 7 Tage vor einem Feiertag oder einem Prüftag: „Stimmen Ihre Zeiten an diesen Tagen?“, mit einem Knopf zum Editor und dem Hinweis, die Zeiten auch bei Google einzutragen), K26 (Wartungs-Abo beendet, Frist, „für einmalig 30 € behalten“), K27 (abgeschaltet). Sind das Vertragsmails (Art. 6 Abs. 1 lit. b DSGVO) ohne Einwilligung, oder wirkt K25 wegen des Hinweises „bei Google auch eintragen“ oder des Verweises auf die Funktion als Werbung (§ 7 UWG, Art. 6 Abs. 1 lit. f DSGVO mit Widerspruchsmöglichkeit)? Muss K25 einen Hinweis zum Abbestellen tragen? Die Erinnerung lässt sich im Programm nicht abschalten; der Kunde kann nur die Funktion abschalten lassen.
|
||||
**Frage.** WEBklar sendet dem Kunden E-Mails: K27 (eingeschaltet), K28 (Erinnerung 1 bis 7 Tage vor einem Feiertag oder einem Prüftag: „Stimmen Ihre Zeiten an diesen Tagen?“, mit einem Knopf zum Editor und dem Hinweis, die Zeiten auch bei Google einzutragen), K29 (Wartungs-Abo beendet, Frist, „für einmalig 30 € behalten“), K30 (abgeschaltet). Sind das Vertragsmails (Art. 6 Abs. 1 lit. b DSGVO) ohne Einwilligung, oder wirkt K28 wegen des Hinweises „bei Google auch eintragen“ oder des Verweises auf die Funktion als Werbung (§ 7 UWG, Art. 6 Abs. 1 lit. f DSGVO mit Widerspruchsmöglichkeit)? Muss K28 einen Hinweis zum Abbestellen tragen? Die Erinnerung lässt sich im Programm nicht abschalten; der Kunde kann nur die Funktion abschalten lassen.
|
||||
|
||||
**Fundstelle im Text.** Abschnitt 6d Absatz 3; LB Nr. 2 Abs. 2; Mailvorlagen K24 bis K27 (`server/mail-templates/`), Spec Abschnitt 7 (K25: eine Mail für alle neuen Tage, Merker, 08:00 bis 20:00 Uhr Berliner Zeit, kein BCC ans Team).
|
||||
**Fundstelle im Text.** Abschnitt 6d Absatz 3; LB Nr. 2 Abs. 2; Mailvorlagen K27 bis K30 (`server/mail-templates/`), Spec Abschnitt 7 (K28: eine Mail für alle neuen Tage, Merker, 08:00 bis 20:00 Uhr Berliner Zeit, kein BCC ans Team).
|
||||
|
||||
**Unsere Annahme.** Die Erinnerung ist Teil der vertraglichen Leistung (LB Nr. 2 Abs. 2) und deshalb eine Vertragsmail ohne Einwilligung; der Hinweis auf Google dient der Durchführung (die Zeiten dort aktuell zu halten), nicht der Werbung.
|
||||
|
||||
**Alternative.** Hinweis auf Google aus K25 streichen; einen Abbestell-Link („Erinnerungen ausschalten“) mit Einstellung im Editor vorsehen.
|
||||
**Alternative.** Hinweis auf Google aus K28 streichen; einen Abbestell-Link („Erinnerungen ausschalten“) mit Einstellung im Editor vorsehen.
|
||||
|
||||
### ZQ7: Einstellung ohne Grund bei Unternehmern im Gratisweg (LB Nr. 10 Abs. 4)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Nach der Fixrunde (Abschlussprüfung, 05.10.2026), alles grün: Portal 4564/4564
|
||||
- UI, je einzeln mit `nice` und vorher `free -m`: `test/ui/zeiten-embed.ui.mjs`, `test/ui/zeiten-portal.ui.mjs`, `test/ui/funktionen.ui.mjs`
|
||||
3. **WOMS neu aufsetzen.** In `ticket.webklar.com/dev-zeiten`: die WOMS-Hauptlinie (`test`, mit TP2) hineinführen (`git fetch origin && git merge origin/test`). Erwarteter Konflikt (1 Datei): `src/components/customer/CustomerFunktionen.jsx`; dort bleiben unser `ersatzTicket` **und** TP2s `speicherStatus`/`K16_WARNUNG` (TP2 `4c3f67e` ändert dieselben `save()`-Zeilen sowie das Ende von `src/lib/funktionenView.js` und seinen Test, das Portal fügt dieselbe POST-Route um `warnung` ein). `K16_WARNUNG` nennt einen „AVV“, den Öffnungszeiten live nicht hat: nach der Zusammenführung prüfen und für die Zeiten anpassen. Danach `nice npm test` und `nice npx vite build`. `node_modules` ist dort im Git verfolgt und wurde von `npm ci` verändert: **nie stagen**.
|
||||
4. **Relaunch prüfen.** `git log --oneline 4139f31..main` im Relaunch-Repo; gibt es neue Commits, `feature/zeiten-station` darauf aufsetzen, dann `nice npm test`, `nice npm run build` und `gzip -9 -c dist/assets/index-*.js | wc -c` (höchstens 133 120).
|
||||
5. **Schema anlegen, vor dem Portal-Deploy.** `scripts/zeiten-setup.mjs` legt `oeffnungszeiten` und `zeitenErinnerungen` an und ergänzt `portalFeatures` um `unlockedBy`, `aboEndeAt`, `einbau`, `behaltenSessionId`, `k24At`. Seit der Fixrunde schreibt `activate()` `aboEndeAt` und `behaltenSessionId` nur noch für Öffnungszeiten live (Parameter `extra`); News und Terminplaner brauchen die Felder nicht mehr und laufen auch vor dem Skript. Trotzdem erst das Schema, dann der Deploy: die Zeiten-Pfade schreiben die Felder. Das Skript braucht den neuen Code, also aus dem Worktree in einem Wegwerf-Container (kein `docker compose run`: der Container bekäme die Traefik-Labels des Portals). Die Env-Datei enthält Zugangsdaten: mit `umask 077` anlegen, nicht erst danach `chmod`. Erst `--check` (beim ersten Lauf Exit 1, erwartet: es fehlt ja noch alles), dann ohne, dann **noch einmal `--check`** (muss grün sein; die Index-Reihenfolge der Schema-Skripte hat am 30.09. schon einmal überrascht):
|
||||
5. **Schema anlegen, vor dem Portal-Deploy.** `scripts/zeiten-setup.mjs` legt `oeffnungszeiten` und `zeitenErinnerungen` an und ergänzt `portalFeatures` um `unlockedBy`, `aboEndeAt`, `einbau`, `behaltenSessionId`, `einschaltMailAt`. Seit der Fixrunde schreibt `activate()` `aboEndeAt` und `behaltenSessionId` nur noch für Öffnungszeiten live (Parameter `extra`); News und Terminplaner brauchen die Felder nicht mehr und laufen auch vor dem Skript. Trotzdem erst das Schema, dann der Deploy: die Zeiten-Pfade schreiben die Felder. Das Skript braucht den neuen Code, also aus dem Worktree in einem Wegwerf-Container (kein `docker compose run`: der Container bekäme die Traefik-Labels des Portals). Die Env-Datei enthält Zugangsdaten: mit `umask 077` anlegen, nicht erst danach `chmod`. Erst `--check` (beim ersten Lauf Exit 1, erwartet: es fehlt ja noch alles), dann ohne, dann **noch einmal `--check`** (muss grün sein; die Index-Reihenfolge der Schema-Skripte hat am 30.09. schon einmal überrascht):
|
||||
```
|
||||
(umask 077; docker inspect webklar-kundenbereich-server -f '{{range .Config.Env}}{{println .}}{{end}}' > /root/zeiten-env.tmp)
|
||||
docker run --rm --env-file /root/zeiten-env.tmp --network webklar-network -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20-alpine node scripts/zeiten-setup.mjs --check
|
||||
@@ -58,11 +58,11 @@ Nach der Fixrunde (Abschlussprüfung, 05.10.2026), alles grün: Portal 4564/4564
|
||||
Nur dein Konto sieht die Kachel. Beachte: Das Portal prüft nur „Leistungsbeschreibung vorhanden“, nicht „freigegeben“ (`unterlagenBereit` ist für Zeiten immer wahr). Der Pilot ist deshalb der einzige Schutz vor dem Entwurfstext, bis `on` kommt.
|
||||
|
||||
1. Kachel im Funktionen-Tab sichtbar. Je nach Konto: „Einschalten“ (Wartungs-Abo aktiv) oder „30 € einmalig“. Den Bezahlweg nur mit echter 30-€-Zahlung testen (danach erstatten) oder weglassen; die Tests decken ihn ab.
|
||||
2. Einschalten: Ticket „Öffnungszeiten live einbauen“ in WOMS, Mail K24, `einbau` = `angestossen` (Runner-Log) oder `manuell`.
|
||||
2. Einschalten: Ticket „Öffnungszeiten live einbauen“ in WOMS, Mail K27, `einbau` = `angestossen` (Runner-Log) oder `manuell`.
|
||||
3. Editor: Wochenplan mit Fenstern und Mitternacht, zweiter Plan, Bundesland und Feiertage, Urlaub (Hinweis-Vorschlag), Hinweis-Leiste, Kontakt-Leiste mit Telefon und Adresse, „Für Google kopieren“, Uhrzeit-Regler.
|
||||
4. Preview der `test-website` nach dem Neubau: im Quelltext `<script data-wk-zeiten … data-site="…">`; Hinweis-Leiste oben (schiebt den Header?), Kontakt-Leiste auf dem Handy, JSON-LD im Quelltext (kein Duplikat), Platzhalter mit Status, Tabelle und Badge; Ersatz-HTML aus WOMS („Ersatz-HTML kopieren“) in die Platzhalter. Dazu der Datenschutz-Absatz aus `docs/funktionen/website-datenschutz-absatz.md` (Entwurf) auf der Test-Seite.
|
||||
5. WOMS: Panel (Zeiten-Einstellungen `navSelektor`, Leisten aus), „Aktivieren ohne Zahlung“ und „Abschalten“ am Testprojekt; Ticket schließen ⇒ nach spätestens 5 Minuten `active` und Mail K16; Abschalten ⇒ Ticket „Ersatz-Tabelle aktualisieren“ (nur wenn der Wochenplan vom Ersatz-HTML abweicht).
|
||||
6. Mails K24 bis K27 ansehen (K25 über einen Feiertag in 1–7 Tagen, K26/K27 nur mit einem Abo-Ende; sonst über die Tests bewertet).
|
||||
6. Mails K27 bis K30 ansehen (K28 über einen Feiertag in 1–7 Tagen, K29/K30 nur mit einem Abo-Ende; sonst über die Tests bewertet).
|
||||
7. Aufräumen: Funktion am Testprojekt abschalten, Ticket schließen. Das Abschalten durch das Team löscht **nie** Daten; die Testdaten löschst du von Hand in der Appwrite-Konsole (Datenbank des Portals): im Dokument der Funktion in `portalFeatures` (`featureKey` = `zeiten`, `projectId` = das Testprojekt) die ID notieren; in der Collection `oeffnungszeiten` das Dokument mit genau dieser ID löschen (es trägt die Eingaben, das Team-Feld und den Ersatz-Hash); in `zeitenErinnerungen` alle Dokumente mit `featureId` = dieser ID löschen (die Erinnerungs-Merker). Danach ist die Schnittstelle des Testprojekts nach spätestens 60 Sekunden (Zwischenspeicher) wieder leer. (Automatisch gelöscht wird nur beim Ende des Abos nach 30 Tagen und beim Löschen des Kundenkontos.)
|
||||
|
||||
## C. Schalter `on` (letzter Schritt)
|
||||
@@ -88,7 +88,7 @@ Reihenfolge laut Spec Abschnitt 9: gemeinsame TP2-Bausteine, Leistungsbeschreibu
|
||||
8. **K02/K03:** Entwurf für den Block `funktionen` in `docs/rechtliches/zeiten-k02-k03-entwurf.md`. Nach der Freigabe die Vorlagen ändern und die Pins nachziehen (`test/mailTemplates.test.js`, `test/publicForms.test.js` `FUNKTIONEN_SATZ`).
|
||||
9. **Website-Absatz** `docs/funktionen/website-datenschutz-absatz.md` (Varianten A/B, Baustein Route und WhatsApp nur wenn die Leiste die Knöpfe zeigt): freigeben; der Einbau auf einer Kundenseite erst, wenn er in deren Datenschutzerklärung steht.
|
||||
10. **K15 für bezahlte Öffnungszeiten** trägt keine Vertragsunterlagen und keine Belehrung als Anhang (dauerhafter Datenträger): entscheiden, ob nötig (Frage ZQ12 zusammen mit dem Haken `information`).
|
||||
11. **Mailtexte K24 bis K27** und die Texte im Editor in deiner Textrunde: K25 sagt „deine Zeiten gelten normal“, obwohl die Seite an gesetzlichen Feiertagen „geschlossen“ zeigt, wenn du nichts änderst; K24-Variante „wieder eingeschaltet“ (ohne „per Ticket“ / „von Hand“); T19-Text spricht von „Einrichtung“; `disabledMessage` schreibt „du“ klein; eigene Texte des Editors (Preishinweis, Vorschau); Betreffzeile K26 nennt „Wartungs-Abo“ (so gewollt).
|
||||
11. **Mailtexte K27 bis K30** und die Texte im Editor in deiner Textrunde: K28 sagt „deine Zeiten gelten normal“, obwohl die Seite an gesetzlichen Feiertagen „geschlossen“ zeigt, wenn du nichts änderst; K27-Variante „wieder eingeschaltet“ (ohne „per Ticket“ / „von Hand“); T19-Text spricht von „Einrichtung“; `disabledMessage` schreibt „du“ klein; eigene Texte des Editors (Preishinweis, Vorschau); Betreffzeile K29 nennt „Wartungs-Abo“ (so gewollt).
|
||||
12. **Uploads:** die Fassungen (Leistungsbeschreibung, Datenschutzerklärung) in WOMS „Rechtliches“ hochladen (Karte `leistungsbeschreibung-zeiten`).
|
||||
|
||||
## E. Offene Code-Punkte (Checkliste für die letzte Fixrunde)
|
||||
@@ -105,7 +105,7 @@ Mit ☐ markiert, was noch im Code offen ist; die Fixrunde des Controllers kann
|
||||
**Härtung (vor `on` empfohlen, für den Pilot nicht nötig)**
|
||||
|
||||
- [x] N5 Website-Skript: im JSON-LD `<` als `\u003c` schreiben (`jsonLdText`, Test); Kopf-Elemente bekommen bei Scroll ≥ Leistenhöhe ihren eigenen Inline-`top` zurück (UI-Prüfung `fester_header_versteckt_beim_scrollen`); dazu späte Platzhalter (Einzelseiten-Apps) über einen entprellten MutationObserver (Fixrunde `aa9ee35`).
|
||||
- [x] N6 Abo-Abgleich: K27 gilt als offen, solange `aboEndeMailAt` vor `disabledAt` liegt (das Zurücksetzen nach `disable()` entfällt); K27 geht erst nach dem Ersatz-Ticket raus, scheitert es, wiederholt der nächste Lauf beides (K27 so höchstens einmal); `activatedAt` wird bei der Löschung geleert (Fixrunde `ecf67d6`).
|
||||
- [x] N6 Abo-Abgleich: K30 gilt als offen, solange `aboEndeMailAt` vor `disabledAt` liegt (das Zurücksetzen nach `disable()` entfällt); K30 geht erst nach dem Ersatz-Ticket raus, scheitert es, wiederholt der nächste Lauf beides (K30 so höchstens einmal); `activatedAt` wird bei der Löschung geleert (Fixrunde `ecf67d6`).
|
||||
- [ ] N7 Team: Sperre `zeiten-ersatz:<docId>` gegen zwei Ersatz-Tickets (paralleles Abschalten und Abgleich); `PATCH …/zeiten/team` nur für `setup`/`active`/`disabled` (409 sonst); `createFeatureTicket` mit `fallbackDetails` für das lange Ersatz-Ticket.
|
||||
- [x] N8 WOMS „Ticket nachlegen“ bei Abo- oder Team-Zeiten ohne `ticketId`: das Portal geht über `holeZeitenNach` (Fixrunde `ecf67d6`). Offen bleiben die Texte „keine Zahlung vermerkt“ (bei gekauften Zeiten ohne Zahlungsvermerk) und der 409 der Abnahme („noch keine Öffnungszeiten eingetragen“).
|
||||
- [ ] N9 Löschen: in `loescheFunktion` erst die Merker, dann das Dokument; fehlgeschlagene Löschung in `retention.js` beim nächsten Lauf wiederholen.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Öffnungszeiten live – Implementation Plan
|
||||
|
||||
> **Hinweis 2026-10-06:** Die Mails dieser Funktion heißen inzwischen K27 (eingeschaltet), K28 (Feiertage prüfen), K29 (Abo beendet) und K30 (abgeschaltet), weil K24–K26 seit dem Webshop-Livegang vergeben sind; das Feld `k24At` heißt `einschaltMailAt`. Dieses Dokument behält die alten Nummern.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Die Funktion „Öffnungszeiten live“ im Kundenportal: Zeitpläne im Portal pflegen, Live-Status/Tabelle/Hinweis-Leiste/Kontakt-Leiste/JSON-LD per Skript auf der Kundenseite, Bestellung (30 €) oder Einschalten im Wartungs-Abo, Abo-Kopplung, Feiertags-Erinnerung, WOMS-Anbindung und eine Station auf der Relaunch-Seite.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Öffnungszeiten live (Funktion im Kundenportal) – Design
|
||||
|
||||
> **Hinweis 2026-10-06:** Die Mails dieser Funktion heißen inzwischen K27 (eingeschaltet), K28 (Feiertage prüfen), K29 (Abo beendet) und K30 (abgeschaltet), weil K24–K26 seit dem Webshop-Livegang vergeben sind; das Feld `k24At` heißt `einschaltMailAt`. Dieses Dokument behält die alten Nummern.
|
||||
|
||||
Stand: 2026-10-04 · Status: vom Inhaber im Chat freigegeben (Abschnitte 1–5), Spec zur Durchsicht
|
||||
Branch `feature/oeffnungszeiten` (Worktree `dev-zeiten`), aufgesetzt auf `feature/terminplaner-tp2` (6ae0f67).
|
||||
WOMS: eigener Worktree auf `feature/terminplaner-tp2` des WOMS-Repos (6f03712), Branch `feature/oeffnungszeiten`.
|
||||
|
||||
@@ -258,7 +258,7 @@ function pickZeitenProject(projects) {
|
||||
|| projects[0]
|
||||
}
|
||||
|
||||
// Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail K26);
|
||||
// Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail K29);
|
||||
// ein unlesbares Datum gibt '' (dann steht kein Hinweis da, nie ein erfundenes Datum). Doppelt zu zeiten.js (funktionen.js importiert dort nichts).
|
||||
function zeitenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
|
||||
@@ -82,7 +82,7 @@ function datumTag(wert) {
|
||||
|
||||
/**
|
||||
* Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail
|
||||
* K26) als TT.MM.JJJJ; ein unlesbares Datum gibt '' (kein erfundenes Datum). Doppelt zu zeitenFrist in funktionen.js (die Kachel importiert nichts).
|
||||
* K29) als TT.MM.JJJJ; ein unlesbares Datum gibt '' (kein erfundenes Datum). Doppelt zu zeitenFrist in funktionen.js (die Kachel importiert nichts).
|
||||
*/
|
||||
export function behaltenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Oeffnungszeiten live: legt die Collections oeffnungszeiten und zeitenErinnerungen mit Attributen und Indizes in
|
||||
* Appwrite an (nur Server-Key, keine Client-Rechte) und ergaenzt portalFeatures um die Felder unlockedBy,
|
||||
* aboEndeAt, einbau, behaltenSessionId und k24At (Abo-Freischaltung, Behalten, Selbstheilung). Das Schema steht in
|
||||
* aboEndeAt, einbau, behaltenSessionId und einschaltMailAt (Abo-Freischaltung, Behalten, Selbstheilung). Das Schema steht in
|
||||
* server/services/zeiten/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes
|
||||
* auch in schon vorhandenen Collections nachgezogen. portalFeatures selbst legt setup-abo-collections.mjs an
|
||||
* (fehlt sie, bricht das Skript vor jeder Aenderung ab). Vor dem Einschalten (ZEITEN_ENABLED) und vor dem
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K24 · Zeiten eingeschaltet
|
||||
K27 · Zeiten eingeschaltet
|
||||
Betreff: Öffnungszeiten live ist eingeschaltet
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K25 · Zeiten Feiertage prüfen
|
||||
K28 · Zeiten Feiertage prüfen
|
||||
Betreff: Feiertage stehen an – stimmen Deine Zeiten?
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · ohne BCC
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K26 · Zeiten Abo beendet
|
||||
K29 · Zeiten Abo beendet
|
||||
Betreff: Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K27 · Zeiten abgeschaltet
|
||||
K30 · Zeiten abgeschaltet
|
||||
Betreff: Öffnungszeiten live ist abgeschaltet
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
@@ -204,7 +204,7 @@ funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) =>
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
|
||||
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
|
||||
* und ohne Zustimmungsnachweis und K27: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
|
||||
* entsteht wie bei den anderen Funktionen (Zeit, Name, Aktion, Text).
|
||||
* Sperren: einschaltenZeiten nimmt fuer die Zustandsentscheidung selbst `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout)
|
||||
* und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` nicht
|
||||
|
||||
@@ -381,7 +381,7 @@ const EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschalte
|
||||
// Gratisweg: Oeffnungszeiten live im Wartungs-Abo einschalten (Spec 3.2). Vorpruefungen wie im Checkout, jede Stufe ohne Seiteneffekt:
|
||||
// 1 Kundenansicht, 2 Zustimmung (Gratis-Fassung: ein Haken, Knopf "Einschalten"), 3 Projekt (eigen, nicht archiviert),
|
||||
// 4 Schalter/Pilot, 5 Unterlagen. Das Abo wird erst unter der Sperre frisch geprueft (einschaltenZeiten), dort entstehen auch Nachweis,
|
||||
// Zustand, Neubau, Ticket und K24; ein zweiter Klick holt Fehlendes nach.
|
||||
// Zustand, Neubau, Ticket und K27; ein zweiter Klick holt Fehlendes nach.
|
||||
router.post('/zeiten/einschalten', requireSession, async (req, res) => {
|
||||
const now = new Date()
|
||||
try {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -6,9 +6,10 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K24–K27 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 8), ...ids('K', 27), ...ids('T', 19), 'T20']
|
||||
// K24–K26 gehoeren dem Webshop (auf main); dieser Zweig traegt bis zur Zusammenfuehrung nur die Oeffnungszeiten-Mails K27–K30
|
||||
const EXPECTED = [...ids('G', 8), ...ids('K', 23), 'K27', 'K28', 'K29', 'K30', ...ids('T', 19), 'T20']
|
||||
const GUEST = ids('G', 8)
|
||||
|
||||
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||||
@@ -527,8 +528,8 @@ test('T02 und T07 erinnern an UptimeRobot', () => {
|
||||
assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text)
|
||||
})
|
||||
|
||||
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14, T12 und K26 (Abo beendet, Öffnungszeiten live)', () => {
|
||||
for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K26'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
|
||||
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14, T12 und K29 (Abo beendet, Öffnungszeiten live)', () => {
|
||||
for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K29'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
|
||||
})
|
||||
|
||||
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
|
||||
@@ -968,9 +969,9 @@ test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und na
|
||||
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
|
||||
})
|
||||
|
||||
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest); Öffnungszeiten live: K24–K27 ebenso
|
||||
test('K22, K23, K24–K27: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
|
||||
for (const id of ['K22', 'K23', 'K24', 'K25', 'K26', 'K27']) {
|
||||
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest); Öffnungszeiten live: K27–K30 ebenso
|
||||
test('K22, K23, K27–K30: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
|
||||
for (const id of ['K22', 'K23', 'K27', 'K28', 'K29', 'K30']) {
|
||||
const tpl = loadTemplate(id)
|
||||
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`)
|
||||
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
|
||||
|
||||
@@ -7,13 +7,13 @@ import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// Abo-Abgleich von Öffnungszeiten live (Aufgabe 21, Spec 5 "Abo-Abgleich" Schritte 1 bis 4 und Z11): gleicheZeitenAboAb(now) koppelt die im
|
||||
// Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K26), nach 30 Tagen abschalten (K27, Ersatz-Ticket),
|
||||
// bei Rückkehr des Abos wieder einschalten (K24 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen.
|
||||
// Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K29), nach 30 Tagen abschalten (K30, Ersatz-Ticket),
|
||||
// bei Rückkehr des Abos wieder einschalten (K27 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen.
|
||||
// Aufbau wie termineAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit JETZT; Tage zählen nach Berliner Kalendertagen.
|
||||
// Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null)
|
||||
const DATETIMES = {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
}
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
@@ -122,7 +122,7 @@ const EIN_PLAN = () => ({
|
||||
})
|
||||
|
||||
/**
|
||||
* Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K24 gesendet,
|
||||
* Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K27 gesendet,
|
||||
* Zeiten gespeichert (daten, ersatzHash leer). Das Projekt "Praxis Muster" und das Einbau-Ticket legt die Hilfe mit an.
|
||||
*/
|
||||
async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxis Muster', zeiten = true } = {}) {
|
||||
@@ -140,7 +140,7 @@ async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxi
|
||||
siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`,
|
||||
ticketId: `t_${nr}`,
|
||||
einbau: 'angestossen',
|
||||
k24At: ZUVOR,
|
||||
einschaltMailAt: ZUVOR,
|
||||
activatedAt: ZUVOR,
|
||||
...over,
|
||||
})
|
||||
@@ -167,9 +167,9 @@ async function laufe(now = JETZT) {
|
||||
const ERGEBNIS_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
|
||||
const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over })
|
||||
|
||||
const K26 = /^Dein Wartungs-Abo ist beendet/
|
||||
const K27 = 'Öffnungszeiten live ist abgeschaltet'
|
||||
const K24 = 'Öffnungszeiten live ist eingeschaltet'
|
||||
const K29 = /^Dein Wartungs-Abo ist beendet/
|
||||
const K30 = 'Öffnungszeiten live ist abgeschaltet'
|
||||
const K27 = 'Öffnungszeiten live ist eingeschaltet'
|
||||
const mails = () => sink.sent
|
||||
const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff))
|
||||
const tickets = () => [...aw.docs('workorders').values()]
|
||||
@@ -203,7 +203,7 @@ async function mitEinwurf(passt, dazwischen, fn) {
|
||||
}
|
||||
const liste = (collection) => (url, methode) => methode === 'GET' && new RegExp(`/collections/${collection}/documents(\\?|$)`).test(url)
|
||||
|
||||
/** Die volle Folge bis zum Abschalten: Tag 0 Abo-Ende erkannt (K26), Tag 30 abgeschaltet (K27). */
|
||||
/** Die volle Folge bis zum Abschalten: Tag 0 Abo-Ende erkannt (K29), Tag 30 abgeschaltet (K30). */
|
||||
async function bisAbgeschaltet() {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
@@ -212,9 +212,9 @@ async function bisAbgeschaltet() {
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K26
|
||||
// ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K29
|
||||
|
||||
test('Abo endet: aboEndeAt gesetzt, K26 einmal', async () => {
|
||||
test('Abo endet: aboEndeAt gesetzt, K29 einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
|
||||
@@ -223,13 +223,13 @@ test('Abo endet: aboEndeAt gesetzt, K26 einmal', async () => {
|
||||
assert.equal(doc.state, 'active', 'die Funktion läuft bis zur Frist weiter')
|
||||
assert.equal(doc.enabled, true)
|
||||
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung')
|
||||
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K26 erst nach dem Versand')
|
||||
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K29 erst nach dem Versand')
|
||||
assert.equal(doc.unlockedBy, 'abo')
|
||||
|
||||
assert.equal(mails().length, 1)
|
||||
const mail = mails()[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.subject, K26)
|
||||
assert.match(mail.subject, K29)
|
||||
assert.match(mail.text, /Silke Müller/)
|
||||
assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet')
|
||||
assert.match(mail.text, /Läuft weiter bis: 04\.11\.2026/, 'Frist = Tag der Erkennung + 30 Tage')
|
||||
@@ -255,7 +255,7 @@ test('Läuft das Abo, geschieht nichts: kein Schreiben, keine Mail', async () =>
|
||||
assert.equal(mails().length, 0)
|
||||
})
|
||||
|
||||
test('K26-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => {
|
||||
test('K29-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => {
|
||||
const varianten = [
|
||||
['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/],
|
||||
[
|
||||
@@ -274,14 +274,14 @@ test('K26-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async (
|
||||
await legeFunktion(1)
|
||||
abo()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name)
|
||||
assert.match(mails()[0].subject, K26, name)
|
||||
assert.match(mails()[0].subject, K29, name)
|
||||
assert.match(mails()[0].text, passt, name)
|
||||
assert.doesNotMatch(mails()[0].text, passtNicht, name)
|
||||
assert.equal(funktion().state, 'active', `${name}: die Frist läuft`)
|
||||
}
|
||||
})
|
||||
|
||||
test('K26 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => {
|
||||
test('K29 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => {
|
||||
await legeFunktion(1)
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })
|
||||
abgelaufen()
|
||||
@@ -291,7 +291,7 @@ test('K26 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('K26 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => {
|
||||
test('K29 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
const roh = sink.sendMail
|
||||
@@ -305,16 +305,16 @@ test('K26 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf
|
||||
|
||||
sink.sendMail = roh
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K26).length, 1)
|
||||
assert.equal(mailsMit(K29).length, 1)
|
||||
assert.equal(ms(funktion().aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt: die Frist wächst nicht')
|
||||
assert.match(mails()[0].text, /Läuft weiter bis: 04\.11\.2026/, 'dieselbe Frist wie beim ersten Versuch')
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K27, Ersatz-Ticket
|
||||
// ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K30, Ersatz-Ticket
|
||||
|
||||
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K27, Ersatz-Ticket', async () => {
|
||||
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K30, Ersatz-Ticket', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -334,12 +334,12 @@ test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K27, Ersatz-Ticket', async
|
||||
assert.equal(doc.disabledReason, 'abo_ende')
|
||||
assert.equal(ms(doc.disabledAt), tag30.getTime())
|
||||
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen')
|
||||
assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K27')
|
||||
assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K30')
|
||||
assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/)
|
||||
|
||||
// K27: Datum der Löschung = Abschalten + 30 Tage
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
const mail = mailsMit(K27)[0]
|
||||
// K30: Datum der Löschung = Abschalten + 30 Tage
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
const mail = mailsMit(K30)[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.text, /Eingaben gespeichert bis: 04\.12\.2026/)
|
||||
assert.match(mail.text, /30 €/)
|
||||
@@ -370,7 +370,7 @@ test('Frist nach Berliner Kalendertagen, nicht nach 24-Stunden-Schritten (Ende d
|
||||
// 23.11., 00:01 Berliner Zeit: der Tag der Frist ist da, auch vor 23:30 (24 x 30 Stunden später wäre erst 22:30 am 23.11.)
|
||||
assert.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/)
|
||||
})
|
||||
|
||||
test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr', async () => {
|
||||
@@ -381,10 +381,10 @@ test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
})
|
||||
|
||||
test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => {
|
||||
test('K30 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -394,18 +394,18 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste
|
||||
}
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K26 gilt nicht für die K27')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K29 gilt nicht für die K30')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
|
||||
sink.sendMail = roh
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute')
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K27 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K27 genau einmal', async () => {
|
||||
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K30 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K30 genau einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -414,21 +414,21 @@ test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K27 und Marke
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(mailsMit(K27).length, 0, 'K27 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K27-Marker')
|
||||
assert.equal(mailsMit(K30).length, 0, 'K30 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K30-Marker')
|
||||
|
||||
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K27 einmal
|
||||
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K30 einmal
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K27')
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K30')
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert mehrere Läufe: kein K27, jeder Lauf zählt fehler; sobald es klappt, genau eine K27', async () => {
|
||||
test('Ersatz-Ticket scheitert mehrere Läufe: kein K30, jeder Lauf zählt fehler; sobald es klappt, genau eine K30', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -437,18 +437,18 @@ test('Ersatz-Ticket scheitert mehrere Läufe: kein K27, jeder Lauf zählt fehler
|
||||
await laufe(nach(30))
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abschalten gelingt, die Folge scheitert (Marker der K26 steht noch, älter als disabledAt): der nächste Lauf holt K27 und das Ersatz-Ticket nach, einmal', async () => {
|
||||
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K26
|
||||
test('Abschalten gelingt, die Folge scheitert (Marker der K29 steht noch, älter als disabledAt): der nächste Lauf holt K30 und das Ersatz-Ticket nach, einmal', async () => {
|
||||
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K29
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
@@ -456,7 +456,7 @@ test('Abschalten gelingt, die Folge scheitert (Marker der K26 steht noch, älter
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K27-Marker liegt nie davor, K27 geht nicht täglich raus', async () => {
|
||||
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K30-Marker liegt nie davor, K30 geht nicht täglich raus', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -470,12 +470,12 @@ test('Wanduhr nach dem Laufstart (disabledAt später als now): der K27-Marker li
|
||||
}
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos
|
||||
|
||||
test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', async () => {
|
||||
test('Rückkehr an Tag 45: zurück auf active, Neubau, K27 wieder', async () => {
|
||||
await bisAbgeschaltet()
|
||||
mails().length = 0
|
||||
laeuft()
|
||||
@@ -492,9 +492,9 @@ test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', async () =>
|
||||
|
||||
// Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre
|
||||
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }])
|
||||
// K24 mit dem Block wieder und dem Neubau-Text
|
||||
assert.equal(mailsMit(K24).length, 1)
|
||||
const mail = mailsMit(K24)[0]
|
||||
// K27 mit dem Block wieder und dem Neubau-Text
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
const mail = mailsMit(K27)[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/)
|
||||
assert.match(mail.text, /nach dem nächsten Veröffentlichen/)
|
||||
@@ -505,7 +505,7 @@ test('Rückkehr an Tag 45: zurück auf active, Neubau, K24 wieder', async () =>
|
||||
assert.equal(rebuilds.length, 1)
|
||||
})
|
||||
|
||||
test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedAt) kommt auf setup zurück; einbau manuell sagt K24 "das Team baut ein"', async () => {
|
||||
test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedAt) kommt auf setup zurück; einbau manuell sagt K27 "das Team baut ein"', async () => {
|
||||
await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null })
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
@@ -524,7 +524,7 @@ test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedA
|
||||
assert.match(mails()[0].text, /baut unser Team/)
|
||||
})
|
||||
|
||||
test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K24 wieder ohne "Wie geht es weiter"', async () => {
|
||||
test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K27 wieder ohne "Wie geht es weiter"', async () => {
|
||||
for (const [name, over] of [
|
||||
['aktiv', {}],
|
||||
['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }],
|
||||
@@ -548,15 +548,15 @@ test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kei
|
||||
assert.equal(doc.einbau, 'angestossen', name)
|
||||
assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`)
|
||||
|
||||
assert.equal(mailsMit(K24).length, 1, name)
|
||||
const text = mailsMit(K24)[0].text
|
||||
assert.equal(mailsMit(K27).length, 1, name)
|
||||
const text = mailsMit(K27)[0].text
|
||||
assert.match(text, /Dein Wartungs-Abo läuft wieder/, name)
|
||||
assert.doesNotMatch(text, /Wie geht es weiter/, `${name}: beide Blöcke aus, der Kasten entfällt`)
|
||||
assert.doesNotMatch(text, /nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten/, name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Rückkehr ohne vorherige K26 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => {
|
||||
test('Rückkehr ohne vorherige K29 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => {
|
||||
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' })
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 }))
|
||||
@@ -572,8 +572,8 @@ test('Rückkehr: das Abo kommt zwischen Vorauswahl und Sperre zurück ⇒ der fr
|
||||
const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30)))
|
||||
assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
assert.equal(mailsMit(K24).length, 1)
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
})
|
||||
|
||||
@@ -592,7 +592,7 @@ test('Löschung fällig, aber die Daten sind schon weg (Lauf nach dem Löschen u
|
||||
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 }))
|
||||
assert.equal(funktion().state, 'off')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mailsMit(K24).length, 0)
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 4: Löschung
|
||||
@@ -615,22 +615,22 @@ test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht,
|
||||
assert.equal(doc.state, 'off')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.deepEqual(
|
||||
[doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
|
||||
[doc.ticketId ?? '', doc.einbau ?? '', doc.einschaltMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
|
||||
['', '', null, null, '', null],
|
||||
'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
|
||||
'ticketId, einbau, einschaltMailAt, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
|
||||
)
|
||||
assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/)
|
||||
assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 0)
|
||||
assert.equal(aw.docs('zeitenErinnerungen').has('m1'), false, 'Erinnerungsmerker der Funktion gelöscht')
|
||||
assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben')
|
||||
assert.equal(mails().length, 0, 'die K27 hat die Löschung angekündigt, es gibt keine weitere Mail')
|
||||
assert.equal(mails().length, 0, 'die K30 hat die Löschung angekündigt, es gibt keine weitere Mail')
|
||||
|
||||
// aus ist aus: der nächste Lauf liest das Dokument nicht mehr
|
||||
assert.deepEqual(await laufe(nach(61)), ergebnis())
|
||||
})
|
||||
|
||||
test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => {
|
||||
test('nach der Löschung: ticketId, einbau, einschaltMailAt, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => {
|
||||
await bisAbgeschaltet()
|
||||
await laufe(nach(60))
|
||||
assert.equal(funktion().state, 'off')
|
||||
@@ -639,7 +639,7 @@ test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellun
|
||||
mails().length = 0
|
||||
rebuilds.length = 0
|
||||
|
||||
// Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K24
|
||||
// Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K27
|
||||
laeuft()
|
||||
const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) })
|
||||
assert.equal(changed, true)
|
||||
@@ -647,8 +647,8 @@ test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellun
|
||||
assert.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket')
|
||||
assert.equal(tickets().length, ticketsVorher + 1)
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.ok(doc.k24At, 'K24 neu gesendet')
|
||||
assert.equal(mailsMit(K24).length, 1)
|
||||
assert.ok(doc.einschaltMailAt, 'K27 neu gesendet')
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
})
|
||||
|
||||
@@ -790,14 +790,14 @@ test('ein unlesbares Datum bei einem Dokument stoppt die anderen nicht (je Dokum
|
||||
const vorher = [stand(1), stand(2)]
|
||||
|
||||
const erg = await laufe(nach(30))
|
||||
assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K26')
|
||||
assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K29')
|
||||
assert.deepEqual([stand(1), stand(2)], vorher, 'die kaputten Dokumente bleiben, wie sie sind')
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com'])
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht')
|
||||
})
|
||||
|
||||
test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K26 nach', async () => {
|
||||
test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K29 nach', async () => {
|
||||
await legeFunktion(1)
|
||||
await legeFunktion(2, {}, { kunde: 'c2' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
|
||||
@@ -817,13 +817,13 @@ test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzde
|
||||
test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT) // K26
|
||||
await laufe(nach(30)) // K27
|
||||
await laufe(JETZT) // K29
|
||||
await laufe(nach(30)) // K30
|
||||
laeuft()
|
||||
await laufe(nach(45)) // Neubau und K24
|
||||
await laufe(nach(45)) // Neubau und K27
|
||||
assert.deepEqual(
|
||||
sperrenBeimVersand.map((s) => [s.subject === K27 ? 'K27' : s.subject === K24 ? 'K24' : 'K26', s.admin]),
|
||||
[['K26', true], ['K27', true], ['K24', true]]
|
||||
sperrenBeimVersand.map((s) => [s.subject === K30 ? 'K30' : s.subject === K27 ? 'K27' : 'K29', s.admin]),
|
||||
[['K29', true], ['K30', true], ['K27', true]]
|
||||
)
|
||||
assert.equal(rebuilds[0].adminFrei, true)
|
||||
})
|
||||
@@ -840,7 +840,7 @@ test('Abo-Abfragen: die Vorauswahl liest je Kunde einmal, dazu das frische Lesen
|
||||
aw.resetListCalls()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 }))
|
||||
assert.equal(abfragenAufAbos(), 2 + 2, 'zwei Kunden in der Vorauswahl; frisch nur für die zwei Dokumente von c1 (das von c2 braucht nichts)')
|
||||
assert.equal(mailsMit(K26).length, 2)
|
||||
assert.equal(mailsMit(K29).length, 2)
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['silke@example.com', 'silke@example.com'])
|
||||
assert.ok(funktion(1).aboEndeAt && funktion(2).aboEndeAt)
|
||||
assert.ok(!funktion(3).aboEndeAt)
|
||||
@@ -887,12 +887,12 @@ test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; admin
|
||||
assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, ''])
|
||||
})
|
||||
|
||||
test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K27 und das Ersatz-Ticket nach', async () => {
|
||||
test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K30 und das Ersatz-Ticket nach', async () => {
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage')
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// activate() ist der gemeinsame Schreibpfad aller Funktionen (News, Terminplaner, Zeiten). Die neuen Zeiten-Felder von portalFeatures
|
||||
// (unlockedBy, aboEndeAt, einbau, behaltenSessionId, k24At) legt erst scripts/zeiten-setup.mjs an: laeuft der Code vor diesem Skript,
|
||||
// (unlockedBy, aboEndeAt, einbau, behaltenSessionId, einschaltMailAt) legt erst scripts/zeiten-setup.mjs an: laeuft der Code vor diesem Skript,
|
||||
// darf die Aktivierung von News und Terminplaner nie ein Zeiten-Feld schreiben (Appwrite: "Unknown attribute"). Der Nachbau hier hat
|
||||
// darum genau das Schema OHNE die Zeiten-Felder; nur der Zeiten-Pfad (extra) schreibt sie.
|
||||
const ZEITEN_FELDER = ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)
|
||||
|
||||
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
featureConsents: ['acceptedAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
|
||||
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
featureConsents: ['acceptedAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
@@ -286,13 +286,13 @@ test('Zahlung ⇒ setup, unlockedBy kauf, Neubau vor dem Ticket, einbau am Dokum
|
||||
assert.doesNotMatch(ticket.details, /bitte von Hand einsetzen/)
|
||||
assert.ok(ticket.details.includes(`data-site="${doc.siteKey}"`))
|
||||
|
||||
// K15 an den Kunden, T16 ans Team, je einmal; keine K24 (die gehört zum Gratisweg)
|
||||
// K15 an den Kunden, T16 ans Team, je einmal; keine K27 (die gehört zum Gratisweg)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.deepEqual(mailsMit(SUBJECT.K15)[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
|
||||
assert.equal(mailsMit('Öffnungszeiten live ist eingeschaltet').length, 0)
|
||||
assert.equal(doc.k24At ?? null, null)
|
||||
assert.equal(doc.einschaltMailAt ?? null, null)
|
||||
})
|
||||
|
||||
test('Zahlung, Neubau nicht möglich (kein Repo): einbau manuell, Ticket sagt „bitte von Hand einsetzen“', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
featureConsents: ['acceptedAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
@@ -127,7 +127,7 @@ test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzun
|
||||
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.ok(doc.ticketId)
|
||||
assert.ok(doc.k24At)
|
||||
assert.ok(doc.einschaltMailAt)
|
||||
assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten')
|
||||
|
||||
// Nachweis: Fassung und Texte der Gratis-Zustimmung, ohne Stripe-Sitzung, mit dem handelnden Konto
|
||||
@@ -142,12 +142,12 @@ test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzun
|
||||
])
|
||||
})
|
||||
|
||||
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k24At', async () => {
|
||||
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K27, dann einschaltMailAt', async () => {
|
||||
const beimVersand = []
|
||||
setMailTransportForTests({
|
||||
sent: sink.sent,
|
||||
async sendMail(mail) {
|
||||
beimVersand.push({ k24At: zeitenDoc()?.k24At ?? null, tickets: aw.docs('workorders').size })
|
||||
beimVersand.push({ einschaltMailAt: zeitenDoc()?.einschaltMailAt ?? null, tickets: aw.docs('workorders').size })
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
@@ -163,19 +163,19 @@ test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k
|
||||
assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/)
|
||||
assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`))
|
||||
assert.equal(zeitenDoc().ticketId, ticket.$id)
|
||||
// K24: genau eine Mail, nach dem Ticket, k24At erst danach
|
||||
// K27: genau eine Mail, nach dem Ticket, einschaltMailAt erst danach
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.deepEqual(beimVersand, [{ k24At: null, tickets: 1 }])
|
||||
assert.deepEqual(beimVersand, [{ einschaltMailAt: null, tickets: 1 }])
|
||||
const mail = sink.sent[0]
|
||||
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/)
|
||||
assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`))
|
||||
assert.doesNotMatch(mail.text, /nicht automatisch aktualisieren/)
|
||||
assert.ok(Date.parse(zeitenDoc().k24At) > 0)
|
||||
assert.ok(Date.parse(zeitenDoc().einschaltMailAt) > 0)
|
||||
})
|
||||
|
||||
test('Neubau nicht möglich: einbau manuell, Ticket und K24 sagen „Team baut ein“', async () => {
|
||||
test('Neubau nicht möglich: einbau manuell, Ticket und K27 sagen „Team baut ein“', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) // kein Repo ⇒ manuell
|
||||
const res = await einschalten(koerper())
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
@@ -333,7 +333,7 @@ test('Nachweis nicht speicherbar ⇒ 500 ohne Rohtext, Zustand bleibt off, kein
|
||||
|
||||
// ------------------------------------------------------------------------------------------- Zustaende
|
||||
|
||||
test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K24', async () => {
|
||||
test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K27', async () => {
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
|
||||
const erster = await einschalten(koerper())
|
||||
// Zustand ist gespeichert, das Ticket fehlt: der Kunde bekommt einen Hinweis zum erneuten Versuch, nicht den Rohtext
|
||||
@@ -342,7 +342,7 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
|
||||
assert.equal(zeitenDoc().state, 'setup')
|
||||
assert.equal(zeitenDoc().unlockedBy, 'abo')
|
||||
assert.ok(!zeitenDoc().ticketId)
|
||||
assert.ok(!zeitenDoc().k24At)
|
||||
assert.ok(!zeitenDoc().einschaltMailAt)
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
assert.equal(nachweise().length, 1)
|
||||
@@ -353,7 +353,7 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(zeitenDoc().ticketId, tickets()[0].$id)
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.ok(zeitenDoc().k24At)
|
||||
assert.ok(zeitenDoc().einschaltMailAt)
|
||||
assert.equal(nachweise().length, 1, 'kein zweiter Nachweis')
|
||||
assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau')
|
||||
assert.equal(zweiter.body.einbau, 'angestossen')
|
||||
@@ -364,21 +364,21 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
|
||||
assert.deepEqual([tickets().length, sink.sent.length, nachweise().length, rebuilds.length], [1, 1, 1, 1])
|
||||
})
|
||||
|
||||
test('K24 scheitert: Zustand und Ticket bleiben, k24At leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => {
|
||||
test('K27 scheitert: Zustand und Ticket bleiben, einschaltMailAt leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => {
|
||||
setMailTransportForTests({ sent: [], async sendMail() { throw new Error('SMTP down') } })
|
||||
const erster = await einschalten(koerper())
|
||||
assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.ok(!zeitenDoc().k24At)
|
||||
assert.ok(!zeitenDoc().einschaltMailAt)
|
||||
setMailTransportForTests(sink)
|
||||
assert.equal((await einschalten(koerper())).status, 200)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.ok(zeitenDoc().k24At)
|
||||
assert.ok(zeitenDoc().einschaltMailAt)
|
||||
assert.match(sink.sent[0].text, new RegExp(`Ticket #${tickets()[0].woid}`))
|
||||
})
|
||||
|
||||
test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K24', async () => {
|
||||
test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K27', async () => {
|
||||
const [a, b] = await Promise.all([einschalten(koerper()), einschalten(koerper())])
|
||||
assert.deepEqual([a.status, b.status], [200, 200], JSON.stringify([a.body, b.body]))
|
||||
assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1])
|
||||
@@ -438,7 +438,7 @@ test('Dokument im Zustand off mit Resten wird sauber auf setup gestellt', async
|
||||
|
||||
// --------------------------------------------------------------------------------------------- Team und Nachholen
|
||||
|
||||
test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K24, Notiz', async () => {
|
||||
test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K27, Notiz', async () => {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT })
|
||||
assert.equal(changed, true)
|
||||
@@ -447,7 +447,7 @@ test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, kei
|
||||
assert.equal(nachweise().length, 0)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
assert.ok(!zeitenDoc().k24At)
|
||||
assert.ok(!zeitenDoc().einschaltMailAt)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
// ein zweiter Aufruf aendert nichts
|
||||
const nochmal = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT })
|
||||
@@ -488,12 +488,12 @@ test('Team aus disabled bei einem gekauften Dokument: kauf und unlockedByPurchas
|
||||
assert.deepEqual([team.doc.unlockedBy, team.doc.unlockedByPurchase], ['team', false])
|
||||
})
|
||||
|
||||
test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy abo und leerem k24At, danach Marker', async () => {
|
||||
test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K27 nur bei unlockedBy abo und leerem einschaltMailAt, danach Marker', async () => {
|
||||
aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' })
|
||||
const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(doc.ticketId, tickets()[0].$id)
|
||||
assert.equal(Date.parse(doc.k24At), JETZT.getTime())
|
||||
assert.equal(Date.parse(doc.einschaltMailAt), JETZT.getTime())
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau')
|
||||
// noch einmal: nichts
|
||||
@@ -501,7 +501,7 @@ test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy
|
||||
assert.deepEqual([tickets().length, sink.sent.length], [1, 1])
|
||||
})
|
||||
|
||||
test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K24', async () => {
|
||||
test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K27', async () => {
|
||||
const seed = (id, felder) => {
|
||||
projekt(id)
|
||||
aw.seed('portalFeatures', { $id: `f_${id}_zeiten`, customerId: 'c1', projectId: id, featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', enabled: false, ...felder })
|
||||
@@ -522,22 +522,22 @@ test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben
|
||||
const fertig = await holeZeitenNach(team, { now: JETZT })
|
||||
assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0])
|
||||
assert.equal(fertig.einbau, 'angestossen')
|
||||
assert.ok(!fertig.k24At)
|
||||
assert.ok(!fertig.einschaltMailAt)
|
||||
assert.match(tickets()[0].details, /automatisch eingesetzt/, 'das Ticket kennt das frische einbau')
|
||||
})
|
||||
|
||||
test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ k24At wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => {
|
||||
test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ einschaltMailAt wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => {
|
||||
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: '', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
|
||||
aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell', ticketId: 'ticket_da' })
|
||||
const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
|
||||
assert.ok(doc.k24At)
|
||||
assert.ok(doc.einschaltMailAt)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
|
||||
await updateDocument('portalFeatures', KEY_P1, { k24At: null })
|
||||
await updateDocument('portalFeatures', KEY_P1, { einschaltMailAt: null })
|
||||
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
|
||||
aw.failNext({ collection: 'customers', status: 500 })
|
||||
await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT }))
|
||||
assert.ok(!zeitenDoc().k24At)
|
||||
assert.ok(!zeitenDoc().einschaltMailAt)
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------- Angebot
|
||||
|
||||
@@ -8,13 +8,13 @@ import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// Feiertags-Erinnerung von Öffnungszeiten live (Aufgabe 23, Spec 7 "Feiertags-Erinnerung", Z18): erinnereFeiertage(now) schickt je Funktion
|
||||
// (setup/active) eine Mail K25 mit allen neuen Tagen in 1–7 Tagen, die Feiertag oder "bitte prüfen" sind. Je Tag zuerst der Merker
|
||||
// (setup/active) eine Mail K28 mit allen neuen Tagen in 1–7 Tagen, die Feiertag oder "bitte prüfen" sind. Je Tag zuerst der Merker
|
||||
// sha1(featureId|datum) (409 = schon erinnert), danach erst die Mail: lieber einmal zu wenig als doppelt. Nur Schalter pilot/on (pilot: nur
|
||||
// freigegebene Kunden), nur 08:00 bis 20:00 Berliner Zeit, kein BCC ans Team, Merker älter als 30 Tage werden gelöscht.
|
||||
// Aufbau wie zeitenAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit als Parameter.
|
||||
const DATETIMES = {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
}
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
@@ -141,7 +141,7 @@ test('eine Mail für zwei Feiertage im Fenster', async () => {
|
||||
assert.match(mail.text, /Fr 26\.03\. – Karfreitag\nMo 29\.03\. – Ostermontag/, 'beide Tage, in Datumsfolge')
|
||||
assert.match(mail.text, /Praxis 1/, 'der Projektname steht in der Mail')
|
||||
assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Knopf zum Editor')
|
||||
assert.ok(!mail.bcc?.length, 'K25 geht ohne Kopie ans Team (R17c)')
|
||||
assert.ok(!mail.bcc?.length, 'K28 geht ohne Kopie ans Team (R17c)')
|
||||
|
||||
assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29'])
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
// Freischalten von Oeffnungszeiten live (Aufgabe 18, Spec 3.2 Selbstheilung und 4.4): der 5-Minuten-Job (runActivation) setzt 'active',
|
||||
// wenn das Einbau-Ticket geschlossen ist UND der Kunde Zeiten eingetragen hat (Haken freischaltbar: Hauptplan gespeichert, mindestens
|
||||
// ein Tag nicht "zu"). Bei active: K16 und der Ersatz-Hash (Haken nachFreischaltung). Dazu heilt der Job Dokumente in setup, denen
|
||||
// Ticket oder K24 fehlen (holeZeitenNach), je Dokument getrennt abgesichert.
|
||||
// Ticket oder K27 fehlen (holeZeitenNach), je Dokument getrennt abgesichert.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
@@ -18,7 +18,7 @@ const aw = await startAppwriteMock({
|
||||
},
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
})
|
||||
@@ -39,13 +39,13 @@ after(() => setMailTransportForTests(null))
|
||||
const Z = config.features.zeiten
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
|
||||
const K16_BETREFF = /bereit/
|
||||
const K24_BETREFF = 'Öffnungszeiten live ist eingeschaltet'
|
||||
const K27_BETREFF = 'Öffnungszeiten live ist eingeschaltet'
|
||||
|
||||
const feld = (id, name) => aw.docs('portalFeatures').get(id)[name]
|
||||
const zustand = (id) => feld(id, 'state')
|
||||
const tickets = () => [...aw.docs('workorders').values()]
|
||||
const k16 = () => sink.sent.filter((m) => K16_BETREFF.test(m.subject))
|
||||
const k24 = () => sink.sent.filter((m) => m.subject === K24_BETREFF)
|
||||
const k27 = () => sink.sent.filter((m) => m.subject === K27_BETREFF)
|
||||
const logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
|
||||
|
||||
function tage(modi) {
|
||||
@@ -69,7 +69,7 @@ const eingabe = (modi = WOCHE) => ({
|
||||
let nr = 0
|
||||
/**
|
||||
* Dokument der Zeiten im Zustand setup mit Projekt, Kunde und Ticket (Standard: geschlossen), optional mit gespeicherten Zeiten
|
||||
* (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und k24At gesetzt, damit nur die Freischaltung etwas bewegt.
|
||||
* (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und einschaltMailAt gesetzt, damit nur die Freischaltung etwas bewegt.
|
||||
*/
|
||||
async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) {
|
||||
nr += 1
|
||||
@@ -87,7 +87,7 @@ async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null
|
||||
siteKey: 'abcdefghijklmnop',
|
||||
unlockedBy: 'abo',
|
||||
einbau: 'angestossen',
|
||||
k24At: JETZT.toISOString(),
|
||||
einschaltMailAt: JETZT.toISOString(),
|
||||
...(ticket ? { ticketId: `t_${projectId}` } : {}),
|
||||
...over,
|
||||
})
|
||||
@@ -174,7 +174,7 @@ test('Ticket zu und Zeiten gespeichert ⇒ active, genau eine K16, ersatzHash ge
|
||||
assert.equal(Date.parse(feld(id, 'activatedAt')), JETZT.getTime())
|
||||
assert.equal(k16().length, 1)
|
||||
assert.deepEqual(k16()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.equal(k24().length, 0)
|
||||
assert.equal(k27().length, 0)
|
||||
|
||||
const nachher = await ladeZeiten(id)
|
||||
assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/)
|
||||
@@ -306,8 +306,8 @@ test('Abnahme der Zeiten: mit eingetragenen Zeiten active, K16, Ersatz-Hash (nac
|
||||
|
||||
// ------------------------------------------------------------------------------------------- Selbstheilung
|
||||
|
||||
test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K24)', async () => {
|
||||
const id = await einrichtung({ ticket: null, einbau: '', k24At: null })
|
||||
test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K27)', async () => {
|
||||
const id = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
|
||||
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
|
||||
assert.equal(zustand(id), 'setup')
|
||||
assert.equal(tickets().length, 1)
|
||||
@@ -315,49 +315,49 @@ test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sen
|
||||
assert.match(tickets()[0].topic, /^Öffnungszeiten live einbauen – Projekt \d+$/)
|
||||
assert.equal(feld(id, 'einbau'), 'angestossen')
|
||||
assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau')
|
||||
assert.equal(k24().length, 1)
|
||||
assert.ok(feld(id, 'k24At'))
|
||||
assert.equal(k27().length, 1)
|
||||
assert.ok(feld(id, 'einschaltMailAt'))
|
||||
|
||||
// kein zweites Ticket, kein zweiter Neubau, keine zweite Mail
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 1, 1])
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 1, 1])
|
||||
})
|
||||
|
||||
test('Selbstheilung: unlockedBy abo ohne k24At ⇒ der Job sendet die K24 genau einmal', async () => {
|
||||
const id = await einrichtung({ ticket: 'Open', k24At: null })
|
||||
test('Selbstheilung: unlockedBy abo ohne einschaltMailAt ⇒ der Job sendet die K27 genau einmal', async () => {
|
||||
const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
|
||||
await sf.runActivation({ now: JETZT })
|
||||
await sf.runActivation({ now: JETZT })
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.equal(k24().length, 1)
|
||||
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.equal(Date.parse(feld(id, 'k24At')), JETZT.getTime())
|
||||
assert.equal(k27().length, 1)
|
||||
assert.deepEqual(k27()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.equal(Date.parse(feld(id, 'einschaltMailAt')), JETZT.getTime())
|
||||
assert.equal(tickets().length, 1, 'das Ticket war da: kein neues')
|
||||
assert.deepEqual(rebuilds, [])
|
||||
assert.equal(zustand(id), 'setup')
|
||||
})
|
||||
|
||||
test('Selbstheilung: K24 nicht gesendet (Mail scheitert) ⇒ k24At bleibt leer, der nächste Lauf sendet nach', async () => {
|
||||
const id = await einrichtung({ ticket: 'Open', k24At: null })
|
||||
test('Selbstheilung: K27 nicht gesendet (Mail scheitert) ⇒ einschaltMailAt bleibt leer, der nächste Lauf sendet nach', async () => {
|
||||
const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
|
||||
setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } })
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.ok(!feld(id, 'k24At'))
|
||||
assert.ok(!feld(id, 'einschaltMailAt'))
|
||||
setMailTransportForTests(sink)
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.equal(k24().length, 1)
|
||||
assert.ok(feld(id, 'k24At'))
|
||||
assert.equal(k27().length, 1)
|
||||
assert.ok(feld(id, 'einschaltMailAt'))
|
||||
})
|
||||
|
||||
test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K24', async () => {
|
||||
const kauf = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'kauf', unlockedByPurchase: true })
|
||||
const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, k24At: null })
|
||||
const aus = await einrichtung({ ticket: null, state: 'off', k24At: null })
|
||||
const team = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'team' })
|
||||
test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K27', async () => {
|
||||
const kauf = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'kauf', unlockedByPurchase: true })
|
||||
const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, einschaltMailAt: null })
|
||||
const aus = await einrichtung({ ticket: null, state: 'off', einschaltMailAt: null })
|
||||
const team = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'team' })
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket')
|
||||
assert.equal(tickets()[0].$id, feld(team, 'ticketId'))
|
||||
assert.equal(sink.sent.length, 0)
|
||||
for (const id of [kauf, aktiv, aus]) assert.ok(!feld(id, 'ticketId'), id)
|
||||
assert.ok(!feld(team, 'k24At'))
|
||||
assert.ok(!feld(team, 'einschaltMailAt'))
|
||||
})
|
||||
|
||||
test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleibt unberührt', async () => {
|
||||
@@ -369,15 +369,15 @@ test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleib
|
||||
})
|
||||
|
||||
test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen werden geheilt, der Job wirft nicht, das Log nennt keine Kundendaten', async () => {
|
||||
const kaputt = await einrichtung({ ticket: null, einbau: '', k24At: null })
|
||||
const heil = await einrichtung({ ticket: null, einbau: '', k24At: null, customerId: 'c2' })
|
||||
const kaputt = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
|
||||
const heil = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, customerId: 'c2' })
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
|
||||
const done = await sf.runActivation({ now: JETZT })
|
||||
assert.deepEqual(done, [])
|
||||
assert.ok(!feld(kaputt, 'ticketId'))
|
||||
assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt')
|
||||
assert.equal(k24().length, 1)
|
||||
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k2@example.invalid'])
|
||||
assert.equal(k27().length, 1)
|
||||
assert.deepEqual(k27()[0].to.map((a) => a.address), ['k2@example.invalid'])
|
||||
const log = logText()
|
||||
assert.match(log, /Ticketsystem down/)
|
||||
assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/)
|
||||
@@ -385,7 +385,7 @@ test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen
|
||||
// der nächste Lauf holt das gescheiterte nach
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.ok(feld(kaputt, 'ticketId'))
|
||||
assert.equal(k24().length, 2)
|
||||
assert.equal(k27().length, 2)
|
||||
})
|
||||
|
||||
test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 der schon freigeschalteten bleibt', async () => {
|
||||
@@ -399,24 +399,24 @@ test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 d
|
||||
})
|
||||
|
||||
test('Projektliste eines Kunden (runActivation mit customerId) schaltet nur frei: keine Selbstheilung, kein Neubau, kein Ticket, keine Mail', async () => {
|
||||
const unvollstaendig = await einrichtung({ ticket: null, einbau: '', k24At: null })
|
||||
const unvollstaendig = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
|
||||
const fertig = await einrichtung({ zeiten: WOCHE })
|
||||
const done = await sf.runActivation({ now: JETZT, customerId: 'c1' })
|
||||
assert.deepEqual(done.map((d) => d.$id), [fertig], 'Freischalten gilt weiter für den Kunden')
|
||||
assert.ok(!feld(unvollstaendig, 'ticketId'))
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion')
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion')
|
||||
assert.equal(k16().length, 1)
|
||||
// der Lauf des Jobs holt es dann nach
|
||||
await sf.runActivation({ now: JETZT })
|
||||
assert.ok(feld(unvollstaendig, 'ticketId'))
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k24().length], [2, 1, 1])
|
||||
assert.deepEqual([tickets().length, rebuilds.length, k27().length], [2, 1, 1])
|
||||
})
|
||||
|
||||
test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K24 mehr nachgeschoben', async () => {
|
||||
const id = await einrichtung({ zeiten: WOCHE, k24At: null })
|
||||
test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K27 mehr nachgeschoben', async () => {
|
||||
const id = await einrichtung({ zeiten: WOCHE, einschaltMailAt: null })
|
||||
const done = await sf.runActivation({ now: JETZT })
|
||||
assert.deepEqual(done.map((d) => d.$id), [id])
|
||||
assert.equal(zustand(id), 'active')
|
||||
assert.equal(k24().length, 0)
|
||||
assert.equal(k27().length, 0)
|
||||
assert.equal(k16().length, 1)
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
// Feiertags-Erinnerung) mit eigener Überlappsperre je Job, ohne Wurf, plus Quelltext-Pin der Einbindung in server/index.js.
|
||||
const DATETIMES = {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
}
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
@@ -75,7 +75,7 @@ async function legeFunktion() {
|
||||
siteKey: 'abcdefghijklmnop',
|
||||
ticketId: 't_1',
|
||||
einbau: 'angestossen',
|
||||
k24At: ZUVOR,
|
||||
einschaltMailAt: ZUVOR,
|
||||
activatedAt: ZUVOR,
|
||||
})
|
||||
const doc = aw.docs('portalFeatures').get('f_p1_zeiten')
|
||||
@@ -145,7 +145,7 @@ function haengeAn() {
|
||||
|
||||
// ---------------------------------------------------------------- Abo-Job
|
||||
|
||||
test('zeitenAboJob: führt den Abo-Abgleich aus (Abo-Ende erkannt, K26) und gibt dessen Ergebnis zurück', async () => {
|
||||
test('zeitenAboJob: führt den Abo-Abgleich aus (Abo-Ende erkannt, K29) und gibt dessen Ergebnis zurück', async () => {
|
||||
await legeFunktion()
|
||||
abgelaufen()
|
||||
|
||||
|
||||
+73
-73
@@ -6,7 +6,7 @@ import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Mails K24–K27 (Öffnungszeiten live: eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet), ihre Builder und
|
||||
// Mails K27–K30 (Öffnungszeiten live: eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet), ihre Builder und
|
||||
// sendeZeitenMail (server/services/zeiten/mails.js, Task 13) sowie die neuen Blöcke behalten/neu in K15 und abo/team in T16.
|
||||
// Die allgemeinen Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Du-Form) prüft test/mailTemplates.test.js.
|
||||
const aw = await startAppwriteMock()
|
||||
@@ -18,7 +18,7 @@ const { loadTemplate, renderMail } = await import('../server/services/mailTempla
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { k24Mail, k25Mail, k26Mail, k27Mail, sendeZeitenMail } = await import('../server/services/zeiten/mails.js')
|
||||
const { k27Mail, k28Mail, k29Mail, k30Mail, sendeZeitenMail } = await import('../server/services/zeiten/mails.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const KUNDE = { $id: 'c1', name: 'Silke Müller', email: 'silke@example.com' }
|
||||
@@ -40,13 +40,13 @@ const an = (nachricht) => Object.entries(nachricht.blocks || {}).filter(([, on])
|
||||
// Ein Beispiel je Variante: genau diese Nachrichten gehen durch die Prüfung "Werte decken die Platzhalter ab"
|
||||
const FRIST = new Date('2026-11-04T09:00:00.000Z')
|
||||
const BEISPIELE = {
|
||||
'K24 angestossen': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }),
|
||||
'K24 manuell mit Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }),
|
||||
'K24 wieder ohne Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }),
|
||||
'K24 wieder ohne Neubau': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: null, wieder: true }),
|
||||
'K25 ein Tag': () => k25Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }),
|
||||
'K25 mehrere mit prüfen': () =>
|
||||
k25Mail({
|
||||
'K27 angestossen': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }),
|
||||
'K27 manuell mit Ticket': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }),
|
||||
'K27 wieder ohne Ticket': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }),
|
||||
'K27 wieder ohne Neubau': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: null, wieder: true }),
|
||||
'K28 ein Tag': () => k28Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }),
|
||||
'K28 mehrere mit prüfen': () =>
|
||||
k28Mail({
|
||||
kunde: KUNDE.name,
|
||||
projekt: 'Praxis Muster',
|
||||
tage: [
|
||||
@@ -54,18 +54,18 @@ const BEISPIELE = {
|
||||
{ tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' },
|
||||
],
|
||||
}),
|
||||
'K26 beendet': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }),
|
||||
'K26 hosting': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'hosting', frist: FRIST, preis: '30 €' }),
|
||||
'K26 Wechsel widerrufen': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'wechsel_widerrufen', frist: FRIST, preis: '30 €' }),
|
||||
K27: () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', loeschdatum: FRIST, preis: '30 €' }),
|
||||
'K29 beendet': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }),
|
||||
'K29 hosting': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'hosting', frist: FRIST, preis: '30 €' }),
|
||||
'K29 Wechsel widerrufen': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'wechsel_widerrufen', frist: FRIST, preis: '30 €' }),
|
||||
K30: () => k30Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', loeschdatum: FRIST, preis: '30 €' }),
|
||||
}
|
||||
|
||||
test('Vorlagen K24–K27: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => {
|
||||
test('Vorlagen K27–K30: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => {
|
||||
const erwartet = {
|
||||
K24: { file: 'K24 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder', 'weiter'] },
|
||||
K25: { file: 'K25 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] },
|
||||
K26: { file: 'K26 Zeiten Abo beendet.html', subject: 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?', placeholders: ['kunde_name', 'projekt', 'frist', 'preis', 'portal_url'], blocks: ['abo_ende', 'hosting', 'wechsel_widerrufen'] },
|
||||
K27: { file: 'K27 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] },
|
||||
K27: { file: 'K27 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder', 'weiter'] },
|
||||
K28: { file: 'K28 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] },
|
||||
K29: { file: 'K29 Zeiten Abo beendet.html', subject: 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?', placeholders: ['kunde_name', 'projekt', 'frist', 'preis', 'portal_url'], blocks: ['abo_ende', 'hosting', 'wechsel_widerrufen'] },
|
||||
K30: { file: 'K30 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] },
|
||||
}
|
||||
for (const [id, soll] of Object.entries(erwartet)) {
|
||||
const tpl = loadTemplate(id)
|
||||
@@ -92,22 +92,22 @@ test('Builder decken genau die Platzhalter und Blöcke der Vorlage ab', () => {
|
||||
})
|
||||
|
||||
test('Anrede: Kunde als Name oder als Kundendokument, ohne Namen bleibt der Wert leer (wie K22/K23)', () => {
|
||||
assert.equal(k27Mail({ kunde: KUNDE, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, 'Silke Müller')
|
||||
assert.equal(k30Mail({ kunde: KUNDE, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, 'Silke Müller')
|
||||
for (const kunde of [undefined, null, '', ' ', {}, { name: null }]) {
|
||||
assert.equal(k27Mail({ kunde, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, '', JSON.stringify(kunde))
|
||||
assert.equal(k30Mail({ kunde, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, '', JSON.stringify(kunde))
|
||||
}
|
||||
})
|
||||
|
||||
test('K24: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => {
|
||||
const auto = BEISPIELE['K24 angestossen']()
|
||||
assert.equal(auto.template, 'K24')
|
||||
test('K27: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => {
|
||||
const auto = BEISPIELE['K27 angestossen']()
|
||||
assert.equal(auto.template, 'K27')
|
||||
assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false, weiter: true })
|
||||
const textAuto = render(auto).text
|
||||
assert.match(textAuto, /nach dem nächsten Veröffentlichen/)
|
||||
assert.doesNotMatch(textAuto, /baut unser Team/)
|
||||
assert.match(textAuto, /Ticket #100123/, 'Ticketnummer steht in beiden Fällen in der Mail')
|
||||
|
||||
const hand = BEISPIELE['K24 manuell mit Ticket']()
|
||||
const hand = BEISPIELE['K27 manuell mit Ticket']()
|
||||
assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true })
|
||||
const textHand = render(hand).text
|
||||
assert.match(textHand, /baut unser Team/)
|
||||
@@ -116,13 +116,13 @@ test('K24: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen
|
||||
|
||||
// ohne bekannten Einbau sagt die Mail das Vorsichtige: das Team baut ein (null gilt nur mit wieder als "kein Einbau nötig", siehe unten)
|
||||
for (const einbau of [undefined, null, '', 'unbekannt']) {
|
||||
const unklar = k24Mail({ kunde: 'K', projekt: 'P', einbau })
|
||||
const unklar = k27Mail({ kunde: 'K', projekt: 'P', einbau })
|
||||
assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true }, String(einbau))
|
||||
}
|
||||
})
|
||||
|
||||
test('K24 wieder ohne Neubau (einbau null, Abo kehrt vor dem Abschalten zurück): beide Einbau-Blöcke und der Kasten "Wie geht es weiter" entfallen', () => {
|
||||
const ohne = BEISPIELE['K24 wieder ohne Neubau']()
|
||||
test('K27 wieder ohne Neubau (einbau null, Abo kehrt vor dem Abschalten zurück): beide Einbau-Blöcke und der Kasten "Wie geht es weiter" entfallen', () => {
|
||||
const ohne = BEISPIELE['K27 wieder ohne Neubau']()
|
||||
assert.deepEqual(ohne.blocks, { angestossen: false, manuell: false, wieder: true, weiter: false })
|
||||
const mail = render(ohne)
|
||||
assert.deepEqual(mail.problems, [])
|
||||
@@ -136,40 +136,40 @@ test('K24 wieder ohne Neubau (einbau null, Abo kehrt vor dem Abschalten zurück)
|
||||
|
||||
// mit Neubau (angestossen oder manuell) bleibt der Kasten, auch beim Wiedereinschalten
|
||||
for (const einbau of ['angestossen', 'manuell']) {
|
||||
const text = render(k24Mail({ kunde: 'K', projekt: 'P', einbau, wieder: true })).text
|
||||
const text = render(k27Mail({ kunde: 'K', projekt: 'P', einbau, wieder: true })).text
|
||||
assert.match(text, /Wie geht es weiter/, einbau)
|
||||
}
|
||||
// einbau null ohne wieder ist kein Fall der Mail: es bleibt beim Vorsichtigen
|
||||
assert.deepEqual(k24Mail({ kunde: 'K', projekt: 'P', einbau: null }).blocks, { angestossen: false, manuell: true, wieder: false, weiter: true })
|
||||
assert.deepEqual(k27Mail({ kunde: 'K', projekt: 'P', einbau: null }).blocks, { angestossen: false, manuell: true, wieder: false, weiter: true })
|
||||
})
|
||||
|
||||
test('K24: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => {
|
||||
const ohne = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' })
|
||||
test('K27: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => {
|
||||
const ohne = k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' })
|
||||
assert.equal(ohne.values.ticket, '')
|
||||
assert.doesNotMatch(render(ohne).text, /Ticket|\(\)|\s\./)
|
||||
const leer = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: ' ' })
|
||||
const leer = k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: ' ' })
|
||||
assert.equal(leer.values.ticket, '')
|
||||
assert.equal(k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)')
|
||||
assert.equal(k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)')
|
||||
|
||||
const wieder = BEISPIELE['K24 wieder ohne Ticket']()
|
||||
const wieder = BEISPIELE['K27 wieder ohne Ticket']()
|
||||
assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true, weiter: true })
|
||||
const mitWieder = render(wieder).text
|
||||
assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/)
|
||||
assert.match(mitWieder, /wieder eingeschaltet/)
|
||||
assert.match(mitWieder, /Zeiten und Einstellungen sind unverändert/)
|
||||
assert.doesNotMatch(render(BEISPIELE['K24 angestossen']()).text, /Wartungs-Abo läuft wieder/)
|
||||
assert.doesNotMatch(render(BEISPIELE['K27 angestossen']()).text, /Wartungs-Abo läuft wieder/)
|
||||
})
|
||||
|
||||
test('K24: Betreff, Projekt und Knopf', () => {
|
||||
const mail = render(BEISPIELE['K24 angestossen']())
|
||||
test('K27: Betreff, Projekt und Knopf', () => {
|
||||
const mail = render(BEISPIELE['K27 angestossen']())
|
||||
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
|
||||
assert.match(mail.text, /Projekt: Praxis Muster/)
|
||||
assert.match(mail.html, new RegExp(`<a href="${PORTAL.replaceAll('.', '\\.')}"`))
|
||||
})
|
||||
|
||||
test('K25: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“ und Mehrzahl nur bei Bedarf', () => {
|
||||
const ein = BEISPIELE['K25 ein Tag']()
|
||||
assert.equal(ein.template, 'K25')
|
||||
test('K28: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“ und Mehrzahl nur bei Bedarf', () => {
|
||||
const ein = BEISPIELE['K28 ein Tag']()
|
||||
assert.equal(ein.template, 'K28')
|
||||
assert.equal(ein.values.tage, 'Sa 03.10. – Tag der Deutschen Einheit')
|
||||
assert.deepEqual(an(ein), [])
|
||||
assert.deepEqual(ein.blocks, { pruefen: false, mehrere: false })
|
||||
@@ -179,7 +179,7 @@ test('K25: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“
|
||||
assert.match(textEin, /Stimmen Deine Zeiten/)
|
||||
assert.match(textEin, /Google/, 'Hinweis: bei Google auch eintragen')
|
||||
|
||||
const viele = BEISPIELE['K25 mehrere mit prüfen']()
|
||||
const viele = BEISPIELE['K28 mehrere mit prüfen']()
|
||||
assert.equal(viele.values.tage, 'Do 24.12. – Heiligabend (bitte prüfen)\nFr 25.12. – 1. Weihnachtstag')
|
||||
assert.deepEqual(viele.blocks, { pruefen: true, mehrere: true })
|
||||
const mail = render(viele)
|
||||
@@ -189,8 +189,8 @@ test('K25: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“
|
||||
assert.match(mail.html, /Heiligabend \(bitte prüfen\)<br>Fr 25\.12\./, 'Zeilenumbrüche werden zu <br>')
|
||||
})
|
||||
|
||||
test('K25: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => {
|
||||
const nachricht = k25Mail({
|
||||
test('K28: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => {
|
||||
const nachricht = k28Mail({
|
||||
kunde: 'K',
|
||||
projekt: 'P',
|
||||
tage: [
|
||||
@@ -209,15 +209,15 @@ test('K25: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woc
|
||||
assert.doesNotMatch(mail.html, /<b>fett/)
|
||||
})
|
||||
|
||||
test('K25 geht ohne BCC ans Team, K24 mit', async () => {
|
||||
const k25 = await sendeZeitenMail(KUNDE, BEISPIELE['K25 ein Tag'](), { bccTeam: false })
|
||||
assert.deepEqual(k25, { sent: true })
|
||||
test('K28 geht ohne BCC ans Team, K27 mit', async () => {
|
||||
const k28 = await sendeZeitenMail(KUNDE, BEISPIELE['K28 ein Tag'](), { bccTeam: false })
|
||||
assert.deepEqual(k28, { sent: true })
|
||||
assert.equal(sink.sent.length, 1)
|
||||
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['silke@example.com'])
|
||||
assert.ok(!sink.sent[0].bcc, 'keine Kopie ans Team')
|
||||
assert.equal(sink.sent[0].subject, 'Feiertage stehen an – stimmen Deine Zeiten?')
|
||||
|
||||
assert.deepEqual(await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']()), { sent: true })
|
||||
assert.deepEqual(await sendeZeitenMail(KUNDE, BEISPIELE['K27 angestossen']()), { sent: true })
|
||||
assert.equal(sink.sent.length, 2)
|
||||
assert.deepEqual(sink.sent[1].bcc.map((t) => t.address), config.chatNotify.emails, 'Standard: Kopie ans Team')
|
||||
assert.ok(sink.sent[1].bcc.length > 0)
|
||||
@@ -225,15 +225,15 @@ test('K25 geht ohne BCC ans Team, K24 mit', async () => {
|
||||
assert.match(sink.sent[1].html, /Hallo Silke Müller,/)
|
||||
})
|
||||
|
||||
test('K26: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf', () => {
|
||||
test('K29: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf', () => {
|
||||
const faelle = [
|
||||
['K26 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/],
|
||||
['K26 hosting', { abo_ende: false, hosting: true, wechsel_widerrufen: false }, /Hosting-Abo/, /Dein Wartungs-Abo ist beendet|widerrufen/],
|
||||
['K26 Wechsel widerrufen', { abo_ende: false, hosting: false, wechsel_widerrufen: true }, /Wechsel.*widerrufen/, /Dein Wartungs-Abo ist beendet|Dein Abo ist ein Hosting-Abo/],
|
||||
['K29 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/],
|
||||
['K29 hosting', { abo_ende: false, hosting: true, wechsel_widerrufen: false }, /Hosting-Abo/, /Dein Wartungs-Abo ist beendet|widerrufen/],
|
||||
['K29 Wechsel widerrufen', { abo_ende: false, hosting: false, wechsel_widerrufen: true }, /Wechsel.*widerrufen/, /Dein Wartungs-Abo ist beendet|Dein Abo ist ein Hosting-Abo/],
|
||||
]
|
||||
for (const [name, blocks, passt, fremd] of faelle) {
|
||||
const nachricht = BEISPIELE[name]()
|
||||
assert.equal(nachricht.template, 'K26')
|
||||
assert.equal(nachricht.template, 'K29')
|
||||
assert.deepEqual(nachricht.blocks, blocks, name)
|
||||
assert.equal(nachricht.values.frist, '04.11.2026', `${name}: Datum TT.MM.JJJJ`)
|
||||
assert.equal(nachricht.values.preis, '30 €')
|
||||
@@ -245,22 +245,22 @@ test('K26: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf',
|
||||
assert.match(text, /Für 30 € behalten: .*#zeiten/, `${name}: Knopf zum Behalten`)
|
||||
}
|
||||
// unbekannte Lage: lieber die Standard-Fassung (Abo beendet) als gar kein Grund
|
||||
assert.deepEqual(k26Mail({ kunde: 'K', projekt: 'P', art: 'was-neues', frist: FRIST, preis: '30 €' }).blocks, { abo_ende: true, hosting: false, wechsel_widerrufen: false })
|
||||
assert.equal(render(BEISPIELE['K26 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?')
|
||||
assert.deepEqual(k29Mail({ kunde: 'K', projekt: 'P', art: 'was-neues', frist: FRIST, preis: '30 €' }).blocks, { abo_ende: true, hosting: false, wechsel_widerrufen: false })
|
||||
assert.equal(render(BEISPIELE['K29 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?')
|
||||
})
|
||||
|
||||
test('K26/K27: Datum aus Date (Berliner Tag), ungültiges Datum wirft früh', () => {
|
||||
test('K29/K30: Datum aus Date (Berliner Tag), ungültiges Datum wirft früh', () => {
|
||||
// Berliner Tag, nicht UTC: 22:30 UTC am 30.10. ist 23:30 MEZ (noch der 30.), 23:30 UTC am 31.10. ist 00:30 MEZ (schon der 1.11.)
|
||||
assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-30T22:30:00.000Z'), preis: '30 €' }).values.frist, '30.10.2026')
|
||||
assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.2026')
|
||||
assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError)
|
||||
assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError)
|
||||
assert.throws(() => k27Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', preis: '30 €' }), TypeError)
|
||||
assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-30T22:30:00.000Z'), preis: '30 €' }).values.frist, '30.10.2026')
|
||||
assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.2026')
|
||||
assert.throws(() => k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError)
|
||||
assert.throws(() => k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError)
|
||||
assert.throws(() => k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', preis: '30 €' }), TypeError)
|
||||
})
|
||||
|
||||
test('K27: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => {
|
||||
const nachricht = BEISPIELE.K27()
|
||||
assert.equal(nachricht.template, 'K27')
|
||||
test('K30: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => {
|
||||
const nachricht = BEISPIELE.K30()
|
||||
assert.equal(nachricht.template, 'K30')
|
||||
assert.equal(nachricht.values.loeschdatum, '04.11.2026')
|
||||
assert.equal(nachricht.values.preis, '30 €')
|
||||
const mail = render(nachricht)
|
||||
@@ -271,15 +271,15 @@ test('K27: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalt
|
||||
assert.match(mail.text, /Projekt: Praxis Muster/)
|
||||
})
|
||||
|
||||
test('K26 und K27 gehen mit Kopie ans Team', async () => {
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())).sent, true)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, true)
|
||||
test('K29 und K30 gehen mit Kopie ans Team', async () => {
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K29 beendet']())).sent, true)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K30())).sent, true)
|
||||
for (const mail of sink.sent) assert.ok(mail.bcc?.length > 0)
|
||||
})
|
||||
|
||||
test('ohne E-Mail-Adresse: nichts senden, nichts werfen', async () => {
|
||||
for (const kunde of [{ name: 'Silke' }, { name: 'Silke', email: '' }, { email: ' ' }, { email: null }, null, undefined]) {
|
||||
assert.deepEqual(await sendeZeitenMail(kunde, BEISPIELE.K27()), { sent: false, reason: 'keine_adresse' }, JSON.stringify(kunde))
|
||||
assert.deepEqual(await sendeZeitenMail(kunde, BEISPIELE.K30()), { sent: false, reason: 'keine_adresse' }, JSON.stringify(kunde))
|
||||
}
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
@@ -290,13 +290,13 @@ test('Versandfehler werden gemeldet, nie geworfen; der Grund nennt keine Adresse
|
||||
throw new Error('550 Recipient silke@example.com rejected for Silke Müller')
|
||||
},
|
||||
})
|
||||
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE.K27())
|
||||
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE.K30())
|
||||
assert.equal(ergebnis.sent, false)
|
||||
assert.equal(ergebnis.reason, 'fehler')
|
||||
assert.doesNotMatch(JSON.stringify(ergebnis), /silke|Müller/i)
|
||||
// auch ohne SMTP-Konfiguration (sendCustomerMail wirft) und mit unbrauchbarer Nachricht
|
||||
setMailTransportForTests(null)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, false)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K30())).sent, false)
|
||||
setMailTransportForTests(sink)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, undefined)).sent, false)
|
||||
assert.equal((await sendeZeitenMail(KUNDE, { template: 'K99' })).sent, false)
|
||||
@@ -309,19 +309,19 @@ test('Log enthält weder Adresse noch Namen', async () => {
|
||||
throw new Error('550 Recipient silke@example.com rejected for Silke Müller (Praxis Muster)')
|
||||
},
|
||||
})
|
||||
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())
|
||||
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE['K29 beendet']())
|
||||
assert.equal(ergebnis.sent, false)
|
||||
const text = logText()
|
||||
assert.match(text, /K26/, 'das Log nennt die Vorlage')
|
||||
assert.match(text, /K29/, 'das Log nennt die Vorlage')
|
||||
assert.ok(text.length > 0)
|
||||
assert.doesNotMatch(text, /silke|example\.com|Müller/i, text)
|
||||
// auch der Fall ohne Adresse loggt höchstens ohne Namen
|
||||
await sendeZeitenMail({ name: 'Silke Müller' }, BEISPIELE.K27())
|
||||
await sendeZeitenMail({ name: 'Silke Müller' }, BEISPIELE.K30())
|
||||
assert.doesNotMatch(logText(), /silke|Müller/i)
|
||||
})
|
||||
|
||||
test('Erfolg schreibt nichts ins Log', async () => {
|
||||
await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']())
|
||||
await sendeZeitenMail(KUNDE, BEISPIELE['K27 angestossen']())
|
||||
assert.equal(logText(), '')
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const { createDocument, updateDocument, getDocument } = await import('../server/
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
|
||||
const NAMES = ['oeffnungszeiten', 'zeitenErinnerungen']
|
||||
const FEATURE_KEYS = ['unlockedBy', 'aboEndeAt', 'einbau', 'behaltenSessionId', 'k24At']
|
||||
const FEATURE_KEYS = ['unlockedBy', 'aboEndeAt', 'einbau', 'behaltenSessionId', 'einschaltMailAt']
|
||||
const keysOf = (name) => ZEITEN_COLLECTIONS[name].attributes.map((a) => a.key)
|
||||
|
||||
/**
|
||||
@@ -120,13 +120,13 @@ test('zeitenErinnerungen: featureId, datum, createdAt', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('portalFeatures bekommt unlockedBy(8), aboEndeAt, einbau(12), behaltenSessionId(128), k24At', () => {
|
||||
test('portalFeatures bekommt unlockedBy(8), aboEndeAt, einbau(12), behaltenSessionId(128), einschaltMailAt', () => {
|
||||
assert.deepEqual(ZEITEN_FEATURE_ATTRIBUTES, [
|
||||
{ key: 'unlockedBy', type: 'string', size: 8, required: false },
|
||||
{ key: 'aboEndeAt', type: 'datetime', required: false },
|
||||
{ key: 'einbau', type: 'string', size: 12, required: false },
|
||||
{ key: 'behaltenSessionId', type: 'string', size: 128, required: false },
|
||||
{ key: 'k24At', type: 'datetime', required: false },
|
||||
{ key: 'einschaltMailAt', type: 'datetime', required: false },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ test('main legt auf leerer Datenbank 2 Collections, alle Attribute und Indizes a
|
||||
assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith(`${featuresId}.`)), FEATURE_KEYS.map((key) => `${featuresId}.${key}`))
|
||||
const features = client.state.get(featuresId).attrs
|
||||
assert.deepEqual(features.get('unlockedBy'), { key: 'unlockedBy', type: 'string', size: 8, required: false })
|
||||
assert.deepEqual(features.get('k24At'), { key: 'k24At', type: 'datetime', required: false })
|
||||
assert.deepEqual(features.get('einschaltMailAt'), { key: 'einschaltMailAt', type: 'datetime', required: false })
|
||||
})
|
||||
|
||||
test('main: zweiter Lauf legt nichts mehr an', async () => {
|
||||
@@ -232,7 +232,7 @@ test('main legt nur fehlende Attribute und Indizes einer vorhandenen Collection
|
||||
)
|
||||
assert.deepEqual(
|
||||
client.calls.attrs.filter((k) => k.startsWith(`${featuresId}.`)),
|
||||
[`${featuresId}.aboEndeAt`, `${featuresId}.behaltenSessionId`, `${featuresId}.k24At`],
|
||||
[`${featuresId}.aboEndeAt`, `${featuresId}.behaltenSessionId`, `${featuresId}.einschaltMailAt`],
|
||||
)
|
||||
assert.equal(client.calls.collections.length, 1)
|
||||
})
|
||||
@@ -256,9 +256,9 @@ test('--check meldet bei vollständigem Stand nichts und ein einzelnes fehlendes
|
||||
const client = fakeClient()
|
||||
await main({ client, log: quiet })
|
||||
assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] })
|
||||
client.state.get(featuresId).attrs.delete('k24At')
|
||||
client.state.get(featuresId).attrs.delete('einschaltMailAt')
|
||||
const result = await main({ check: true, client, log: quiet })
|
||||
assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${featuresId}.k24At`])
|
||||
assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${featuresId}.einschaltMailAt`])
|
||||
assert.deepEqual(result.missingCollections, [])
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const aw = await startAppwriteMock({
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
})
|
||||
@@ -123,7 +123,7 @@ afterEach(() => {
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Aktivieren
|
||||
|
||||
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', async () => {
|
||||
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K27', async () => {
|
||||
const res = await aktion('activate', { note: 'Pilot' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1'])
|
||||
@@ -142,7 +142,7 @@ test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24',
|
||||
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.ok(doc.ticketId)
|
||||
assert.ok(!doc.k24At, 'keine K24 an den Kunden')
|
||||
assert.ok(!doc.einschaltMailAt, 'keine K27 an den Kunden')
|
||||
assert.equal(aw.docs('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner')
|
||||
assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team')
|
||||
|
||||
@@ -202,7 +202,7 @@ test('Team „Freischalten (Abnahme)“ der Zeiten: ohne Zeiten 409 (bleibt setu
|
||||
})
|
||||
|
||||
// Review N8 / I1: „Ticket nachlegen“ lief für Abo und Team in den Zahlungsweg und scheiterte immer mit 409 „keine Zahlung vermerkt“
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 409, Neubau und Einbau-Ticket kommen über das Nachholen, keine K24', async () => {
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 409, Neubau und Einbau-Ticket kommen über das Nachholen, keine K27', async () => {
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'team', einbau: '' }))
|
||||
const res = await aktion('ticket')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
@@ -213,7 +213,7 @@ test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 40
|
||||
assert.equal(rebuilds.length, 1)
|
||||
assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false)
|
||||
assert.match(doc.adminNote, / Andrej: ticket$/)
|
||||
assert.equal(sink.sent.length, 0, 'unlockedBy team: keine K24')
|
||||
assert.equal(sink.sent.length, 0, 'unlockedBy team: keine K27')
|
||||
|
||||
// zweiter Klick: das Ticket ist da, nichts entsteht neu
|
||||
assert.equal((await aktion('ticket')).status, 200)
|
||||
@@ -221,14 +221,14 @@ test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 40
|
||||
assert.equal(rebuilds.length, 1)
|
||||
})
|
||||
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Abo, setup, ohne Ticket): Ticket und K24 über das Nachholen, nicht über den Zahlungsweg', async () => {
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Abo, setup, ohne Ticket): Ticket und K27 über das Nachholen, nicht über den Zahlungsweg', async () => {
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }))
|
||||
const res = await aktion('ticket')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.ok(zeitenDoc().ticketId)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(sink.sent.length, 1, 'K24 an den Kunden (unlockedBy abo)')
|
||||
assert.ok(zeitenDoc().k24At)
|
||||
assert.equal(sink.sent.length, 1, 'K27 an den Kunden (unlockedBy abo)')
|
||||
assert.ok(zeitenDoc().einschaltMailAt)
|
||||
})
|
||||
|
||||
test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
|
||||
},
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
featureConsents: ['acceptedAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
|
||||
Reference in new issue
Block a user