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

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

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

No files matched your search

+2 -2
View File
@@ -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>`). 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 (`&auml;`, `&#8211;`), 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“. 2. In jeden Platzhalter einsetzen. Das Ersatz-HTML ist eine schlichte Wochentabelle als reines ASCII mit Entities (`&auml;`, `&#8211;`), 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). 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`) ## 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 ## 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. - **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. - **Abschalten durch das Team** (Grund `team`): keine automatische Löschung.
## 11. Häufige Fehlerbilder ## 11. Häufige Fehlerbilder
+3 -3
View File
@@ -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. - 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. - 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). - „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“. 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 ## 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 ## Beim Übernehmen
1. Vorlagen K02 und K03 ändern (Zelle des Blocks, Kopfkommentar). 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. 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. 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.
+3 -3
View File
@@ -60,13 +60,13 @@ Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block
### ZQ6: Mails zur Funktion: Erinnerung vor Feiertagen und Zustandsmails ### 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. **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) ### 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` - 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**. 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). 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) (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 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. 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. 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. 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. 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). 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.) 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) ## 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`). 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. 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`). 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`). 12. **Uploads:** die Fassungen (Leistungsbeschreibung, Datenschutzerklärung) in WOMS „Rechtliches“ hochladen (Karte `leistungsbeschreibung-zeiten`).
## E. Offene Code-Punkte (Checkliste für die letzte Fixrunde) ## 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)** **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] 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. - [ ] 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“). - [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. - [ ] 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 # Ö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. > **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. **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 # Ö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 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). 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`. WOMS: eigener Worktree auf `feature/terminplaner-tp2` des WOMS-Repos (6f03712), Branch `feature/oeffnungszeiten`.
+1 -1
View File
@@ -258,7 +258,7 @@ function pickZeitenProject(projects) {
|| projects[0] || 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). // 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) { function zeitenFrist(aboEndeAt) {
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
+1 -1
View File
@@ -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 * 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) { export function behaltenFrist(aboEndeAt) {
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
+1 -1
View File
@@ -2,7 +2,7 @@
/** /**
* Oeffnungszeiten live: legt die Collections oeffnungszeiten und zeitenErinnerungen mit Attributen und Indizes in * 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, * 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 * 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 * 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 * (fehlt sie, bricht das Skript vor jeder Aenderung ab). Vor dem Einschalten (ZEITEN_ENABLED) und vor dem
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K24 · Zeiten eingeschaltet K27 · Zeiten eingeschaltet
Betreff: Öffnungszeiten live ist eingeschaltet Betreff: Öffnungszeiten live ist eingeschaltet
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K25 · Zeiten Feiertage prüfen K28 · Zeiten Feiertage prüfen
Betreff: Feiertage stehen an – stimmen Deine Zeiten? Betreff: Feiertage stehen an – stimmen Deine Zeiten?
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · ohne BCC Empfänger: Kunde (E-Mail des Kundenkontos) · ohne BCC
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K26 · Zeiten Abo beendet K29 · Zeiten Abo beendet
Betreff: Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten? Betreff: Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- <!--
K27 · Zeiten abgeschaltet K30 · Zeiten abgeschaltet
Betreff: Öffnungszeiten live ist abgeschaltet Betreff: Öffnungszeiten live ist abgeschaltet
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
+1 -1
View File
@@ -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' * 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). * 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) * 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 * und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` nicht
+1 -1
View File
@@ -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: // 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), // 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, // 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) => { router.post('/zeiten/einschalten', requireSession, async (req, res) => {
const now = new Date() const now = new Date()
try { try {
+3 -3
View File
@@ -828,7 +828,7 @@ async function ticketNachZahlung(doc, note) {
/** /**
* Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt * 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 -> * 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). * 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). * "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, * (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 * 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); * (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
* (c) Ticket vorhanden: nichts, created false. * (c) Ticket vorhanden: nichts, created false.
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten * 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 * 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 * 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. * 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. * Dynamischer Import: zeiten/* importiert dieses Modul.
+41 -41
View File
@@ -7,7 +7,7 @@ import { LOESCHFRIST_TAGE } from '../termine/frist.js'
import { pruefeNow, zeitMs } from '../termine/util.js' import { pruefeNow, zeitMs } from '../termine/util.js'
import { heute, plusTage, zuUtc } from '../termine/zeit.js' import { heute, plusTage, zuUtc } from '../termine/zeit.js'
import { nachAbschaltung } from './abschalten.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 { stosseNeubauAn } from './neubau.js'
import { ladeZeiten, loescheZeiten } from './speicher.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 * 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): * 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) * aboEndeAt + 30 Tage)
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', dann das Ersatz-Ticket des Teams * 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 * 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), * 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: * 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 * 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). * 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, * 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. * 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 * 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 * 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 * 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): * 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 * - 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 * - 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 * 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. * 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 * - 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 K27 (nur ein Marker, der nicht vor disabledAt liegt: k27Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es * 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 * 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 * nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Die K30 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 * 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). K24 "wieder" geht nur an Kunden, die K26 oder K27 bekommen haben (Marker, vor dem Leeren gelesen) * 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`). * 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 * - 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. * (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): * 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) { function entscheide(doc, lage, now) {
const tag = heute(now) const tag = heute(now)
if (istAbgeschaltet(doc)) { if (istAbgeschaltet(doc)) {
if (lage.laeuft) return 'zurueck' if (lage.laeuft) return 'zurueck'
if (tag >= loeschTag(doc)) return 'loeschen' 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 (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null
if (!doc.aboEndeAt) return 'ende' if (!doc.aboEndeAt) return 'ende'
if (tag >= fristTag(doc)) return 'abschalten' 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) * 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 K27 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte * 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 K26-Marker stehen und K27 samt Ersatz-Ticket liefen nie). * (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). */ /** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
async function datenDa(featureId) { async function datenDa(featureId) {
@@ -131,7 +131,7 @@ async function loesche(frisch, ctx) {
...stateFields('off'), ...stateFields('off'),
ticketId: '', ticketId: '',
einbau: '', 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) 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, aboEndeAt: null,
behaltenSessionId: '', behaltenSessionId: '',
@@ -157,17 +157,17 @@ async function unterSperre(doc, ctx) {
case 'ende': { case 'ende': {
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null }) const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
erg.aboEnde += 1 erg.aboEnde += 1
return { aktion: 'k26', doc: neu, lage } return { aktion: 'k29', doc: neu, lage }
} }
case 'k26': case 'k29':
case 'k27': case 'k30':
return { aktion, doc: frisch, lage } return { aktion, doc: frisch, lage }
case 'abschalten': { case 'abschalten': {
const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) }) 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 if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
erg.abgeschaltet += 1 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) // 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: 'k27', doc: aus, lage } return { aktion: 'k30', doc: aus, lage }
} }
case 'loeschen': case 'loeschen':
await loesche(frisch, ctx) await loesche(frisch, ctx)
@@ -221,9 +221,9 @@ const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || '
const preis = () => config.features.zeiten.priceLabel 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). * 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 K27: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem * `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 k27Gesendet nicht und die K27 ginge jeden Tag erneut raus). * `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) { async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
const customer = await customerOf(doc.customerId) 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 }) 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) { async function sendeWiederMail(doc, einbau, ctx) {
const customer = await customerOf(doc.customerId) const customer = await customerOf(doc.customerId)
const projekt = projektName(await ladeProjekt(doc.projectId)) 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 if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 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, * 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 K27 mit Marker; nach der Rückkehr aus 'disabled' der Neubau * nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K30 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. * 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) { async function danach({ aktion, doc, lage, hatteMail }, ctx) {
switch (aktion) { switch (aktion) {
case 'k26': case 'k29':
await sendeMitMarker(doc, ({ kunde, projekt }) => k26Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx) await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
return return
case 'k27': case 'k30':
// Erst das Ersatz-Ticket, dann K27 mit Marker. Scheitert das Ticket, gibt es weder K27 noch Marker: der nächste Lauf wiederholt beides // 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 K27 ging noch nicht raus und geht so höchstens einmal raus) // (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') { if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') {
ctx.erg.fehler += 1 ctx.erg.fehler += 1
return 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 return
case 'zurueck': { case 'zurueck': {
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) 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 }>} * @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; * 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 * 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 * zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K27 "wieder", K29 und
* K27; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets * K30; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
*/ */
export async function gleicheZeitenAboAb(now) { export async function gleicheZeitenAboAb(now) {
pruefeNow(now, 'gleicheZeitenAboAb') pruefeNow(now, 'gleicheZeitenAboAb')
+14 -14
View File
@@ -5,7 +5,7 @@ import { featureConfig, ticketTexte } from '../featureRegistry.js'
import { withLocks } from '../keyedLock.js' import { withLocks } from '../keyedLock.js'
import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js' import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js'
import { createFeatureTicket } from '../ticketAdmin.js' import { createFeatureTicket } from '../ticketAdmin.js'
import { k24Mail, sendeZeitenMail } from './mails.js' import { k27Mail, sendeZeitenMail } from './mails.js'
import { stosseNeubauAn } from './neubau.js' import { stosseNeubauAn } from './neubau.js'
import { ladeZeiten } from './speicher.js' import { ladeZeiten } from './speicher.js'
import { hauptplan, statusZu } from './zeitlogik.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 * 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, * Bestellen und Einschalten ausschliessen): Abo frisch pruefen, Zustand pruefen (idempotent), Nachweis speichern,
* Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K24). * 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, K24, dann k24At. * 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. * 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 * 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). * 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, * @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null,
* nachweis?: (() => Promise<unknown>)|null, now?: Date }} p * 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 '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 K24; staff ergibt die Notiz am Dokument. * 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. * 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 * @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) * @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 } return { doc: gespeichert, changed: true }
}) })
// 5 und 6 nach der Sperre: Neubau, Ticket, K24 // 5 und 6 nach der Sperre: Neubau, Ticket, K27
try { try {
return { doc: await holeZeitenNach(doc, { now }), changed } return { doc: await holeZeitenNach(doc, { now }), changed }
} catch (err) { } catch (err) {
@@ -112,11 +112,11 @@ export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', s
* vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung): * vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung):
* 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau * 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau
* 2. ticketId leer => Einbau-Ticket aus dem Register (Text mit dem frischen einbau), ticketId * 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); * 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 k24At leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt k24At (es gibt nichts zu senden). * 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. * 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 * 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 {{ $id: string }} doc portalFeatures-Dokument der Zeiten (nur $id zaehlt)
* @param {{ now?: Date }} [options] * @param {{ now?: Date }} [options]
* @returns {Promise<object>} das aktuelle Dokument * @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 () => { return withLocks([`zeiten-nachholen:${doc.$id}`], async () => {
let frisch = await getDocument(config.collections.portalFeatures, doc.$id) let frisch = await getDocument(config.collections.portalFeatures, doc.$id)
if (frisch.state !== 'setup' || unlockedByVon(frisch) === 'kauf') return frisch 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) // 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) const feature = featureConfig(KEY)
// das Projekt frisch: der Neubau und der Text des Tickets brauchen den Stand nach dem Speichern des Zustands // 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) 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 }) frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId })
} }
if (mitK24 && !frisch.k24At) { if (mitK27 && !frisch.einschaltMailAt) {
const nachricht = k24Mail({ const nachricht = k27Mail({
kunde: customer, kunde: customer,
projekt: project?.projectName || project?.subdomain || '', projekt: project?.projectName || project?.subdomain || '',
einbau: frisch.einbau, einbau: frisch.einbau,
@@ -155,7 +155,7 @@ export async function holeZeitenNach(doc, { now = new Date() } = {}) {
}) })
const { sent, reason } = await sendeZeitenMail(customer, nachricht) const { sent, reason } = await sendeZeitenMail(customer, nachricht)
if (sent || reason === 'keine_adresse') { 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 return frisch
+5 -5
View File
@@ -4,7 +4,7 @@ import { createDocument, deleteDocument, getDocument, listAllDocuments, Query }
import { customerOf } from '../siteFeatures.js' import { customerOf } from '../siteFeatures.js'
import { heute, plusTage } from '../termine/zeit.js' import { heute, plusTage } from '../termine/zeit.js'
import { pruefeNow } from '../termine/util.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 { zeitenFuerKunde, zeitenModus } from './schalter.js'
import { ladeZeiten } from './speicher.js' import { ladeZeiten } from './speicher.js'
import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.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). * 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 * 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 * (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`. * 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, * 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 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) { async function bearbeiteDokument(doc, ctx) {
let kunde // undefined = noch nicht gelesen let kunde // undefined = noch nicht gelesen
const holeKunde = async () => { const holeKunde = async () => {
@@ -111,7 +111,7 @@ async function bearbeiteDokument(doc, ctx) {
if (neu.length === 0) return undefined // alles schon erinnert if (neu.length === 0) return undefined // alles schon erinnert
const projekt = await projektName(doc.projectId) 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 if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung
return undefined 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) * @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 }>} * @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); * 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 * gescheiterte Mails
*/ */
export async function erinnereFeiertage(now) { export async function erinnereFeiertage(now) {
+20 -20
View File
@@ -3,11 +3,11 @@ import { sendCustomerMail } from '../mailer.js'
import { datumKurz, wochentag } from '../termine/zeit.js' import { datumKurz, wochentag } from '../termine/zeit.js'
/** /**
* Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K24 bis K27): * Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K27 bis K30):
* K24 eingeschaltet (Gratisweg im Wartungs-Abo; mit Block `wieder` nach der Rückkehr des Abos) * K27 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) * K28 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) * K29 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 * 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 * 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. * 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 * @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); * 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. * 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. * 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. * 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 nr = String(ticketNr ?? '').trim().replace(/^#/, '')
const keinNeubau = wieder === true && einbau === null const keinNeubau = wieder === true && einbau === null
const angestossen = einbau === 'angestossen' const angestossen = einbau === 'angestossen'
const manuell = !keinNeubau && !angestossen const manuell = !keinNeubau && !angestossen
return { return {
template: 'K24', template: 'K27',
values: { values: {
kunde_name: kundenName(kunde), kunde_name: kundenName(kunde),
projekt, 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 * @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). * 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 : [] const liste = Array.isArray(tage) ? tage : []
return { return {
template: 'K25', template: 'K28',
values: { values: {
kunde_name: kundenName(kunde), kunde_name: kundenName(kunde),
projekt, 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 * @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 * 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). * 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 { return {
template: 'K26', template: 'K29',
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() }, 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' }, 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 * @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. * 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 { return {
template: 'K27', template: 'K30',
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() }, 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 {{ email?: string, name?: string } | null | undefined} customer Kundendokument
* @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k24Mail ... k27Mail * @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k27Mail ... k30Mail
* @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K25) * @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K28)
* @returns {Promise<{ sent: boolean, reason?: 'keine_adresse' | 'fehler' }>} * @returns {Promise<{ sent: boolean, reason?: 'keine_adresse' | 'fehler' }>}
*/ */
export async function sendeZeitenMail(customer, nachricht, { bccTeam = true } = {}) { export async function sendeZeitenMail(customer, nachricht, { bccTeam = true } = {}) {
+2 -2
View File
@@ -53,14 +53,14 @@ export const ZEITEN_COLLECTIONS = {
* - aboEndeAt: Zeitpunkt, an dem der Abgleich das Ende des Wartungs-Abos erkannt hat * - aboEndeAt: Zeitpunkt, an dem der Abgleich das Ende des Wartungs-Abos erkannt hat
* - einbau: 'angestossen' | 'manuell' (Ergebnis des Neubau-Anstosses) * - einbau: 'angestossen' | 'manuell' (Ergebnis des Neubau-Anstosses)
* - behaltenSessionId: offene Behalten-Zahlung (Stripe-Sitzung) * - behaltenSessionId: offene Behalten-Zahlung (Stripe-Sitzung)
* - k24At: Marker der Mail K24 (Selbstheilung) * - einschaltMailAt: Marker der Mail K27 (Selbstheilung)
*/ */
export const ZEITEN_FEATURE_ATTRIBUTES = [ export const ZEITEN_FEATURE_ATTRIBUTES = [
str('unlockedBy', 8), str('unlockedBy', 8),
dt('aboEndeAt'), dt('aboEndeAt'),
str('einbau', 12), str('einbau', 12),
str('behaltenSessionId', 128), str('behaltenSessionId', 128),
dt('k24At'), dt('einschaltMailAt'),
] ]
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */ /** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
+8 -7
View File
@@ -6,9 +6,10 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url) 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; // 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 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 GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) 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) 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)', () => { 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', 'K26'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id) 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', () => { 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\./) 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 // 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, K24–K27: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => { test('K22, K23, K27–K30: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23', 'K24', 'K25', 'K26', 'K27']) { for (const id of ['K22', 'K23', 'K27', 'K28', 'K29', 'K30']) {
const tpl = loadTemplate(id) const tpl = loadTemplate(id)
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`) 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)) const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
+76 -76
View File
@@ -7,13 +7,13 @@ import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.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 // 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), // 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 (K24 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen. // 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. // 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) // Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null)
const DATETIMES = { const DATETIMES = {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
} }
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { 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. * 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 } = {}) { 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]}`, siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`,
ticketId: `t_${nr}`, ticketId: `t_${nr}`,
einbau: 'angestossen', einbau: 'angestossen',
k24At: ZUVOR, einschaltMailAt: ZUVOR,
activatedAt: ZUVOR, activatedAt: ZUVOR,
...over, ...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_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over })
const K26 = /^Dein Wartungs-Abo ist beendet/ const K29 = /^Dein Wartungs-Abo ist beendet/
const K27 = 'Öffnungszeiten live ist abgeschaltet' const K30 = 'Öffnungszeiten live ist abgeschaltet'
const K24 = 'Öffnungszeiten live ist eingeschaltet' const K27 = 'Öffnungszeiten live ist eingeschaltet'
const mails = () => sink.sent const mails = () => sink.sent
const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff)) const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff))
const tickets = () => [...aw.docs('workorders').values()] 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) 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() { async function bisAbgeschaltet() {
await legeFunktion(1) await legeFunktion(1)
abgelaufen() abgelaufen()
@@ -212,9 +212,9 @@ async function bisAbgeschaltet() {
assert.equal(funktion().state, 'disabled') 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) await legeFunktion(1)
abgelaufen() 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.state, 'active', 'die Funktion läuft bis zur Frist weiter')
assert.equal(doc.enabled, true) assert.equal(doc.enabled, true)
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung') 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(doc.unlockedBy, 'abo')
assert.equal(mails().length, 1) assert.equal(mails().length, 1)
const mail = mails()[0] const mail = mails()[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) 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, /Silke Müller/)
assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet') 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') 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) 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 = [ const varianten = [
['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/], ['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) await legeFunktion(1)
abo() abo()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name) 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.match(mails()[0].text, passt, name)
assert.doesNotMatch(mails()[0].text, passtNicht, name) assert.doesNotMatch(mails()[0].text, passtNicht, name)
assert.equal(funktion().state, 'active', `${name}: die Frist läuft`) 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) await legeFunktion(1)
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' }) aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })
abgelaufen() 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 })) 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) await legeFunktion(1)
abgelaufen() abgelaufen()
const roh = sink.sendMail const roh = sink.sendMail
@@ -305,16 +305,16 @@ test('K26 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf
sink.sendMail = roh sink.sendMail = roh
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) 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.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.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.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 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) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) 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(doc.disabledReason, 'abo_ende')
assert.equal(ms(doc.disabledAt), tag30.getTime()) assert.equal(ms(doc.disabledAt), tag30.getTime())
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen') 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/) assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/)
// K27: Datum der Löschung = Abschalten + 30 Tage // K30: Datum der Löschung = Abschalten + 30 Tage
assert.equal(mailsMit(K27).length, 1) assert.equal(mailsMit(K30).length, 1)
const mail = mailsMit(K27)[0] const mail = mailsMit(K30)[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) 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, /Eingaben gespeichert bis: 04\.12\.2026/)
assert.match(mail.text, /30 €/) 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.) // 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.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
assert.equal(funktion().state, 'disabled') 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 () => { 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.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
assert.equal(funktion().state, 'active') assert.equal(funktion().state, 'active')
assert.equal(ersatzTickets().length, 0) 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) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) 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.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled') 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) assert.equal(ersatzTickets().length, 1)
sink.sendMail = roh sink.sendMail = roh
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K27).length, 1) assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute') 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.equal(ersatzTickets().length, 1)
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 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) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) 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.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled') assert.equal(funktion().state, 'disabled')
assert.equal(ersatzTickets().length, 0) 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.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 K27-Marker') 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.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(ersatzTickets().length, 1) assert.equal(ersatzTickets().length, 1)
assert.equal(mailsMit(K27).length, 1) assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung') 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 K27') assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K30')
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
assert.equal(ersatzTickets().length, 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) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) 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)) await laufe(nach(30))
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 })) 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.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) 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 () => { 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 K26 // 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 }) await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
abgelaufen() abgelaufen()
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) 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.equal(ersatzTickets().length, 1)
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt)) assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) 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) 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) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) 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.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 })) 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 // ---------------------------------------------------------------- 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() await bisAbgeschaltet()
mails().length = 0 mails().length = 0
laeuft() 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 // Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }]) assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }])
// K24 mit dem Block wieder und dem Neubau-Text // K27 mit dem Block wieder und dem Neubau-Text
assert.equal(mailsMit(K24).length, 1) assert.equal(mailsMit(K27).length, 1)
const mail = mailsMit(K24)[0] const mail = mailsMit(K27)[0]
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) 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, /Dein Wartungs-Abo läuft wieder/)
assert.match(mail.text, /nach dem nächsten Veröffentlichen/) 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) 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 }) await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null })
abgelaufen() abgelaufen()
await laufe(JETZT) 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/) 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 [ for (const [name, over] of [
['aktiv', {}], ['aktiv', {}],
['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }], ['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(doc.einbau, 'angestossen', name)
assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`) assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`)
assert.equal(mailsMit(K24).length, 1, name) assert.equal(mailsMit(K27).length, 1, name)
const text = mailsMit(K24)[0].text const text = mailsMit(K27)[0].text
assert.match(text, /Dein Wartungs-Abo läuft wieder/, name) 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, /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) 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' }) await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' })
laeuft() laeuft()
assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 })) 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))) const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30)))
assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
assert.equal(funktion().state, 'active') assert.equal(funktion().state, 'active')
assert.equal(mailsMit(K27).length, 0) assert.equal(mailsMit(K30).length, 0)
assert.equal(mailsMit(K24).length, 1) assert.equal(mailsMit(K27).length, 1)
assert.equal(ersatzTickets().length, 0) 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.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 }))
assert.equal(funktion().state, 'off') assert.equal(funktion().state, 'off')
assert.equal(rebuilds.length, 0) assert.equal(rebuilds.length, 0)
assert.equal(mailsMit(K24).length, 0) assert.equal(mailsMit(K27).length, 0)
}) })
// ---------------------------------------------------------------- Schritt 4: Löschung // ---------------------------------------------------------------- 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.state, 'off')
assert.equal(doc.enabled, false) assert.equal(doc.enabled, false)
assert.deepEqual( 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], ['', '', 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.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(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
assert.equal(aw.docs('oeffnungszeiten').size, 0) 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('m1'), false, 'Erinnerungsmerker der Funktion gelöscht')
assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben') 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 // aus ist aus: der nächste Lauf liest das Dokument nicht mehr
assert.deepEqual(await laufe(nach(61)), ergebnis()) 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 bisAbgeschaltet()
await laufe(nach(60)) await laufe(nach(60))
assert.equal(funktion().state, 'off') assert.equal(funktion().state, 'off')
@@ -639,7 +639,7 @@ test('nach der Löschung: ticketId, einbau, k24At, ersatzHash leer; Neubestellun
mails().length = 0 mails().length = 0
rebuilds.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() laeuft()
const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) }) const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) })
assert.equal(changed, true) 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.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket')
assert.equal(tickets().length, ticketsVorher + 1) assert.equal(tickets().length, ticketsVorher + 1)
assert.equal(doc.einbau, 'angestossen') assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.k24At, 'K24 neu gesendet') assert.ok(doc.einschaltMailAt, 'K27 neu gesendet')
assert.equal(mailsMit(K24).length, 1) assert.equal(mailsMit(K27).length, 1)
assert.equal(rebuilds.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 vorher = [stand(1), stand(2)]
const erg = await laufe(nach(30)) 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([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.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com'])
assert.equal(ersatzTickets().length, 0) assert.equal(ersatzTickets().length, 0)
assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht') 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(1)
await legeFunktion(2, {}, { kunde: 'c2' }) await legeFunktion(2, {}, { kunde: 'c2' })
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) 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 () => { test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => {
await legeFunktion(1) await legeFunktion(1)
abgelaufen() abgelaufen()
await laufe(JETZT) // K26 await laufe(JETZT) // K29
await laufe(nach(30)) // K27 await laufe(nach(30)) // K30
laeuft() laeuft()
await laufe(nach(45)) // Neubau und K24 await laufe(nach(45)) // Neubau und K27
assert.deepEqual( assert.deepEqual(
sperrenBeimVersand.map((s) => [s.subject === K27 ? 'K27' : s.subject === K24 ? 'K24' : 'K26', s.admin]), sperrenBeimVersand.map((s) => [s.subject === K30 ? 'K30' : s.subject === K27 ? 'K27' : 'K29', s.admin]),
[['K26', true], ['K27', true], ['K24', true]] [['K29', true], ['K30', true], ['K27', true]]
) )
assert.equal(rebuilds[0].adminFrei, 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() aw.resetListCalls()
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 })) 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(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.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(1).aboEndeAt && funktion(2).aboEndeAt)
assert.ok(!funktion(3).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, '']) 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 }) await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
abgelaufen() abgelaufen()
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K27).length, 1) assert.equal(mailsMit(K30).length, 1)
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage') assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage')
assert.equal(ersatzTickets().length, 1) assert.equal(ersatzTickets().length, 1)
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1) assert.equal(mails().length, 1)
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.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 // 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 // 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. // 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) const ZEITEN_FELDER = ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)
+1 -1
View File
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'], featureConsents: ['acceptedAt'],
}, },
defaultLimit: 25, defaultLimit: 25,
+3 -3
View File
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'], featureConsents: ['acceptedAt'],
}, },
defaultLimit: 25, 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.doesNotMatch(ticket.details, /bitte von Hand einsetzen/)
assert.ok(ticket.details.includes(`data-site="${doc.siteKey}"`)) 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.equal(mailsMit(SUBJECT.K15).length, 1)
assert.deepEqual(mailsMit(SUBJECT.K15)[0].to.map((a) => a.address), ['k@example.invalid']) assert.deepEqual(mailsMit(SUBJECT.K15)[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.equal(mailsMit(SUBJECT.T16).length, 1)
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA) assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
assert.equal(mailsMit('Öffnungszeiten live ist eingeschaltet').length, 0) 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 () => { test('Zahlung, Neubau nicht möglich (kein Repo): einbau manuell, Ticket sagt „bitte von Hand einsetzen“', async () => {
+25 -25
View File
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'], featureConsents: ['acceptedAt'],
}, },
defaultLimit: 25, 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.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(doc.einbau, 'angestossen') assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.ticketId) assert.ok(doc.ticketId)
assert.ok(doc.k24At) assert.ok(doc.einschaltMailAt)
assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten') assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten')
// Nachweis: Fassung und Texte der Gratis-Zustimmung, ohne Stripe-Sitzung, mit dem handelnden Konto // 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 = [] const beimVersand = []
setMailTransportForTests({ setMailTransportForTests({
sent: sink.sent, sent: sink.sent,
async sendMail(mail) { 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) 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.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/)
assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`)) assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`))
assert.equal(zeitenDoc().ticketId, ticket.$id) 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.equal(sink.sent.length, 1)
assert.deepEqual(beimVersand, [{ k24At: null, tickets: 1 }]) assert.deepEqual(beimVersand, [{ einschaltMailAt: null, tickets: 1 }])
const mail = sink.sent[0] const mail = sink.sent[0]
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid']) assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid'])
assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/) assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/)
assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`)) assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`))
assert.doesNotMatch(mail.text, /nicht automatisch aktualisieren/) 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 aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) // kein Repo ⇒ manuell
const res = await einschalten(koerper()) const res = await einschalten(koerper())
assert.equal(res.status, 200, JSON.stringify(res.body)) 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 // ------------------------------------------------------------------------------------------- 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' }) aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
const erster = await einschalten(koerper()) const erster = await einschalten(koerper())
// Zustand ist gespeichert, das Ticket fehlt: der Kunde bekommt einen Hinweis zum erneuten Versuch, nicht den Rohtext // 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().state, 'setup')
assert.equal(zeitenDoc().unlockedBy, 'abo') assert.equal(zeitenDoc().unlockedBy, 'abo')
assert.ok(!zeitenDoc().ticketId) assert.ok(!zeitenDoc().ticketId)
assert.ok(!zeitenDoc().k24At) assert.ok(!zeitenDoc().einschaltMailAt)
assert.equal(tickets().length, 0) assert.equal(tickets().length, 0)
assert.equal(sink.sent.length, 0) assert.equal(sink.sent.length, 0)
assert.equal(nachweise().length, 1) 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(tickets().length, 1)
assert.equal(zeitenDoc().ticketId, tickets()[0].$id) assert.equal(zeitenDoc().ticketId, tickets()[0].$id)
assert.equal(sink.sent.length, 1) assert.equal(sink.sent.length, 1)
assert.ok(zeitenDoc().k24At) assert.ok(zeitenDoc().einschaltMailAt)
assert.equal(nachweise().length, 1, 'kein zweiter Nachweis') assert.equal(nachweise().length, 1, 'kein zweiter Nachweis')
assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau') assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau')
assert.equal(zweiter.body.einbau, 'angestossen') 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]) 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') } }) setMailTransportForTests({ sent: [], async sendMail() { throw new Error('SMTP down') } })
const erster = await einschalten(koerper()) const erster = await einschalten(koerper())
assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts') assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts')
assert.equal(tickets().length, 1) assert.equal(tickets().length, 1)
assert.ok(!zeitenDoc().k24At) assert.ok(!zeitenDoc().einschaltMailAt)
setMailTransportForTests(sink) setMailTransportForTests(sink)
assert.equal((await einschalten(koerper())).status, 200) assert.equal((await einschalten(koerper())).status, 200)
assert.equal(tickets().length, 1) assert.equal(tickets().length, 1)
assert.equal(sink.sent.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}`)) 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())]) 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([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]) 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 // --------------------------------------------------------------------------------------------- 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) aw.seed('subscriptions', hostingDoc)
const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT })
assert.equal(changed, true) 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(nachweise().length, 0)
assert.equal(tickets().length, 1) assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 0) assert.equal(sink.sent.length, 0)
assert.ok(!zeitenDoc().k24At) assert.ok(!zeitenDoc().einschaltMailAt)
assert.equal(rebuilds.length, 1) assert.equal(rebuilds.length, 1)
// ein zweiter Aufruf aendert nichts // ein zweiter Aufruf aendert nichts
const nochmal = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) 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]) 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' }) 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 }) const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
assert.equal(tickets().length, 1) assert.equal(tickets().length, 1)
assert.equal(doc.ticketId, tickets()[0].$id) 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.equal(sink.sent.length, 1)
assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau') assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau')
// noch einmal: nichts // 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]) 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) => { const seed = (id, felder) => {
projekt(id) projekt(id)
aw.seed('portalFeatures', { $id: `f_${id}_zeiten`, customerId: 'c1', projectId: id, featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', enabled: false, ...felder }) 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 }) const fertig = await holeZeitenNach(team, { now: JETZT })
assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0]) assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0])
assert.equal(fertig.einbau, 'angestossen') 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') 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('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' }) 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 }) const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
assert.ok(doc.k24At) assert.ok(doc.einschaltMailAt)
assert.equal(sink.sent.length, 0) 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.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
aw.failNext({ collection: 'customers', status: 500 }) aw.failNext({ collection: 'customers', status: 500 })
await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT })) await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT }))
assert.ok(!zeitenDoc().k24At) assert.ok(!zeitenDoc().einschaltMailAt)
}) })
// ----------------------------------------------------------------------------------------------------- Angebot // ----------------------------------------------------------------------------------------------------- Angebot
+3 -3
View File
@@ -8,13 +8,13 @@ import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.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 // 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 // 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. // 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. // Aufbau wie zeitenAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit als Parameter.
const DATETIMES = { const DATETIMES = {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
} }
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { 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, /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, /Praxis 1/, 'der Projektname steht in der Mail')
assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Knopf zum Editor') 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']) assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29'])
}) })
+39 -39
View File
@@ -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', // 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 // 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 // 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({ const aw = await startAppwriteMock({
schema: { schema: {
portalFeatures: schemaKeys('portalFeatures'), portalFeatures: schemaKeys('portalFeatures'),
@@ -18,7 +18,7 @@ const aw = await startAppwriteMock({
}, },
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
}, },
defaultLimit: 25, defaultLimit: 25,
}) })
@@ -39,13 +39,13 @@ after(() => setMailTransportForTests(null))
const Z = config.features.zeiten const Z = config.features.zeiten
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const K16_BETREFF = /bereit/ 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 feld = (id, name) => aw.docs('portalFeatures').get(id)[name]
const zustand = (id) => feld(id, 'state') const zustand = (id) => feld(id, 'state')
const tickets = () => [...aw.docs('workorders').values()] const tickets = () => [...aw.docs('workorders').values()]
const k16 = () => sink.sent.filter((m) => K16_BETREFF.test(m.subject)) 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') const logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
function tage(modi) { function tage(modi) {
@@ -69,7 +69,7 @@ const eingabe = (modi = WOCHE) => ({
let nr = 0 let nr = 0
/** /**
* Dokument der Zeiten im Zustand setup mit Projekt, Kunde und Ticket (Standard: geschlossen), optional mit gespeicherten Zeiten * 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 } = {}) { async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) {
nr += 1 nr += 1
@@ -87,7 +87,7 @@ async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null
siteKey: 'abcdefghijklmnop', siteKey: 'abcdefghijklmnop',
unlockedBy: 'abo', unlockedBy: 'abo',
einbau: 'angestossen', einbau: 'angestossen',
k24At: JETZT.toISOString(), einschaltMailAt: JETZT.toISOString(),
...(ticket ? { ticketId: `t_${projectId}` } : {}), ...(ticket ? { ticketId: `t_${projectId}` } : {}),
...over, ...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(Date.parse(feld(id, 'activatedAt')), JETZT.getTime())
assert.equal(k16().length, 1) assert.equal(k16().length, 1)
assert.deepEqual(k16()[0].to.map((a) => a.address), ['k@example.invalid']) 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) const nachher = await ladeZeiten(id)
assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/) 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 // ------------------------------------------------------------------------------------------- Selbstheilung
test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K24)', async () => { test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K27)', async () => {
const id = await einrichtung({ ticket: null, einbau: '', k24At: null }) const id = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
assert.equal(zustand(id), 'setup') assert.equal(zustand(id), 'setup')
assert.equal(tickets().length, 1) 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.match(tickets()[0].topic, /^Öffnungszeiten live einbauen – Projekt \d+$/)
assert.equal(feld(id, 'einbau'), 'angestossen') assert.equal(feld(id, 'einbau'), 'angestossen')
assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau') assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau')
assert.equal(k24().length, 1) assert.equal(k27().length, 1)
assert.ok(feld(id, 'k24At')) assert.ok(feld(id, 'einschaltMailAt'))
// kein zweites Ticket, kein zweiter Neubau, keine zweite Mail // kein zweites Ticket, kein zweiter Neubau, keine zweite Mail
await sf.runActivation({ now: JETZT }) 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 () => { test('Selbstheilung: unlockedBy abo ohne einschaltMailAt ⇒ der Job sendet die K27 genau einmal', async () => {
const id = await einrichtung({ ticket: 'Open', k24At: null }) const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.equal(k24().length, 1) assert.equal(k27().length, 1)
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k@example.invalid']) assert.deepEqual(k27()[0].to.map((a) => a.address), ['k@example.invalid'])
assert.equal(Date.parse(feld(id, 'k24At')), JETZT.getTime()) assert.equal(Date.parse(feld(id, 'einschaltMailAt')), JETZT.getTime())
assert.equal(tickets().length, 1, 'das Ticket war da: kein neues') assert.equal(tickets().length, 1, 'das Ticket war da: kein neues')
assert.deepEqual(rebuilds, []) assert.deepEqual(rebuilds, [])
assert.equal(zustand(id), 'setup') assert.equal(zustand(id), 'setup')
}) })
test('Selbstheilung: K24 nicht gesendet (Mail scheitert) ⇒ k24At bleibt leer, der nächste Lauf sendet nach', async () => { test('Selbstheilung: K27 nicht gesendet (Mail scheitert) ⇒ einschaltMailAt bleibt leer, der nächste Lauf sendet nach', async () => {
const id = await einrichtung({ ticket: 'Open', k24At: null }) const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null })
setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } }) setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } })
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.ok(!feld(id, 'k24At')) assert.ok(!feld(id, 'einschaltMailAt'))
setMailTransportForTests(sink) setMailTransportForTests(sink)
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.equal(k24().length, 1) assert.equal(k27().length, 1)
assert.ok(feld(id, 'k24At')) assert.ok(feld(id, 'einschaltMailAt'))
}) })
test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K24', async () => { 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: '', k24At: null, unlockedBy: 'kauf', unlockedByPurchase: true }) const kauf = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'kauf', unlockedByPurchase: true })
const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, k24At: null }) const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, einschaltMailAt: null })
const aus = await einrichtung({ ticket: null, state: 'off', k24At: null }) const aus = await einrichtung({ ticket: null, state: 'off', einschaltMailAt: null })
const team = await einrichtung({ ticket: null, einbau: '', k24At: null, unlockedBy: 'team' }) const team = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'team' })
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket') assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket')
assert.equal(tickets()[0].$id, feld(team, 'ticketId')) assert.equal(tickets()[0].$id, feld(team, 'ticketId'))
assert.equal(sink.sent.length, 0) assert.equal(sink.sent.length, 0)
for (const id of [kauf, aktiv, aus]) assert.ok(!feld(id, 'ticketId'), id) 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 () => { 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 () => { 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 kaputt = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null })
const heil = await einrichtung({ ticket: null, einbau: '', k24At: null, customerId: 'c2' }) const heil = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, customerId: 'c2' })
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
const done = await sf.runActivation({ now: JETZT }) const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done, []) assert.deepEqual(done, [])
assert.ok(!feld(kaputt, 'ticketId')) assert.ok(!feld(kaputt, 'ticketId'))
assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt') assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt')
assert.equal(k24().length, 1) assert.equal(k27().length, 1)
assert.deepEqual(k24()[0].to.map((a) => a.address), ['k2@example.invalid']) assert.deepEqual(k27()[0].to.map((a) => a.address), ['k2@example.invalid'])
const log = logText() const log = logText()
assert.match(log, /Ticketsystem down/) assert.match(log, /Ticketsystem down/)
assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/) 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 // der nächste Lauf holt das gescheiterte nach
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.ok(feld(kaputt, 'ticketId')) 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 () => { 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 () => { 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 fertig = await einrichtung({ zeiten: WOCHE })
const done = await sf.runActivation({ now: JETZT, customerId: 'c1' }) 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.deepEqual(done.map((d) => d.$id), [fertig], 'Freischalten gilt weiter für den Kunden')
assert.ok(!feld(unvollstaendig, 'ticketId')) 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) assert.equal(k16().length, 1)
// der Lauf des Jobs holt es dann nach // der Lauf des Jobs holt es dann nach
await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT })
assert.ok(feld(unvollstaendig, 'ticketId')) 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 () => { test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K27 mehr nachgeschoben', async () => {
const id = await einrichtung({ zeiten: WOCHE, k24At: null }) const id = await einrichtung({ zeiten: WOCHE, einschaltMailAt: null })
const done = await sf.runActivation({ now: JETZT }) const done = await sf.runActivation({ now: JETZT })
assert.deepEqual(done.map((d) => d.$id), [id]) assert.deepEqual(done.map((d) => d.$id), [id])
assert.equal(zustand(id), 'active') assert.equal(zustand(id), 'active')
assert.equal(k24().length, 0) assert.equal(k27().length, 0)
assert.equal(k16().length, 1) assert.equal(k16().length, 1)
}) })
+3 -3
View File
@@ -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. // Feiertags-Erinnerung) mit eigener Überlappsperre je Job, ohne Wurf, plus Quelltext-Pin der Einbindung in server/index.js.
const DATETIMES = { const DATETIMES = {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
} }
const aw = await startAppwriteMock({ const aw = await startAppwriteMock({
schema: { schema: {
@@ -75,7 +75,7 @@ async function legeFunktion() {
siteKey: 'abcdefghijklmnop', siteKey: 'abcdefghijklmnop',
ticketId: 't_1', ticketId: 't_1',
einbau: 'angestossen', einbau: 'angestossen',
k24At: ZUVOR, einschaltMailAt: ZUVOR,
activatedAt: ZUVOR, activatedAt: ZUVOR,
}) })
const doc = aw.docs('portalFeatures').get('f_p1_zeiten') const doc = aw.docs('portalFeatures').get('f_p1_zeiten')
@@ -145,7 +145,7 @@ function haengeAn() {
// ---------------------------------------------------------------- Abo-Job // ---------------------------------------------------------------- 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() await legeFunktion()
abgelaufen() abgelaufen()
+73 -73
View File
@@ -6,7 +6,7 @@ import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js' import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.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. // 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. // Die allgemeinen Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Du-Form) prüft test/mailTemplates.test.js.
const aw = await startAppwriteMock() 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 { setMailTransportForTests } = await import('../server/services/mailer.js')
const reg = await import('../server/services/featureRegistry.js') const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.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)) after(() => setMailTransportForTests(null))
const KUNDE = { $id: 'c1', name: 'Silke Müller', email: 'silke@example.com' } 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" // 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 FRIST = new Date('2026-11-04T09:00:00.000Z')
const BEISPIELE = { const BEISPIELE = {
'K24 angestossen': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }), 'K27 angestossen': () => k27Mail({ 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 }), 'K27 manuell mit Ticket': () => k27Mail({ 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 }), 'K27 wieder ohne Ticket': () => k27Mail({ 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 }), 'K27 wieder ohne Neubau': () => k27Mail({ 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' }] }), 'K28 ein Tag': () => k28Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }),
'K25 mehrere mit prüfen': () => 'K28 mehrere mit prüfen': () =>
k25Mail({ k28Mail({
kunde: KUNDE.name, kunde: KUNDE.name,
projekt: 'Praxis Muster', projekt: 'Praxis Muster',
tage: [ tage: [
@@ -54,18 +54,18 @@ const BEISPIELE = {
{ tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' }, { tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' },
], ],
}), }),
'K26 beendet': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }), 'K29 beendet': () => k29Mail({ 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 €' }), 'K29 hosting': () => k29Mail({ 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 €' }), 'K29 Wechsel widerrufen': () => k29Mail({ 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 €' }), 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 = { 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'] }, K27: { file: 'K27 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'] }, K28: { file: 'K28 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'] }, 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'] },
K27: { file: 'K27 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] }, 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)) { for (const [id, soll] of Object.entries(erwartet)) {
const tpl = loadTemplate(id) 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)', () => { 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 }]) { 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)', () => { test('K27: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => {
const auto = BEISPIELE['K24 angestossen']() const auto = BEISPIELE['K27 angestossen']()
assert.equal(auto.template, 'K24') assert.equal(auto.template, 'K27')
assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false, weiter: true }) assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false, weiter: true })
const textAuto = render(auto).text const textAuto = render(auto).text
assert.match(textAuto, /nach dem nächsten Veröffentlichen/) assert.match(textAuto, /nach dem nächsten Veröffentlichen/)
assert.doesNotMatch(textAuto, /baut unser Team/) assert.doesNotMatch(textAuto, /baut unser Team/)
assert.match(textAuto, /Ticket #100123/, 'Ticketnummer steht in beiden Fällen in der Mail') 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 }) assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true })
const textHand = render(hand).text const textHand = render(hand).text
assert.match(textHand, /baut unser Team/) 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) // 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']) { 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)) 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', () => { 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['K24 wieder ohne Neubau']() const ohne = BEISPIELE['K27 wieder ohne Neubau']()
assert.deepEqual(ohne.blocks, { angestossen: false, manuell: false, wieder: true, weiter: false }) assert.deepEqual(ohne.blocks, { angestossen: false, manuell: false, wieder: true, weiter: false })
const mail = render(ohne) const mail = render(ohne)
assert.deepEqual(mail.problems, []) 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 // mit Neubau (angestossen oder manuell) bleibt der Kasten, auch beim Wiedereinschalten
for (const einbau of ['angestossen', 'manuell']) { 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) assert.match(text, /Wie geht es weiter/, einbau)
} }
// einbau null ohne wieder ist kein Fall der Mail: es bleibt beim Vorsichtigen // 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', () => { test('K27: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => {
const ohne = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' }) const ohne = k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' })
assert.equal(ohne.values.ticket, '') assert.equal(ohne.values.ticket, '')
assert.doesNotMatch(render(ohne).text, /Ticket|\(\)|\s\./) 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(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 }) assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true, weiter: true })
const mitWieder = render(wieder).text const mitWieder = render(wieder).text
assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/) assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/)
assert.match(mitWieder, /wieder eingeschaltet/) assert.match(mitWieder, /wieder eingeschaltet/)
assert.match(mitWieder, /Zeiten und Einstellungen sind unverändert/) 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', () => { test('K27: Betreff, Projekt und Knopf', () => {
const mail = render(BEISPIELE['K24 angestossen']()) const mail = render(BEISPIELE['K27 angestossen']())
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
assert.match(mail.text, /Projekt: Praxis Muster/) assert.match(mail.text, /Projekt: Praxis Muster/)
assert.match(mail.html, new RegExp(`<a href="${PORTAL.replaceAll('.', '\\.')}"`)) 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', () => { test('K28: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“ und Mehrzahl nur bei Bedarf', () => {
const ein = BEISPIELE['K25 ein Tag']() const ein = BEISPIELE['K28 ein Tag']()
assert.equal(ein.template, 'K25') assert.equal(ein.template, 'K28')
assert.equal(ein.values.tage, 'Sa 03.10. – Tag der Deutschen Einheit') assert.equal(ein.values.tage, 'Sa 03.10. – Tag der Deutschen Einheit')
assert.deepEqual(an(ein), []) assert.deepEqual(an(ein), [])
assert.deepEqual(ein.blocks, { pruefen: false, mehrere: false }) 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, /Stimmen Deine Zeiten/)
assert.match(textEin, /Google/, 'Hinweis: bei Google auch eintragen') 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.equal(viele.values.tage, 'Do 24.12. – Heiligabend (bitte prüfen)\nFr 25.12. – 1. Weihnachtstag')
assert.deepEqual(viele.blocks, { pruefen: true, mehrere: true }) assert.deepEqual(viele.blocks, { pruefen: true, mehrere: true })
const mail = render(viele) 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>') 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', () => { test('K28: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => {
const nachricht = k25Mail({ const nachricht = k28Mail({
kunde: 'K', kunde: 'K',
projekt: 'P', projekt: 'P',
tage: [ tage: [
@@ -209,15 +209,15 @@ test('K25: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woc
assert.doesNotMatch(mail.html, /<b>fett/) assert.doesNotMatch(mail.html, /<b>fett/)
}) })
test('K25 geht ohne BCC ans Team, K24 mit', async () => { test('K28 geht ohne BCC ans Team, K27 mit', async () => {
const k25 = await sendeZeitenMail(KUNDE, BEISPIELE['K25 ein Tag'](), { bccTeam: false }) const k28 = await sendeZeitenMail(KUNDE, BEISPIELE['K28 ein Tag'](), { bccTeam: false })
assert.deepEqual(k25, { sent: true }) assert.deepEqual(k28, { sent: true })
assert.equal(sink.sent.length, 1) assert.equal(sink.sent.length, 1)
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['silke@example.com']) assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['silke@example.com'])
assert.ok(!sink.sent[0].bcc, 'keine Kopie ans Team') assert.ok(!sink.sent[0].bcc, 'keine Kopie ans Team')
assert.equal(sink.sent[0].subject, 'Feiertage stehen an – stimmen Deine Zeiten?') 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.equal(sink.sent.length, 2)
assert.deepEqual(sink.sent[1].bcc.map((t) => t.address), config.chatNotify.emails, 'Standard: Kopie ans Team') 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) 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,/) 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 = [ const faelle = [
['K26 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/], ['K29 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/], ['K29 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 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) { for (const [name, blocks, passt, fremd] of faelle) {
const nachricht = BEISPIELE[name]() const nachricht = BEISPIELE[name]()
assert.equal(nachricht.template, 'K26') assert.equal(nachricht.template, 'K29')
assert.deepEqual(nachricht.blocks, blocks, name) assert.deepEqual(nachricht.blocks, blocks, name)
assert.equal(nachricht.values.frist, '04.11.2026', `${name}: Datum TT.MM.JJJJ`) assert.equal(nachricht.values.frist, '04.11.2026', `${name}: Datum TT.MM.JJJJ`)
assert.equal(nachricht.values.preis, '30 €') 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`) assert.match(text, /Für 30 € behalten: .*#zeiten/, `${name}: Knopf zum Behalten`)
} }
// unbekannte Lage: lieber die Standard-Fassung (Abo beendet) als gar kein Grund // 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.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['K26 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?') 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.) // 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(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(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.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(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError) assert.throws(() => k29Mail({ 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(() => k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError)
assert.throws(() => k27Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', 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', () => { test('K30: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => {
const nachricht = BEISPIELE.K27() const nachricht = BEISPIELE.K30()
assert.equal(nachricht.template, 'K27') assert.equal(nachricht.template, 'K30')
assert.equal(nachricht.values.loeschdatum, '04.11.2026') assert.equal(nachricht.values.loeschdatum, '04.11.2026')
assert.equal(nachricht.values.preis, '30 €') assert.equal(nachricht.values.preis, '30 €')
const mail = render(nachricht) 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/) assert.match(mail.text, /Projekt: Praxis Muster/)
}) })
test('K26 und K27 gehen mit Kopie ans Team', async () => { test('K29 und K30 gehen mit Kopie ans Team', async () => {
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())).sent, true) assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K29 beendet']())).sent, true)
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, true) assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K30())).sent, true)
for (const mail of sink.sent) assert.ok(mail.bcc?.length > 0) for (const mail of sink.sent) assert.ok(mail.bcc?.length > 0)
}) })
test('ohne E-Mail-Adresse: nichts senden, nichts werfen', async () => { test('ohne E-Mail-Adresse: nichts senden, nichts werfen', async () => {
for (const kunde of [{ name: 'Silke' }, { name: 'Silke', email: '' }, { email: ' ' }, { email: null }, null, undefined]) { 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) 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') 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.sent, false)
assert.equal(ergebnis.reason, 'fehler') assert.equal(ergebnis.reason, 'fehler')
assert.doesNotMatch(JSON.stringify(ergebnis), /silke|Müller/i) assert.doesNotMatch(JSON.stringify(ergebnis), /silke|Müller/i)
// auch ohne SMTP-Konfiguration (sendCustomerMail wirft) und mit unbrauchbarer Nachricht // auch ohne SMTP-Konfiguration (sendCustomerMail wirft) und mit unbrauchbarer Nachricht
setMailTransportForTests(null) setMailTransportForTests(null)
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, false) assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K30())).sent, false)
setMailTransportForTests(sink) setMailTransportForTests(sink)
assert.equal((await sendeZeitenMail(KUNDE, undefined)).sent, false) assert.equal((await sendeZeitenMail(KUNDE, undefined)).sent, false)
assert.equal((await sendeZeitenMail(KUNDE, { template: 'K99' })).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)') 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) assert.equal(ergebnis.sent, false)
const text = logText() 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.ok(text.length > 0)
assert.doesNotMatch(text, /silke|example\.com|Müller/i, text) assert.doesNotMatch(text, /silke|example\.com|Müller/i, text)
// auch der Fall ohne Adresse loggt höchstens ohne Namen // 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) assert.doesNotMatch(logText(), /silke|Müller/i)
}) })
test('Erfolg schreibt nichts ins Log', async () => { test('Erfolg schreibt nichts ins Log', async () => {
await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']()) await sendeZeitenMail(KUNDE, BEISPIELE['K27 angestossen']())
assert.equal(logText(), '') assert.equal(logText(), '')
}) })
+7 -7
View File
@@ -16,7 +16,7 @@ const { createDocument, updateDocument, getDocument } = await import('../server/
after(() => Promise.all([aw.close(), st.close()])) after(() => Promise.all([aw.close(), st.close()]))
const NAMES = ['oeffnungszeiten', 'zeitenErinnerungen'] 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) 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, [ assert.deepEqual(ZEITEN_FEATURE_ATTRIBUTES, [
{ key: 'unlockedBy', type: 'string', size: 8, required: false }, { key: 'unlockedBy', type: 'string', size: 8, required: false },
{ key: 'aboEndeAt', type: 'datetime', required: false }, { key: 'aboEndeAt', type: 'datetime', required: false },
{ key: 'einbau', type: 'string', size: 12, required: false }, { key: 'einbau', type: 'string', size: 12, required: false },
{ key: 'behaltenSessionId', type: 'string', size: 128, 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}`)) assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith(`${featuresId}.`)), FEATURE_KEYS.map((key) => `${featuresId}.${key}`))
const features = client.state.get(featuresId).attrs const features = client.state.get(featuresId).attrs
assert.deepEqual(features.get('unlockedBy'), { key: 'unlockedBy', type: 'string', size: 8, required: false }) 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 () => { 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( assert.deepEqual(
client.calls.attrs.filter((k) => k.startsWith(`${featuresId}.`)), 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) 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() const client = fakeClient()
await main({ client, log: quiet }) await main({ client, log: quiet })
assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] }) 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 }) 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, []) assert.deepEqual(result.missingCollections, [])
}) })
+8 -8
View File
@@ -18,7 +18,7 @@ const aw = await startAppwriteMock({
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
}, },
defaultLimit: 25, defaultLimit: 25,
}) })
@@ -123,7 +123,7 @@ afterEach(() => {
// ------------------------------------------------------------------------------------------------ Aktivieren // ------------------------------------------------------------------------------------------------ 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' }) const res = await aktion('activate', { note: 'Pilot' })
assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1']) 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.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(doc.einbau, 'angestossen') assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.ticketId) 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('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner')
assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team') 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“ // 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: '' })) aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'team', einbau: '' }))
const res = await aktion('ticket') const res = await aktion('ticket')
assert.equal(res.status, 200, JSON.stringify(res.body)) 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(rebuilds.length, 1)
assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false) assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false)
assert.match(doc.adminNote, / Andrej: ticket$/) 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 // zweiter Klick: das Ticket ist da, nichts entsteht neu
assert.equal((await aktion('ticket')).status, 200) 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) 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' })) aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }))
const res = await aktion('ticket') const res = await aktion('ticket')
assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.status, 200, JSON.stringify(res.body))
assert.ok(zeitenDoc().ticketId) assert.ok(zeitenDoc().ticketId)
assert.equal(tickets().length, 1) assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 1, 'K24 an den Kunden (unlockedBy abo)') assert.equal(sink.sent.length, 1, 'K27 an den Kunden (unlockedBy abo)')
assert.ok(zeitenDoc().k24At) assert.ok(zeitenDoc().einschaltMailAt)
}) })
test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => { test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => {
+1 -1
View File
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
}, },
datetimes: { datetimes: {
...ZEITEN_DATETIMES, ...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'], featureConsents: ['acceptedAt'],
}, },
defaultLimit: 25, defaultLimit: 25,