Compare commits
52
Commits
cab9c33db6
...
50f854ef1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f854ef1c | ||
|
|
d0691236d5 | ||
|
|
3984d468bf | ||
|
|
3e66dfa91c | ||
|
|
e87140e5ba | ||
|
|
c7cd436ec2 | ||
|
|
ea4457e6a4 | ||
|
|
aa9ee35ab6 | ||
|
|
ecf67d6978 | ||
|
|
c10e50d0b3 | ||
|
|
90e8607e26 | ||
|
|
6a9daa0583 | ||
|
|
b3e96d4a56 | ||
|
|
242fbb01e5 | ||
|
|
f01ed15247 | ||
|
|
04a3525f79 | ||
|
|
cee44d6852 | ||
|
|
7bb918390a | ||
|
|
f20f056c60 | ||
|
|
811e054ca4 | ||
|
|
019833d385 | ||
|
|
1d5a565e9f | ||
|
|
ed99234ae4 | ||
|
|
a6a4eae294 | ||
|
|
a2d11ad6c4 | ||
|
|
a198c472bd | ||
|
|
9a14e874c0 | ||
|
|
efc6dcf332 | ||
|
|
53253f0451 | ||
|
|
97e6971276 | ||
|
|
11dc4b2c9f | ||
|
|
47ae572fff | ||
|
|
66793d06d5 | ||
|
|
8d670ca5dc | ||
|
|
302b58cf72 | ||
|
|
0042d8dd8f | ||
|
|
d1f9176c99 | ||
|
|
d4c1be91c7 | ||
|
|
1c9f773e97 | ||
|
|
7bf2ad2665 | ||
|
|
097036c9a5 | ||
|
|
88fb9263f9 | ||
|
|
5f52ef54e8 | ||
|
|
61c2f72631 | ||
|
|
59b0ea799d | ||
|
|
0f25f7cb52 | ||
|
|
2636ce60be | ||
|
|
db9f7df747 | ||
|
|
2314f7d940 | ||
|
|
4b4cd1058f | ||
|
|
385f768d76 | ||
|
|
360d218d4e |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand)
|
||||
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand, Öffnungszeiten live)
|
||||
|
||||
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht.
|
||||
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht. Dasselbe gilt für „Öffnungszeiten live“: Der Absatz „Öffnungszeiten auf dieser Website“ (je Variante unten) gehört in die Erklärung jeder Website mit dieser Funktion; bau die Funktion erst ein, wenn er dort steht (Einbau-Anleitung: `docs/funktionen/zeiten-einbau.md`).
|
||||
|
||||
Hinweise für das Team:
|
||||
|
||||
@@ -8,7 +8,8 @@ Hinweise für das Team:
|
||||
- Im sichtbaren Text der Kundenwebsite keine Gedankenstriche (Wunsch von Emma's Schatzkiste). Den Text unverändert übernehmen oder ohne Gedankenstriche anpassen.
|
||||
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). `[Überschrift]`-Nummerierung der Website beachten.
|
||||
- Die Löschfrist von 14 Tagen für Server-Protokolle entspricht Abschnitt 3 der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort die Frist, hier mitändern.
|
||||
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe).
|
||||
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe) und für `zeiten.js` (ein Abruf je Seite, keine Anfragen an Dritte).
|
||||
- „Öffnungszeiten auf dieser Website“: den Absatz und nur die Bausteine übernehmen, die die Seite wirklich zeigt. Der Baustein „Route“ gehört dazu, wenn die Kontakt-Leiste den Knopf „Route“ hat, der Baustein „WhatsApp“, wenn sie den Knopf „WhatsApp“ hat. Der WhatsApp-Baustein ist der „WhatsApp-Hinweis“, den der Kunde im Portal mit einem Haken bestätigt, bevor die WhatsApp-Schaltfläche erscheint. Beide Dienste öffnen sich erst nach dem Klick des Besuchers. Anschriften von Google und WhatsApp vor der Übernahme prüfen. Auch hier gilt der Entwurfsstatus: erst nach der Freigabe durch den Inhaber (Rechtsrunde „Öffnungszeiten live“) verwenden.
|
||||
|
||||
## Variante A: Website wird von WEBklar gehostet
|
||||
|
||||
@@ -16,8 +17,32 @@ Hinweise für das Team:
|
||||
|
||||
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Beim Aufruf der Seite lädt Ihr Browser diese Beiträge und Bilder von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Beiträge auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Informationen und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht.
|
||||
|
||||
### Öffnungszeiten auf dieser Website
|
||||
|
||||
Auf dieser Website zeigen wir unsere Öffnungszeiten und ob wir gerade geöffnet haben. Dazu können Hinweise, zum Beispiel zu Betriebsurlaub, am Seitenanfang und auf dem Handy eine Leiste mit Knöpfen zum Anrufen und für die Route kommen. Beim Aufruf der Seite lädt Ihr Browser ein kleines Programm und unsere Öffnungszeiten von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Das Programm setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Öffnungszeiten auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Öffnungszeiten und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht. Für Suchmaschinen stehen unsere Öffnungszeiten außerdem in maschinenlesbarer Form im Seitencode.
|
||||
|
||||
#### Baustein Route (nur aufnehmen, wenn die Leiste den Knopf „Route“ zeigt)
|
||||
|
||||
Der Knopf „Route“ ist ein Link zu Google Maps. Erst wenn Sie darauf klicken, öffnet sich Google Maps (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) mit unserer Adresse als Ziel. Vorher werden keine Daten an Google übertragen. Nach dem Klick gelten die Datenschutzhinweise von Google.
|
||||
|
||||
#### Baustein WhatsApp (nur aufnehmen, wenn die Leiste den Knopf „WhatsApp“ zeigt)
|
||||
|
||||
Der Knopf „WhatsApp“ ist ein Link zu WhatsApp. Erst wenn Sie darauf klicken, öffnet sich WhatsApp (WhatsApp Ireland Limited, Merrion Road, Dublin 4, D04 X2K5, Irland, ein Unternehmen von Meta) mit unserer Nummer und gegebenenfalls einem vorgeschlagenen Starttext Ihrer Nachricht. Vorher werden keine Daten an WhatsApp oder Meta übertragen. Die Nachricht senden Sie selbst ab; nach dem Klick gelten die Datenschutzhinweise von WhatsApp.
|
||||
|
||||
## Variante B: Website liegt bei einem anderen Anbieter
|
||||
|
||||
### Neuigkeiten auf dieser Website
|
||||
|
||||
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich]
|
||||
|
||||
### Öffnungszeiten auf dieser Website
|
||||
|
||||
Auf dieser Website zeigen wir unsere Öffnungszeiten und ob wir gerade geöffnet haben. Dazu können Hinweise, zum Beispiel zu Betriebsurlaub, am Seitenanfang und auf dem Handy eine Leiste mit Knöpfen zum Anrufen und für die Route kommen. Diese Angaben lädt Ihr Browser beim Aufruf der Seite als kleines Programm und als Öffnungszeiten von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Das Programm setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit unsere Öffnungszeiten auf der Website überall gleich und aktuell erscheinen, ohne dass wir die Website bei jeder Änderung neu veröffentlichen müssen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Öffnungszeiten. Die Protokolle werden nach 14 Tagen gelöscht. Für Suchmaschinen stehen unsere Öffnungszeiten außerdem in maschinenlesbarer Form im Seitencode. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich]
|
||||
|
||||
#### Baustein Route (nur aufnehmen, wenn die Leiste den Knopf „Route“ zeigt)
|
||||
|
||||
Der Knopf „Route“ ist ein Link zu Google Maps. Erst wenn Sie darauf klicken, öffnet sich Google Maps (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) mit unserer Adresse als Ziel. Vorher werden keine Daten an Google übertragen. Nach dem Klick gelten die Datenschutzhinweise von Google.
|
||||
|
||||
#### Baustein WhatsApp (nur aufnehmen, wenn die Leiste den Knopf „WhatsApp“ zeigt)
|
||||
|
||||
Der Knopf „WhatsApp“ ist ein Link zu WhatsApp. Erst wenn Sie darauf klicken, öffnet sich WhatsApp (WhatsApp Ireland Limited, Merrion Road, Dublin 4, D04 X2K5, Irland, ein Unternehmen von Meta) mit unserer Nummer und gegebenenfalls einem vorgeschlagenen Starttext Ihrer Nachricht. Vorher werden keine Daten an WhatsApp oder Meta übertragen. Die Nachricht senden Sie selbst ab; nach dem Klick gelten die Datenschutzhinweise von WhatsApp.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Öffnungszeiten live auf der Kundenwebsite einbauen
|
||||
|
||||
Anleitung für das WEBklar-Team. „Öffnungszeiten live“ zeigt die Zeiten, die der Kunde im Portal einträgt (Tab „Funktionen“, Kachel „Öffnungszeiten live“), auf seiner Website: als Tabelle, als Live-Status und als Kurzanzeige, dazu eine Hinweis-Leiste, auf dem Handy eine Kontakt-Leiste und Google-Angaben (JSON-LD) im Seitencode. Den `siteKey` (16 Zeichen, a–z und 2–7) findest Du im WOMS: Kunde → Reiter „Funktionen“, Zeile „Öffnungszeiten live“ (mit Kopierknopf). Im Einrichtungs-Ticket „Öffnungszeiten live einbauen – <Projekt>“ stehen Einbau-Zeile, Ergebnis des automatischen Einbaus (`einbau`), Beispiele der Platzhalter mit den Plan-Kurznamen und die Prüfliste. Dieses Dokument ist die Referenz dahinter. Die Datenschutz-Absätze für die Kundenseite stehen in `docs/funktionen/website-datenschutz-absatz.md`.
|
||||
|
||||
Die Spec ist `docs/superpowers/specs/2026-10-04-oeffnungszeiten-live-design.md` (Abschnitte 4.2, 4.3, 4.5, 4.6).
|
||||
|
||||
## 1. Was automatisch geschieht und was Du tust
|
||||
|
||||
| Teil | Wer | Wie |
|
||||
| --- | --- | --- |
|
||||
| Einbau-Zeile (Skript) | Runner, automatisch | beim nächsten Veröffentlichen in jede HTML-Datei vor `</head>`, nur wenn die Funktion in `setup` oder `active` steht |
|
||||
| Hinweis-Leiste, Kontakt-Leiste, JSON-LD | Skript | kommen mit den Daten, ohne Platzhalter |
|
||||
| Platzhalter (Tabelle, Status, Badge) mit Ersatz-HTML | Du | an die Stellen setzen, an denen feste Zeiten stehen, und die festen Zeiten ersetzen |
|
||||
| Datenschutz-Absatz auf der Website | Du | Abschnitt 8, Punkt 5 |
|
||||
| Einbau-Zeile bei `einbau: manuell` oder wenn die Seite woanders liegt | Du | Abschnitt 9 |
|
||||
|
||||
Der Einbau ist **ein Einbau je Website** und ist im Preis (30 € oder im Wartungs-Abo ohne Aufpreis) enthalten. Er wird nicht auf das Stundenkontingent angerechnet. Sonderwünsche (zusätzliche Stellen, Sondergestaltung) gehen über ein Angebot oder, mit Abo, über Stunden.
|
||||
|
||||
## 2. Die Einbau-Zeile
|
||||
|
||||
```html
|
||||
<script data-wk-zeiten src="https://project.webklar.com/embed/zeiten.js" data-site="SITEKEY" defer></script>
|
||||
```
|
||||
|
||||
- Ins `<head>` jeder HTML-Seite, einmal pro Seite. Der Runner setzt sie vor `</head>` ein (liegt kein `</head>` vor, nach `<body>`); eine schon vorhandene Zeile mit `data-wk-zeiten` ersetzt er, der Einbau ist also wiederholbar und doppelt nichts.
|
||||
- `data-site` ist der `siteKey`. Optional `data-api="https://project.webklar.com"` (nur am Script-Tag, nie an einem Platzhalter); ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse (das klappt nur bei einem normalen `<script src>`; bei Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht). Den siteKey darf auch ein Platzhalter tragen (`data-webklar-zeiten="SITEKEY"`).
|
||||
- Die Adresse des Skripts kommt aus der Runner-Variable `ZEITEN_PUBLIC_BASE` (Vorgabe `https://project.webklar.com/embed/`, muss mit `https://` beginnen und mit `/` enden). Für die Produktion nichts ändern.
|
||||
- Das Skript hat keine Abhängigkeiten, setzt keine Cookies (keine Cookies, kein `localStorage`, kein `sessionStorage`), fragt nur `project.webklar.com` ab und lädt nichts von Dritten. Kundentexte setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden. Ein Abruf je Seite für alle Teile, danach höchstens alle 10 Minuten.
|
||||
- Hat die Seite eine Content-Security-Policy, muss `project.webklar.com` in `script-src` und `connect-src` stehen, und `style-src` muss Inline-Stile erlauben (`'unsafe-inline'`): das Skript legt ein `<style>`-Element an (Tabelle, Status, Leisten). Ohne diese Erlaubnis erscheinen sie ohne Gestaltung.
|
||||
|
||||
## 3. Platzhalter
|
||||
|
||||
Ein Platzhalter ist ein Element mit dem Attribut `data-webklar-zeiten`. Innen steht das Ersatz-HTML (Abschnitt 4).
|
||||
|
||||
```html
|
||||
<div data-webklar-zeiten data-modus="tabelle" data-plan="oeffnungszeiten">…Ersatz-HTML…</div>
|
||||
<div data-webklar-zeiten data-modus="status" data-plan="oeffnungszeiten">Mo–Fr 9–18 Uhr</div>
|
||||
<span data-webklar-zeiten data-modus="badge" data-plan="oeffnungszeiten">Mo–Fr 9–18 Uhr</span>
|
||||
```
|
||||
|
||||
| `data-modus` | Zeigt | Gedacht für |
|
||||
| --- | --- | --- |
|
||||
| `tabelle` | Wochentabelle (echtes `<table>` mit Caption und Zeilenköpfen, heutiger Tag mit `aria-current="date"`), darunter „Abweichungen in den nächsten 14 Tagen“ | Kontaktseite, Footer |
|
||||
| `status` | Punkt und Text, zum Beispiel „Jetzt geöffnet · schließt um 18 Uhr“, ohne `aria-live` | Kopfbereich, Hero |
|
||||
| `badge` | Kurzform „Geöffnet“, „Geschlossen“, „Nach Vereinbarung“ | Menü, Header |
|
||||
|
||||
- `data-plan` ist der **Kurzname** des Zeitplans (Kleinbuchstaben, Ziffern und Bindestrich, 1–24 Zeichen, nach dem Anlegen unveränderlich). Der Hauptplan heißt `oeffnungszeiten`. Weitere Pläne (zum Beispiel `kueche`, `lieferung`) hat der Kunde im Editor angelegt; die Kurznamen stehen im Editor und im Einrichtungs-Ticket („Pläne (Kurznamen für data-plan)“). Ohne `data-plan` gilt der Hauptplan.
|
||||
- **Einzelseiten-Apps (Vite/React):** Das Skript beobachtet `<body>` und füllt Platzhalter, die erst nach einem Seitenwechsel auf die Seite kommen, nach etwa 0,2 Sekunden (auch einen wieder eingehängten Knoten). Setze Platzhalter nicht in Teile, die die App beim Rendern entfernt oder neu aufbaut: ein neu gebauter Knoten startet wieder mit seinem Ersatz-HTML, und was das Skript hineingeschrieben hat, kann die App überschreiben.
|
||||
- Ein **unbekannter** `data-plan` oder `data-modus` lässt das Ersatz-HTML stehen und schreibt eine Warnung in die Konsole. Es gibt kein stilles Umschalten auf den Hauptplan. Bei Fehlern, ohne JavaScript, bei Daten älter als 24 Stunden und nach dem Abschalten bleibt ebenfalls das Ersatz-HTML.
|
||||
- Das Skript füllt die Platzhalter ab `setup`, damit Du vor dem Schließen des Tickets prüfen kannst.
|
||||
- Gestaltung: Light DOM, alle Klassen beginnen mit `wkz-`, die Stile stehen in `@layer wkz`. **Jede** Regel der Kundenseite (ohne Layer) gewinnt also gegen die Standard-Stile. Schrift und Farben erbt die Seite. Variablen (alle optional, am Platzhalter oder auf `:root`): `--wkz-offen`, `--wkz-zu`, `--wkz-unklar` (Farben der Punkte), `--wkz-offen-hell`, `--wkz-zu-hell`, `--wkz-unklar-hell`, `--wkz-offen-text`, `--wkz-zu-text`, `--wkz-unklar-text` (Badge), `--wkz-rand`, `--wkz-heute`. Kontrast mindestens 4,5 : 1 prüfen.
|
||||
- Hat das Theme globale Regeln wie `table{…}` oder `td{…}`, greifen sie auch in der Tabelle. Prüfe das bei der Abnahme und überschreibe gezielt, zum Beispiel `[data-webklar-zeiten] table{ … }`.
|
||||
|
||||
## 4. Ersatz-HTML
|
||||
|
||||
Jeder Platzhalter behält eine **statische Ersatz-Tabelle**: Sie steht ohne JavaScript, bei einem Fehler und nach der Abschaltung. Ohne sie wäre die Stelle leer.
|
||||
|
||||
1. WOMS → Funktionen → Ersatz-HTML kopieren (je Plan ein Knopf; bei mehreren Plänen mit Namen dahinter). Erst nach dem ersten Speichern des Kunden; vorher meldet das WOMS „Der Kunde hat noch keine Zeiten eingetragen“ (die Schnittstelle antwortet 409: `GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=…&plan=<kurz>`).
|
||||
2. In jeden Platzhalter einsetzen. Das Ersatz-HTML ist eine schlichte Wochentabelle als reines ASCII mit Entities (`ä`, `–`), also in jeder Dateikodierung sicher. Ein Fensterende um Mitternacht steht als `00:00` („18:00–00:00“), ein Fenster von 00:00 bis 24:00 als „rund um die Uhr“.
|
||||
3. Die Tabelle zeigt den Wochenplan **zum Zeitpunkt des Einbaus**. Ändert der Kunde seinen Wochenplan später, bleibt das Ersatz-HTML zunächst stehen (mit Skript unsichtbar).
|
||||
4. **Beim Abschalten** der Funktion, wenn der aktuelle Wochenplan des Hauptplans vom Stand beim Einbau abweicht, entsteht ein WOMS-Ticket **„Ersatz-Tabelle aktualisieren – <Projekt>“** mit dem aktuellen Ersatz-HTML. Ersetze die Tabelle in allen Platzhaltern (gehört zum enthaltenen Einbau, Leistungsbeschreibung Nr. 4 Abs. 3). Die Mail 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`)
|
||||
|
||||
Die Hinweis-Leiste steht als erstes Kind von `<body>` im Fluss und schiebt den Inhalt nach unten. Feste und klebende Elemente mit `top: 0` rückt das Skript, solange die Leiste sichtbar ist, um `max(0, Leistenhöhe − Scrollposition)` nach unten (Inline-`top`, beim Entfernen der Leiste zurückgesetzt) und erhöht `scroll-padding-top`.
|
||||
|
||||
Überlappt die Leiste trotzdem den Kopf der Seite (zum Beispiel ein fester Header mit `top: 12px`, mit `transform` oder in einem Wrapper), setze im WOMS bei Kunde → Funktionen → „Öffnungszeiten live“ das Feld **Header-Selektor** (Team-Einstellung `navSelektor`):
|
||||
|
||||
- ein CSS-Selektor des Headers, höchstens 120 Zeichen, erlaubt sind nur `A–Z a–z 0–9`, Leerzeichen und `# . - _ > : [ ] = " '`, nicht erlaubt `<`, `{`, `}` und `;`. Beispiel: `header.site-header` oder `#nav`.
|
||||
- Das Skript wendet ihn mit `querySelectorAll` in einem `try/catch` an; ein falscher Selektor bricht nichts, er bewirkt nur nichts.
|
||||
- Leer lassen = Standard (nur `top: 0`). Speichern mit `teamVersion` (bei 409 „Inzwischen geändert – bitte neu laden“).
|
||||
- Prüfe nach dem Setzen: oben auf der Seite, beim Scrollen (die Leiste scrollt weg, der Header klebt wieder oben) und auf dem Handy.
|
||||
|
||||
## 6. Leisten ausschalten
|
||||
|
||||
Im selben Bereich (WOMS → Funktionen → „Öffnungszeiten live“) gibt es zwei Haken: **`kontaktleisteAus`** („Kontakt-Leiste aus“) und **`hinweisleisteAus`** („Hinweis-Leiste aus“). Sie gelten je Website.
|
||||
|
||||
- Die **Kontakt-Leiste** erscheint nur bei `(max-width: 768px)`, unten fest mit `z-index: 40`, Knöpfe „Anrufen“, „Route“ und, wenn der Kunde sie eingerichtet hat, „WhatsApp“. Hat die Seite selbst ein festes Element am unteren Rand mit einem `a[href^="tel:"]`, bleibt sie **automatisch aus**. Setze den Haken, wenn die Seite eine eigene Anruf-Leiste hat, die das Skript nicht erkennt, oder der Kunde die Leiste nicht möchte.
|
||||
- WhatsApp und die Route (Google Maps) öffnen erst beim Klick. Den Haken „Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen“ setzt der Kunde im Portal; in der Kundenansicht setzt ihn kein Mitarbeiter.
|
||||
- Die **Hinweis-Leiste** zeigt zu jedem Zeitpunkt den gültigen Hinweis mit dem höchsten Rang (Wichtig, Urlaub, Aktion, Info). Sie hat keinen Schließen-Knopf (das Skript speichert nichts im Browser).
|
||||
|
||||
## 7. JSON-LD
|
||||
|
||||
Das Skript pflegt die Öffnungszeiten im Seitencode: Gibt es einen `application/ld+json`-Block mit einem Knoten vom Typ `LocalBusiness` oder einem Untertyp (auch in `@graph`), ersetzt es dort **nur** `openingHoursSpecification` und `specialOpeningHoursSpecification` (alles andere im Block bleibt); unlesbare Blöcke bleiben unberührt. Gibt es keinen, legt es einen eigenen Block `[data-wkz-jsonld]` mit `name`, `address` und `url` an. Prüfe auf der Seite, ob JSON-LD mit festen Öffnungszeiten an einer anderen Stelle steht (zum Beispiel als Mikrodaten im Footer); das würde dem Skript widersprechen.
|
||||
|
||||
## 8. Prüfliste
|
||||
|
||||
Das Einrichtungs-Ticket nennt dieselben fünf Punkte:
|
||||
|
||||
1. **Feste Zeiten im Repo suchen und ersetzen**: `grep -ril "öffnungszeit" .` und Ähnliches (`geöffnet`, `Mo-Fr`, `Uhr`), auch Footer, Kontaktseite, `<meta>` und eigene Status-Skripte. Jede Stelle wird ein Platzhalter mit Ersatz-HTML oder fällt weg.
|
||||
2. **Header**: Überlappt die Hinweis-Leiste den Kopf der Seite? Sonst `navSelektor` setzen (Abschnitt 5).
|
||||
3. **Anruf-Leiste**: Hat die Seite schon eine eigene feste Anruf-Leiste? Dann `kontaktleisteAus` (Abschnitt 6), damit sie nicht doppelt erscheint.
|
||||
4. **Vorhandenes JSON-LD** (Öffnungszeiten der Seite) prüfen, damit nichts doppelt oder widersprüchlich steht (Abschnitt 7).
|
||||
5. **Datenschutz-Absatz auf der Website**: den Absatz „Öffnungszeiten auf dieser Website“ aus `docs/funktionen/website-datenschutz-absatz.md` einsetzen (Variante A bei WEBklar-Hosting, Variante B bei Fremdhosting). Die Bausteine „Route“ und „WhatsApp“ nur, wenn die Kontakt-Leiste die Knöpfe zeigt. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, setzt Du den Absatz ein (gehört zum Einbau, nicht auf das Stundenkontingent anrechnen); liegt sie woanders (externer Generator), schicke dem Kunden den Textbaustein, er fügt ihn selbst ein. Die Texte sind Entwurf, bis die Rechtsrunde sie freigibt.
|
||||
|
||||
Zur Abnahme außerdem: Seite in der Vorschau öffnen und mit ausgeschaltetem JavaScript ansehen (Ersatz-Tabelle muss stehen), Konsole auf Warnungen zu `data-plan` prüfen, auf einem Handy (höchstens 768 px Breite) die Kontakt-Leiste, den Regler „Uhrzeit testen“ im Editor gegen die Anzeige halten, Kontrast und Tippflächen (mindestens 44 px) ansehen.
|
||||
|
||||
## 9. Sperrliste `ZEITEN_KEIN_NEUBAU` und Seiten ohne automatischen Einbau
|
||||
|
||||
**Neubau und Einsetzen.** Bei jedem Übergang in `setup` oder `active` (Einschalten im Abo, Zahlung, Aktivierung durch das Team, Rückkehr des Abos, Behalten) stößt das Portal einen Neubau der Seite an (höchstens einer gleichzeitig). Das Ergebnis steht in `einbau`: `angestossen` (Auftrag angenommen, die Zeile erscheint nach dem Veröffentlichen) oder `einbau: manuell`. Änderungen an Zeiten und Leisten brauchen keinen Neubau.
|
||||
|
||||
`einbau: manuell` heißt: kein Repo, Vorschau aus, Dev-Modus, Projekt auf der Sperrliste, Fehler oder Runner ausgelastet. Dann setzt Du die Einbau-Zeile von Hand ins Repo.
|
||||
|
||||
**Sperrliste.** `ZEITEN_KEIN_NEUBAU` (Umgebungsvariable des Portals, Liste von Subdomains, durch Komma getrennt): Für diese Projekte gibt es weder einen angestoßenen Neubau noch das automatische Einsetzen bei normalen Deploys; die Funktion zählt sie immer als manuell. Vorgabe ist `emmas-schatzkiste` (Emmas Seiten, deren Dateien teilweise nicht in Git liegen). **Achtung:** Setzt Du die Variable, **ersetzt** sie die Vorgabe. Willst Du eine Seite zusätzlich sperren, nenne `emmas-schatzkiste` mit in der Liste. Änderung in `compose` und `.env` des Portals, danach das Portal mit `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` neu erstellen (kein `docker restart`: es liest geänderte Werte nicht neu ein). Für gesperrte Seiten setzt Du die Zeile und die Platzhalter von Hand ein (im Repo, soweit die Dateien dort liegen, sonst direkt auf dem Server) und prüfst nach jedem Deploy, ob sie noch da sind.
|
||||
|
||||
**Seite liegt woanders** (anderer Hoster, keine WEBklar-Veröffentlichung): Das Ticket enthält die Einbau-Zeile; Du setzt sie ein, soweit WEBklar Zugriff auf die Seite hat. Hat WEBklar keinen Zugriff, schicke dem Kunden im Chat Einbau-Zeile, Platzhalter-Beispiele und Ersatz-HTML und bitte ihn, sie selbst oder durch seinen Anbieter einzufügen. Schließe das Ticket erst, wenn die Anzeige auf der Live-Seite stimmt: Öffne die Seite, sieh in den Netzwerk-Aufrufen nach der Antwort von `/api/public/zeiten/v1/<siteKey>` (`"aktiv": true`) und nach der gefüllten Tabelle.
|
||||
|
||||
## 10. Freischalten und Ende
|
||||
|
||||
- **Freischalten:** Der Job setzt `active` (und sendet K16), wenn das Ticket geschlossen ist und der Kunde Zeiten eingetragen hat (Hauptplan gespeichert, mindestens ein Tag nicht „zu“). Sonst bleibt `setup`, das WOMS zeigt „wartet auf die Zeiten des Kunden“. Solange der Kunde nichts eingetragen hat, kannst Du die Tabelle nicht einbauen.
|
||||
- **Ende des Abos** (Gratisweg): K29 mit Frist zum Behalten (30 Tage), danach `disabled` und K30, Eingaben noch 30 Tage, dann gelöscht. Das Ticket „Ersatz-Tabelle aktualisieren“ gehört zum Abschalten (Abschnitt 4).
|
||||
- **Abschalten durch das Team** (Grund `team`): keine automatische Löschung.
|
||||
|
||||
## 11. Häufige Fehlerbilder
|
||||
|
||||
| Symptom | Meist liegt es an |
|
||||
| --- | --- |
|
||||
| Es erscheint nur das Ersatz-HTML | Funktion nicht in `setup`/`active`, `einbau: manuell` und Zeile fehlt, unbekannter `data-plan`, kein JavaScript, Daten älter als 24 Stunden, CSP blockiert `project.webklar.com` |
|
||||
| Hinweis-Leiste verdeckt den Header | `navSelektor` nicht gesetzt (Abschnitt 5) |
|
||||
| Zwei Anruf-Leisten auf dem Handy | `kontaktleisteAus` setzen (Abschnitt 6) |
|
||||
| Kontakt-Leiste fehlt auf dem Handy | Seite hat schon eine feste Anruf-Leiste unten, oder Breite über 768 px, oder `kontaktleisteAus` |
|
||||
| Öffnungszeiten doppelt im Suchergebnis oder Test | festes JSON-LD an anderer Stelle (Abschnitt 7) |
|
||||
| Tabelle sieht fremd aus | globale Regeln des Themes (Abschnitt 3) |
|
||||
@@ -0,0 +1,52 @@
|
||||
# Datenschutzerklärung von WEBklar: Abschnitt 6e „Öffnungszeiten live auf Websites unserer Kunden“ (Entwurf)
|
||||
|
||||
Entwurf für die Rechtsrunde (Spec „Öffnungszeiten live“, Abschnitt 9 Nr. 3), Stand 05.10.2026. Keine Rechtsberatung. **`public/datenschutz.html` ist nicht geändert**: Der Abschnitt wird erst nach der Freigabe durch den Inhaber (und nach der Kanzlei, soweit sie die Fragen aus `zeiten-kanzlei-fragen.md` bearbeitet) in die Erklärung übernommen. Reihenfolge der Rechtsrunde: Leistungsbeschreibung, dann dieser Abschnitt samt Website-Absatz (`docs/funktionen/website-datenschutz-absatz.md`), dann Mailtexte, dann Schalter `ZEITEN_ENABLED=on`.
|
||||
|
||||
Aufbau wie Abschnitt 6c (`termine-datenschutz-6c.md`): Rolle, gespeicherte Angaben, E-Mails, Aufruf der Kundenwebsite, Löschfristen. Die Leistungsbeschreibung verweist auf diesen Abschnitt (`/datenschutz.html#zeiten`, Nr. 12 Abs. 2): **Sie darf erst live gehen, wenn der Abschnitt mit dem Anker `id="zeiten"` in der Erklärung steht.**
|
||||
|
||||
Einbau in `public/datenschutz.html`: zwischen 6c und „7. Abos und Zahlung über Stripe“. Beim Übernehmen ändert sich der Stand der Erklärung (Abschnitt 15); die Tests `test/legalTexts.test.js` (Abschnitt 6c und Kundenansicht) und `test/zeitenRecht.test.js` (dieser Entwurf) werden dann angepasst.
|
||||
|
||||
**Nachtrag 06.10.2026 (nach dem Zusammenführen mit main):** Die Nummer 6d ist seit dem Webshop-Livegang vergeben („6d. Webshop auf Websites unserer Kunden“, Anker `id="webshop"`). Dieser Abschnitt heißt deshalb **6e** und steht hinter dem Webshop-Abschnitt und vor „7. Abos und Zahlung über Stripe“; der Anker `id="zeiten"` bleibt.
|
||||
|
||||
## Abschnitt 6e (HTML)
|
||||
|
||||
```html
|
||||
<h2 id="zeiten">6e. Öffnungszeiten live auf Websites unserer Kunden</h2>
|
||||
<p>Kunden können auf ihrer eigenen Website ihre Öffnungszeiten live anzeigen lassen („Öffnungszeiten live“): als Tabelle und als Status („Jetzt geöffnet“), als Hinweis-Leiste am Seitenanfang, auf dem Handy als Kontakt-Leiste mit Knöpfen zum Anrufen, für die Route und für WhatsApp sowie als Angaben für Suchmaschinen im Seitencode. Für die Besucher dieser Websites verarbeiten wir Daten ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Angaben, die der Kunde im Kundenportal für die Funktion einträgt, für die E-Mails an den Kunden und für seine Bestellung. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer eine Kundenwebsite besucht, findet die Informationen zum Aufruf (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website.</p>
|
||||
<p>Im Kundenportal speichern wir, was der Kunde für die Funktion einträgt: seine Öffnungszeiten (benannte Zeitpläne, Zeiten je Wochentag, Abweichungen an Feiertagen, Betriebsurlaub und Sondertage sowie das gewählte Bundesland), Hinweise für die Hinweis-Leiste (Text, Art, Zeitraum und gegebenenfalls ein Knopf mit Link oder Telefonnummer), Kontaktangaben für die Kontakt-Leiste (Telefonnummer, Anschrift für die Route und auf Wunsch WhatsApp-Nummer und Starttext der Nachricht, dazu der Zeitpunkt, zu dem der Kunde bestätigt hat, den WhatsApp-Hinweis in seine Datenschutzerklärung aufgenommen zu haben), die Anzeige-Einstellungen, die unser Team für die Website des Kunden setzt, sowie Zeitpunkt und Urheber (Kunde oder Mitarbeiter) der letzten Änderung. Das sind überwiegend Betriebsdaten; personenbezogene Daten enthalten sie nur, soweit der Kunde sie einträgt, zum Beispiel die Telefonnummer eines Inhabers. Was auf der Website erscheinen soll (Zeiten, Hinweise, Telefonnummer, Anschrift, WhatsApp-Nummer, Name des Betriebs), ist dort öffentlich sichtbar. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wie bei den übrigen Portal-Daten (Abschnitt 6). Unsere Mitarbeiter können die Zeiten im Auftrag des Kunden ansehen und ändern (Abschnitt 6); den Haken für den WhatsApp-Hinweis setzen sie nicht. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7.</p>
|
||||
<p>Hat der Kunde die Funktion eingeschaltet, erinnern wir ihn per E-Mail an die E-Mail-Adresse seines Kundenkontos vor Feiertagen: Die E-Mail nennt die Tage der nächsten sieben Tage, an denen ein Feiertag seines Bundeslands liegt oder an denen wir um Prüfung bitten, und fragt, ob seine Zeiten für diese Tage stimmen. Für jeden Tag senden wir höchstens eine Erinnerung; dafür speichern wir den Tag als Merker (ohne weitere Angaben) und löschen den Merker nach 30 Tagen. Außerdem senden wir dem Kunden E-Mails zum Stand der Funktion (eingeschaltet, Ende des Wartungs-Abos mit der Möglichkeit, die Funktion zu behalten, abgeschaltet). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Abschnitt 8). Die E-Mails enthalten keine Werbung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, weil die Erinnerung zur Funktion gehört.</p>
|
||||
<p>Beim Aufruf einer Kundenwebsite mit Öffnungszeiten live lädt der Browser ein kleines Programm von unserem Server (project.webklar.com) und die Öffnungszeiten des Kunden. Das Programm ruft die Daten einmal je Seitenaufruf ab und danach höchstens alle zehn Minuten erneut. Dabei gelten die Protokolle in Abschnitt 3. Das Programm setzt keine Cookies, speichert nichts im Browser des Besuchers und lädt nichts von anderen Anbietern. Die Knöpfe „Route“ und „WhatsApp“ sind Links: Erst der Klick des Besuchers öffnet Google Maps oder WhatsApp; vorher übertragen wir nichts an diese Dienste, und danach gelten deren Datenschutzhinweise. Zum Schutz vor Missbrauch begrenzen wir die Abrufe je IP-Adresse (höchstens 120 je Minute); dafür merken wir uns die Zeitpunkte der Abrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.</p>
|
||||
<p>Löschfristen: Haben Sie die Funktion im Wartungs-Abo ohne Aufpreis eingeschaltet und endet das Wartungs-Abo, läuft sie noch 30 Tage weiter; in dieser Zeit können Sie sie für einmalig 30 € behalten. Andernfalls schalten wir sie danach ab, bewahren Ihre Eingaben noch weitere 30 Tage auf und löschen sie dann; beginnt in dieser Zeit wieder ein Wartungs-Abo, schalten wir die Funktion mit den vorhandenen Eingaben wieder ein. Haben Sie die Funktion für 30 € bestellt, bleiben Ihre Eingaben, solange die Funktion läuft. Schalten wir sie auf Ihren Wunsch ab, löschen wir Ihre Eingaben auf Ihr Verlangen, spätestens mit der Löschung Ihres Kundenkontos. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
|
||||
```
|
||||
|
||||
## Ergänzung Abschnitt 6 (Mitarbeiter im Kundenauftrag)
|
||||
|
||||
Heute steht in Abschnitt 6 (wörtlich, `public/datenschutz.html`; der Satz ist durch `legal_datenschutz_kundenansicht` in `test/legalTexts.test.js` gepinnt):
|
||||
|
||||
> Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.
|
||||
|
||||
Vorgeschlagen:
|
||||
|
||||
> Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern sowie, wenn Sie Öffnungszeiten live nutzen, Ihre Öffnungszeiten, Hinweise und Kontaktangaben ansehen und ändern.
|
||||
|
||||
Der folgende Satz („Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.“) und das Protokoll (Mitarbeiter und Zeitpunkt, 12 Monate) bleiben unverändert und decken auch das Speichern der Zeiten durch Mitarbeiter ab (Protokollaktion `zeiten_gespeichert`). Beim Übernehmen die beiden Pins in `test/legalTexts.test.js` (`legal_datenschutz_kundenansicht`) auf den neuen Satz umstellen.
|
||||
|
||||
## Folgeänderung Abschnitt 7 (Nachweis beim Einschalten)
|
||||
|
||||
Abschnitt 7 nennt heute die Funktionen und den Klick auf „Zahlungspflichtig bestellen“. Beim Einschalten im Wartungs-Abo gibt es keine Zahlung und keine Stripe-Sitzung, gespeichert wird der Nachweis der Zustimmung trotzdem. Heute (wörtlich):
|
||||
|
||||
> Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.
|
||||
|
||||
Vorgeschlagen (Ergänzung der Klammer und des Klicks; der Rest bleibt):
|
||||
|
||||
> Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner, Öffnungszeiten live) oder schalten Sie Öffnungszeiten live im Wartungs-Abo ein, speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“ oder „Einschalten“, bei einer Bestellung die Stripe-Sitzung, jeweils die IP-Adresse und die Browserkennung sowie Ihr Konto und die angemeldete Person.
|
||||
|
||||
Diese Folgeänderung steht nicht in Spec 9 Nr. 3; sie ergibt sich daraus, dass der Gratisweg einen Nachweis speichert. Gegen den Code geprüft (`speichereNachweisAusAnfrage` in `server/services/featureConsents.js`, Zeilen 196 und 197): Der Gratisweg speichert IP-Adresse und Browserkennung genauso wie die Bestellung; nur die Stripe-Sitzung entfällt (ohne `sessionId` trägt der Nachweis keine). Der Satz nennt deshalb die Stripe-Sitzung nur bei der Bestellung, IP-Adresse und Browserkennung bei jeder Zustimmung.
|
||||
|
||||
## Offene Punkte für die Rechtsrunde
|
||||
|
||||
1. **Rolle bei Websites ohne Abo oder bei Fremdhosting.** Der Entwurf sagt wie 6c: Wir sind Auftragsverarbeiter der Kunden für die Besucherdaten (Server-Protokolle). Mit Abo deckt das Anlage 1 der Abo-Bedingungen, ohne Abo oder bei Fremdhosting besteht kein Auftragsverarbeitungsvertrag (gleiche Lücke wie bei der News-Pinnwand). Frage `ZQ1` in `zeiten-kanzlei-fragen.md`.
|
||||
2. **Löschung bei Abschaltung durch das Team** (Spec Abschnitt 5: keine automatische Löschung bei Grund `team`). Der Entwurf sagt „auf Verlangen, spätestens mit der Löschung des Kundenkontos“. Soll stattdessen eine feste Frist gelten (zum Beispiel 30 Tage wie beim Ende des Abos)? Entscheidung des Inhabers; dann auch Leistungsbeschreibung Nr. 9 Abs. 5 und das Programm (`abschalten.js`) anpassen.
|
||||
3. **Erinnerungs-E-Mail.** Rechtsgrundlage Art. 6 Abs. 1 lit. b DSGVO (Teil der Funktion). Es ist keine Werbung; ob sie wegen des Hinweises „bei Google auch eintragen“ als Werbung gilt, ist nicht anzunehmen, steht aber in der Kanzlei-Frage `ZQ6`.
|
||||
4. **Nachweis beim Einschalten** (Folgeänderung Abschnitt 7, siehe oben): beantwortet. Beim Einschalten im Wartungs-Abo werden IP-Adresse und Browserkennung ebenfalls gespeichert, nur die Stripe-Sitzung entfällt (der Satz ist entsprechend gefasst). Offen bleibt allein die rechtliche Prüfung des Satzes.
|
||||
5. **Kontaktdaten öffentlich.** Die Telefonnummer und Anschrift stehen im Seitencode der Kundenwebsite (Abschnitt 6e, zweiter Absatz). Das ist die Entscheidung des Kunden; die Leistungsbeschreibung (Nr. 6 Abs. 3) verlangt, nur veröffentlichungsfähige Angaben einzutragen.
|
||||
@@ -0,0 +1,43 @@
|
||||
# K02 und K03: Block `funktionen` mit Öffnungszeiten live (Entwurf)
|
||||
|
||||
Entwurf für die Rechtsrunde (Spec „Öffnungszeiten live“, Abschnitte 7 und 9; Task 33), Stand 05.10.2026. **Keine Codeänderung:** Der Block `funktionen` in `server/mail-templates/K02 Kuendigungsbestaetigung.html` und `K03 Eingang Kuendigung.html` ist Rechtstext mit Pins; dieser Entwurf schlägt den neuen Satz nur vor. Der Inhaber entscheidet, die Kanzlei sieht ihn mit der Mailrunde (Spec 9: Oberflächen- und Mailtexte). Erst danach werden die Vorlagen und die Pins geändert.
|
||||
|
||||
Beide Vorlagen sind Kündigungsbestätigungen an den Kunden und stehen in der Sie-Form. Der Block erscheint nur beim Wartungs-Abo (`server/routes/abo.js`: K02 mit `funktionen: isWartung(sub)`, K03 mit `funktionen: !storage && isWartung(sub)`), nicht bei Hosting-Abo und nicht bei der Kündigung von Zusatzspeicher.
|
||||
|
||||
## Heute (gepinnt)
|
||||
|
||||
Der Block besteht aus diesem Satzpaar, in beiden Vorlagen wörtlich gleich:
|
||||
|
||||
> Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.
|
||||
|
||||
Pins, die der Satz heute hat:
|
||||
|
||||
- `test/mailTemplates.test.js`, Test „K02, K03: Block funktionen nennt Terminplaner und Löschfrist, nur mit Block (in K03 nur innerhalb von abo)“: prüft das ganze Satzpaar mit einem regulären Ausdruck (beginnend mit „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.“ bis „… ansehen und exportieren.“), dass der Block ohne `funktionen` fehlt und dass „Terminplaner“ bei Hosting-Abo und bei Speicher-Kündigung nirgends steht.
|
||||
- `test/publicForms.test.js`, Konstante `FUNKTIONEN_SATZ` (nur der erste Satz, „… und enden mit ihm.“).
|
||||
- Die Vorlagen-Kommentare (Zeile 12 beider Vorlagen) beschreiben den Block als „Hinweis, dass Website-Analyse und Terminplaner mit dem Wartungs-Abo enden“.
|
||||
|
||||
## Vorschlag
|
||||
|
||||
Der heutige Satz bleibt unverändert der Anfang (damit beide Pins ohne Änderung grün bleiben); angehängt wird ein Satz für Öffnungszeiten live, der die 30 Tage zum Behalten und die 30 Tage Datenfrist nach Z11 und Leistungsbeschreibung Nr. 9 nennt:
|
||||
|
||||
> Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren. Haben Sie Öffnungszeiten live im Wartungs-Abo eingeschaltet, läuft die Funktion nach dem Ende des Abos noch 30 Tage weiter; in dieser Zeit können Sie sie im Kundenportal für einmalig 30 € behalten. Behalten Sie sie nicht, schalten wir sie danach ab, auf Ihrer Website stehen dann wieder feste Öffnungszeiten, und Ihre Eingaben löschen wir 30 Tage später.
|
||||
|
||||
Warum so:
|
||||
|
||||
- Der Satz ist bedingt („Haben Sie … eingeschaltet“), weil der Block bei jeder Kündigung eines Wartungs-Abos erscheint, auch bei Kunden ohne die Funktion.
|
||||
- Wer die Funktion für 30 € bestellt hat, behält sie ohne Abo (Leistungsbeschreibung Nr. 9 Abs. 2 letzter Satz); für ihn gilt der Satz nicht, was die Bedingung „im Wartungs-Abo eingeschaltet“ ausdrückt. Ein eigener Satz dazu wäre in einer Kündigungsbestätigung verwirrend.
|
||||
- Die Einzelheiten (Frist mit Datum, Knopf „Behalten“) sagt K29 („Wartungs-Abo beendet“), sobald das Ende des Abos erkannt ist; K02 und K03 sagen nur, dass es das gibt.
|
||||
- „Abschalten“ bedeutet: Hinweis-Leiste, Kontakt-Leiste, Status und Google-Angaben verschwinden, die feste Ersatz-Tabelle der Website bleibt stehen (Leistungsbeschreibung Nr. 9 Abs. 4).
|
||||
|
||||
Im HTML ändert sich nur der Inhalt der Zelle zwischen `<!-- WENN funktionen -->` und `<!-- ENDE funktionen -->`; Aufbau und Blockkommentare bleiben. Die Kopfkommentare beider Vorlagen sagen dann „Website-Analyse, Terminplaner und Öffnungszeiten live“.
|
||||
|
||||
## Alternative
|
||||
|
||||
Ein eigener Block `zeiten` (nur für Kunden, bei denen die Funktion im Abo läuft): kein Text für Kunden ohne Funktion und die Zeile steht ohne „Haben Sie …“. Dafür braucht das Programm in `server/routes/abo.js` an beiden Stellen eine zusätzliche Abfrage der Funktion, beide Vorlagen einen neuen Block, und die Pins im Mail-Pin-Test (K01 bis K30) ändern sich. Nur sinnvoll, wenn der Inhaber den bedingten Satz nicht möchte.
|
||||
|
||||
## Beim Übernehmen
|
||||
|
||||
1. Vorlagen K02 und K03 ändern (Zelle des Blocks, Kopfkommentar).
|
||||
2. `test/mailTemplates.test.js` (K02/K03-Test): zusätzlicher Wortlaut-Pin für den neuen Satz; die bestehenden Prüfungen bleiben.
|
||||
3. Den Mail-Pin-Test (K01 bis 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.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Fragen an die Kanzlei: Öffnungszeiten live (⚖)
|
||||
|
||||
Stand 05.10.2026, Entwurf (Spec „Öffnungszeiten live“, Abschnitt 9, und Task 33). Dies sind die Fragen zu den Texten dieser Funktion; sie ergänzen das Kanzlei-Paket des Terminplaners (`docs/rechtliches/termine-kanzlei-paket.md`, Fragen Q1 bis Q26).
|
||||
|
||||
**Warum eine eigene Datei.** Das Kanzlei-Paket des Terminplaners liegt auf dem Zweig `feature/terminplaner-tp2` und ist in diesen Zweig (`feature/oeffnungszeiten`) noch nicht eingeflossen; ändern wir es hier, entsteht beim Zusammenführen ein Konflikt. Die Fragen tragen deshalb die Kennung `ZQ1` bis `ZQ12`. Beim Zusammenführen: als `Q27` bis `Q38` in Abschnitt 4 des Kanzlei-Pakets übernehmen, die Fundstellen in Abschnitt 5 ergänzen, die Fassung der Leistungsbeschreibung in Abschnitt 3.4 daneben stellen. In veröffentlichten Dateien stehen keine ⚖-Marker.
|
||||
|
||||
Jede Frage hat vier Teile wie im Paket des Terminplaners: **Frage** (was wir wissen möchten), **Fundstelle im Text** (wo der Wortlaut steht), **Unsere Annahme** (der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren), **Alternative** (was wir stattdessen täten). Einordnungen wie „Annahme“ sind keine Rechtsurteile. Die Texte, um die es geht: `public/vertragsunterlagen/zeiten-leistungsbeschreibung.html` (Leistungsbeschreibung, im Folgenden „LB“), `docs/rechtliches/zeiten-datenschutz-6e-entwurf.md` (Abschnitt 6e), `docs/rechtliches/zeiten-k02-k03-entwurf.md` (K02/K03), `docs/funktionen/website-datenschutz-absatz.md` (Website-Absatz) und die Haken im Register (`server/config.js`, `features.zeiten`).
|
||||
|
||||
Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block F) → Leistungsbeschreibung → Datenschutz § 6e/§ 6 und Website-Absatz → Oberflächen- und Mailtexte → diese Fragen ans Kanzlei-Paket → Inhaber lädt die Fassungen in WOMS „Rechtliches“ hoch → Schalter `ZEITEN_ENABLED=on`.
|
||||
|
||||
### ZQ1: ⚖ AVV Funktionen per Haken?
|
||||
|
||||
**Frage.** Für Öffnungszeiten live verarbeitet WEBklar beim Aufruf einer Kundenwebsite Besucherdaten (IP-Adresse, Zeitpunkt, Browserangaben in Server-Protokollen, 14 Tage). Mit einem Wartungs-Abo deckt Anlage 1 der Abo-Bedingungen (Auftragsverarbeitung, Server-Protokolle der Website) das ab. Beim 30-€-Weg ohne Abo oder bei Websites, die woanders liegen, gibt es keinen Auftragsverarbeitungsvertrag; das ist dieselbe Lücke wie bei der News-Pinnwand. Soll sie mit einem zusätzlichen Haken „AVV Funktionen“ beim Bestellen und Einschalten geschlossen werden (wie der Haken für den AVV Terminplaner und den AVV Website-Analyse), und genügt ein gemeinsamer AVV „Funktionen“ für News-Pinnwand und Öffnungszeiten live? Oder ist WEBklar für die Protokolle des eigenen Servers ohnehin selbst Verantwortlicher, sodass kein AVV nötig ist?
|
||||
|
||||
**Fundstelle im Text.** Spec Abschnitt 9 Nr. 5; Register `features.zeiten.avv: null`; Abschnitt 6e Absatz 1 (Rolle als Auftragsverarbeiter) und Offener Punkt 1; Website-Absatz Variante B (`[Prüfen: AVV mit WEBklar erforderlich]`); LB Nr. 12 Abs. 2.
|
||||
|
||||
**Unsere Annahme.** Mit Abo genügt Anlage 1. Ohne Abo und bei Fremdhosting braucht es einen AVV; wir würden einen kurzen AVV „Funktionen“ per Haken einführen (Aufbau und Nummern wie AVV Terminplaner Nr. 4 bis 9, ohne Termindaten, Unterauftragsverarbeiter nur Hetzner).
|
||||
|
||||
**Alternative.** Nichts ändern, weil die IP-Adresse in Server-Protokollen beim Abruf eines Skripts von `project.webklar.com` in der Verantwortung des Betreibers dieses Servers liegt; oder Öffnungszeiten live nur mit Wartungs-Abo oder bei WEBklar-Hosting verkaufen; oder der Rahmen-AVV mit Anlage je Funktion (TP2 Spec 7.4 „Später“).
|
||||
|
||||
### ZQ2: Behalten-Zustimmung nutzt den Haken „Einbau beginnt“
|
||||
|
||||
**Frage.** Endet das Wartungs-Abo, kann der Kunde Öffnungszeiten live im Kundenportal für einmalig 30 € behalten (LB Nr. 9 Abs. 2). Der Weg „Behalten“ verlangt dieselbe Zustimmung wie die Neubestellung, also auch den zweiten Haken (`beginn`): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Beim Behalten gibt es keinen Einbau; die Funktion läuft bereits, es entsteht kein neues Ticket. Passt der Haken dann? Ist das Behalten ein eigener Vertrag mit eigenem Widerrufsrecht für Verbraucher (Frist ab dem Klick), und was ist die „Leistung“, deren vollständige Erbringung das Widerrufsrecht erlöschen lässt (der Weiterbetrieb ohne Abo hat kein Ende)?
|
||||
|
||||
**Fundstelle im Text.** Register `features.zeiten.consent.items` (Haken `leistung` und `beginn`); Programm: `POST /api/funktionen/zeiten/behalten` (`pruefeZustimmung(feature, body)`: „die der Bestellung, beide Haken“), `server/services/zeiten/behalten.js`; Spec Abschnitt 3.3; LB Nr. 9 Abs. 2 und Nr. 11.
|
||||
|
||||
**Unsere Annahme.** Der Haken ist für das Behalten unschädlich, weil er nur das Verlangen des Beginns und die Kenntnis vom Erlöschen bestätigt. Der Wortlaut „mit dem Einbau“ ist aber ungenau; wir würden für das Behalten „mit der Leistung“ oder „mit dem Weiterbetrieb“ sagen. Der Widerruf eines Behalten-Vertrags führt zur Abschaltung (LB Nr. 11 Abs. 3).
|
||||
|
||||
**Alternative.** Ein eigener Haken für das Behalten („Ich verlange, dass die Funktion nach dem Ende meines Wartungs-Abos ohne Unterbrechung weiterläuft …“); oder kein Beginn-Haken, wenn für das Behalten kein Widerrufsrecht besteht (Kunde ist Unternehmer, Leistung sofort).
|
||||
|
||||
### ZQ3: Beginn-Haken der bezahlten Bestellung: Wertersatz-Satz und Fassung
|
||||
|
||||
**Frage.** Der Haken `beginn` der Bestellung für 30 € trug zunächst nur das Zitat zum Erlöschen des Widerrufsrechts. Nach der Abschlussprüfung ist der Satz zum Wertersatz ergänzt, wörtlich wie in den Haken der News-Pinnwand und des Terminplaners (TP2 Q3 und Q5). Er lautet jetzt: „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Die Fassung der Zustimmung ist dafür auf `2026-10-05` gesetzt (die übrigen Funktionen-Unterlagen tragen ebenfalls `2026-10-05`; die Gratis-Zustimmung `consentFrei` und die LB bleiben bei `2026-10-04`, weil ihr Text unverändert ist). Bitte bestätigen: (a) Genügt der Satz in dieser Form, auch mit „der Leistung“ statt „des Einbaus“ im ersten Satzteil (bei der News-Pinnwand steht „des Einbaus“)? (b) Dürfen die Fassungen je Funktion unterschiedlich nummeriert sein, oder soll der Nachweis eine gemeinsame Fassung der Funktionen-Unterlagen tragen? Die LB nennt den Wertersatz weiterhin in Nr. 11 Abs. 2.
|
||||
|
||||
**Fundstelle im Text.** Register `features.zeiten.consent` (Fassung `2026-10-05`, Haken `leistung` und `beginn`) und `consentFrei` (Fassung `2026-10-04`); LB Nr. 11 Abs. 2; Widerrufsbelehrung der Funktionen (Hinweise zum Widerruf, Wertersatz als Anteil am Gesamtpreis); Test `test/zeitenRegister.test.js` pinnt den Wortlaut und prüft, dass der Satz mit dem der News-Pinnwand und des Terminplaners übereinstimmt.
|
||||
|
||||
**Unsere Annahme.** Der Wertersatz-Satz gehört wie beim Terminplaner in den Haken (Frage Q3 des Terminplaners gilt entsprechend) und ist jetzt enthalten; der Wortlaut bleibt Entwurf bis zur Freigabe, danach wird die Fassung auf den Tag der Freigabe gesetzt (Pins in `test/zeitenRegister.test.js` mitziehen).
|
||||
|
||||
**Alternative.** Den Satz wieder aus dem Haken nehmen und nur in der Belehrung und der LB lassen; eine gemeinsame Fassung für alle Funktionen.
|
||||
|
||||
### ZQ4: Haftung, ausgeschrieben für Kunden ohne Abo (LB Nr. 8)
|
||||
|
||||
**Frage.** Die LB schreibt die Haftung aus, statt auf § 13 der Abo-Bedingungen zu verweisen, weil Kunden ohne Wartungs-Abo die Abo-Bedingungen nicht haben. Nr. 8 Abs. 4 und 5 geben § 13 Abs. 1 und 2 wörtlich wieder, Abs. 6 den Satz zugunsten der Mitarbeiter und Beauftragten (§ 13 Abs. 5) und den Hinweis, dass der Abschnitt auch für Kunden ohne Abo gilt. Abs. 1 bis 3 sagen: Die Live-Anzeige ist ein Hilfsmittel, die Richtigkeit der Angaben liegt beim Kunden, Fehler in Feiertagsdaten und Softwarefehler beseitigt WEBklar nach Meldung, für entgangenen Gewinn und Folgeschäden (etwa weil ein Besucher wegen einer Fehlanzeige vor verschlossener Tür steht) gelten Abs. 4 und 5. Fragen: (a) Ist die Klausel gegenüber Verbrauchern mit § 309 Nr. 7 BGB vereinbar und hinreichend transparent? (b) Braucht es gegenüber Unternehmern einen Höchstbetrag? (c) Fehlen § 13 Abs. 3 (Datenverlust, Wiederherstellungsaufwand) und Abs. 4 (kein Schadensersatz nach § 536a Abs. 1 BGB für anfängliche Mängel) für Kunden ohne Abo? (d) Ist die Zusage „Fehler beseitigt WEBklar in angemessener Frist“ (Nr. 7 Abs. 1, Nr. 8 Abs. 2) für Kunden ohne Abo zu weit?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 7 und Nr. 8; Abo-Bedingungen § 13 (`public/abo-bedingungen.html`); der Test `test/zeitenRecht.test.js` hält den Wortlaut beider Klauseln gleich; TP2 Q8 (Haftung für verpasste Termine).
|
||||
|
||||
**Unsere Annahme.** Die Spiegelung von § 13 Abs. 1 und 2 genügt; ein Höchstbetrag ist nicht nötig, weil Abs. 2 den Schaden auf den vertragstypischen, vorhersehbaren begrenzt. Die Abs. 3 und 4 sind für diese Funktion nicht nötig (die Funktion speichert keine Daten Dritter; ein Schadensersatz für anfängliche Mängel ist bei einem Einmalpreis von 30 € kein praktisches Thema).
|
||||
|
||||
**Alternative.** Abs. 3 und 4 des § 13 ebenfalls ausschreiben; einen Höchstbetrag (zum Beispiel den Preis der Funktion oder den Jahresbetrag des Wartungs-Abos) gegenüber Unternehmern festlegen.
|
||||
|
||||
### ZQ5: Ende mit dem Abo: 30 Tage zum Behalten, Abschalten, Rückkehr (LB Nr. 9)
|
||||
|
||||
**Frage.** Wer Öffnungszeiten live im Wartungs-Abo ohne Aufpreis eingeschaltet hat, behält die Funktion nach dem Ende des Abos noch 30 Tage; in dieser Zeit kann er sie für einmalig 30 € behalten, sonst schaltet WEBklar sie ab (auf der Website bleibt die feste Ersatz-Tabelle), die Eingaben bleiben noch weitere 30 Tage und werden dann gelöscht; beginnt in dieser Zeit wieder ein Wartungs-Abo, läuft die Funktion mit den Eingaben weiter. Wer sie für 30 € bestellt hat, behält sie auch ohne Abo. Fragen: (a) Ist die Kopplung der Gratis-Funktion an das Abo mit dieser Frist zulässig, obwohl sie ein „freiwilliges Extra“ ist und die Abo-Bedingungen unverändert bleiben (Z19)? (b) Darf die Rückkehr bei neuem Abo ohne neue Zustimmung automatisch erfolgen? (c) Frist und Anker der Löschung (30 Tage nach dem Abschalten) und der Sicherungsüberhang von sieben Tagen. (d) Bei Abschaltung durch WEBklar auf Wunsch des Kunden oder aus anderem Grund als dem Ende des Abos gibt es im Programm keine automatische Löschung; die LB sagt „auf Verlangen, spätestens mit dem Löschen des Kundenkontos“ (Nr. 9 Abs. 5). Genügt das, oder braucht es eine feste Frist? (e) Gehört das einmalige Aktualisieren der Ersatz-Tabelle beim Abschalten (Nr. 9 Abs. 4) zu einer Leistung, die WEBklar auch nach dem Ende des Abos schuldet?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 9 Abs. 1 bis 5, Nr. 4 Abs. 3; Abschnitt 6e (Löschfristen) und Offener Punkt 2; K02/K03 (`zeiten-k02-k03-entwurf.md`); Programm: `server/services/zeiten/aboAbgleich.js` (30 Tage Frist zum Behalten, 30 Tage Datenfrist), `abschalten.js`; Spec Z11 und Abschnitt 5; Parallele: TP2 Q7 und Q9 (Terminplaner endet mit dem Abo).
|
||||
|
||||
**Unsere Annahme.** Ja zu (a) bis (c): Der Kunde hat die Funktion nur durch das Abo ohne Aufpreis; die Frist zum Behalten und die Datenfrist sind angemessen, die Rückkehr stellt den früheren Stand wieder her. Zu (d) genügt „auf Verlangen“, weil die Eingaben Betriebsdaten sind und der Kunde die Löschung jederzeit verlangen kann. Zu (e) ja, weil es zum Einbau gehört, den der Preis enthält (Z20).
|
||||
|
||||
**Alternative.** Löschung auch bei Abschaltung durch das Team nach 30 Tagen (das Programm müsste dafür angepasst werden); keine Frist zum Behalten (Abschaltung mit dem Ende des Abos); Rückkehr nur auf Antrag.
|
||||
|
||||
### ZQ6: Mails zur Funktion: Erinnerung vor Feiertagen und Zustandsmails
|
||||
|
||||
**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 6e Absatz 3; LB Nr. 2 Abs. 2; Mailvorlagen K27 bis K30 (`server/mail-templates/`), Spec Abschnitt 7 (K28: eine Mail für alle neuen Tage, Merker, 08:00 bis 20:00 Uhr Berliner Zeit, kein BCC ans Team).
|
||||
|
||||
**Unsere Annahme.** Die Erinnerung ist Teil der vertraglichen Leistung (LB Nr. 2 Abs. 2) und deshalb eine Vertragsmail ohne Einwilligung; der Hinweis auf Google dient der Durchführung (die Zeiten dort aktuell zu halten), nicht der Werbung.
|
||||
|
||||
**Alternative.** Hinweis auf Google aus 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)
|
||||
|
||||
**Frage.** Die LB folgt in Nr. 10 der Einstellungsregel der Website-Analyse (Nr. 8 dort): Beenden oder Einstellen mit vier Wochen oder drei Monaten nur bei Rechtslage oder nicht behebbarer Sicherheitslücke (Abs. 2), zusätzlich nach Abs. 4 **ohne** besonderen Grund mit drei Monaten gegenüber Unternehmern, die die Funktion im Wartungs-Abo ohne Aufpreis haben; der Kunde kann das Abo dann innerhalb von 30 Tagen zum Einstellungszeitpunkt kündigen, auch vor Ablauf der Mindestlaufzeit. Wer die Funktion für 30 € bestellt hat, kann sie nur aus den Gründen von Abs. 2 verlieren (Abs. 5). Fragen: (a) Ist die Einstellung ohne Grund für ein kostenloses Zusatzangebot zulässig, auch wenn die Funktion im Abo „enthalten“ beworben wird (Z4)? (b) Ändert das Sonderkündigungsrecht in Abs. 4 die Abo-Bedingungen so, dass das Verfahren nach § 15 nötig wäre (Z19: freiwilliges Extra, keine Änderung der Abo-Bedingungen)? (c) Braucht es bei Einstellung einer gekauften Funktion durch WEBklar eine (anteilige) Erstattung des Preises (vgl. TP2 Q8)?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 10 Abs. 1 bis 5; Abo-Bedingungen §§ 3 Abs. 7, 8, 15; LB der Website-Analyse Nr. 8 (`docs/rechtliches/leistungsbeschreibung-website-analyse.md`); Spec Abschnitt 9 Nr. 1 („Einstellung/Änderung nach Vorbild Analyse Nr. 8“).
|
||||
|
||||
**Unsere Annahme.** Ja zu (a) und (b) wie bei der Analyse: Das Sonderkündigungsrecht stellt den Kunden besser; die Abo-Bedingungen bleiben unverändert. Zu (c): Bei Einstellung aus den Gründen von Abs. 2 erstattet WEBklar nichts, weil die Funktion bis dahin lief; die Rechte aus § 327r BGB bleiben.
|
||||
|
||||
**Alternative.** Abs. 4 streichen (Einstellung nur aus Gründen); bei gekauften Funktionen anteilig erstatten.
|
||||
|
||||
### ZQ8: Website-Absatz: WhatsApp und Google Maps erst nach Klick, Haken als Nachweis
|
||||
|
||||
**Frage.** (a) Der Absatz „Öffnungszeiten auf dieser Website“ (Variante A und B) sagt, dass die Knöpfe „Route“ und „WhatsApp“ einfache Links sind, dass Google Maps und WhatsApp erst nach dem Klick geöffnet werden und dass vorher nichts an Google oder Meta übertragen wird; das Skript setzt keine Cookies und speichert nichts im Browser. Genügt das, oder braucht es für diese Links eine Einwilligung (§ 25 TDDDG, Art. 6 Abs. 1 lit. a DSGVO)? (b) Der Kunde bestätigt im Portal mit einem Haken, dass er den „WhatsApp-Hinweis“ (Baustein WhatsApp) in seine Datenschutzerklärung aufgenommen hat; der Zeitpunkt wird gespeichert, Mitarbeiter setzen den Haken in der Kundenansicht nicht. Reicht der Haken als Nachweis? (c) Wenn WEBklar den Absatz beim Einbau in die Datenschutzerklärung einfügt (LB Nr. 6 Abs. 4), ist das Einfügen eines Mustertextes eine Rechtsdienstleistung (§ 2 RDG)? Das ist dieselbe Regel wie TP2 Q18 und soll für alle Funktionen gleich bleiben. (d) Die Angaben im Seitencode (strukturierte Daten zu den Öffnungszeiten) enthalten keine Daten von Besuchern; ist ein Satz dazu nötig?
|
||||
|
||||
**Fundstelle im Text.** `docs/funktionen/website-datenschutz-absatz.md` (Variante A und B, Bausteine Route und WhatsApp); LB Nr. 2 Abs. 4, Nr. 6 Abs. 3 und 4; Abschnitt 6e Absatz 4; Programm: `public/embed/zeiten.js` (keine Cookies, kein Storage, keine Anfragen an Dritte; Links erst beim Klick), Spec Z23 (Route öffnet Google Maps erst beim Klick).
|
||||
|
||||
**Unsere Annahme.** (a) Ein Link, den der Besucher anklickt, ist keine Übertragung durch WEBklar oder den Kunden vor dem Klick; eine Einwilligung ist nicht nötig. (b) Der Haken genügt als Nachweis. (c) Wie TP2 Q18: Technik und Mustertext, keine Rechtsdienstleistung im Einzelfall. (d) Ein Satz genügt, wie im Entwurf.
|
||||
|
||||
**Alternative.** Vor dem ersten Klick einen Hinweis einblenden („Sie verlassen diese Website“); die Schaltfläche „WhatsApp“ nur auf ausdrücklichen Wunsch einschalten.
|
||||
|
||||
### ZQ9: Vertragsrahmen ohne Abo (LB Nr. 1 Abs. 3)
|
||||
|
||||
**Frage.** Für Kunden ohne Wartungs-Abo gilt die LB „allein“. Sie regelt Leistung, Preis, Haftung, Ende, Einstellung und Widerruf, verweist für den Widerruf auf die Belehrung der Funktionen und für Datenschutz auf die Datenschutzerklärung. Fehlen Regeln, die sonst die Abo-Bedingungen liefern (Zahlung und Zahlungsverzug, Mängelrechte für digitale Dienstleistungen §§ 327 ff. BGB, Verjährung, Gerichtsstand, Textform, Änderungen der Bedingungen)? Sollen die Abo-Bedingungen auch für Kunden ohne Abo teilweise vereinbart werden (Verweis auf bestimmte Paragrafen), oder reichen die gesetzlichen Regeln?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 1 Abs. 3, Nr. 4 (Zahlung über Stripe), Nr. 7 Abs. 1 (Mängel in angemessener Frist), Nr. 8 (Haftung ausgeschrieben); Spec Abschnitt 9 Nr. 1 und Z19.
|
||||
|
||||
**Unsere Annahme.** Die gesetzlichen Regeln genügen für eine Einmalleistung von 30 €; die Haftung ist wegen der AGB-Kontrolle ausgeschrieben.
|
||||
|
||||
**Alternative.** Ausgewählte Paragrafen der Abo-Bedingungen (Zahlung, Mängel, Textform, Änderungen) in die LB aufnehmen oder auf sie verweisen.
|
||||
|
||||
### ZQ10: Hinweis zu Preisangaben in der Hinweis-Leiste
|
||||
|
||||
**Frage.** Der Editor zeigt beim Anlegen eines Hinweises, dessen Text „%“ oder „statt“ enthält, einen Hinweis zu Preisangaben (Endpreise, bei Waren der niedrigste Preis der letzten 30 Tage). LB Nr. 6 Abs. 2 verpflichtet den Kunden, Preise und Rabatte in Hinweisen rechtmäßig anzugeben, zum Beispiel als Endpreise und „bei Preisermäßigungen für Waren, mit dem niedrigsten Preis der letzten 30 Tage“; Nr. 12 Abs. 1 schließt eine Rechtsberatung aus. Sind diese Aussagen zutreffend und ausreichend, insbesondere die Beschränkung der 30-Tage-Regel auf Waren (Preisangabenverordnung) und die Abgrenzung, dass die Angaben des Kunden nicht von WEBklar geprüft werden (Nr. 8 Abs. 1)? Braucht es einen Hinweis zur Preisangabe auch für Leistungen, die Dienstleistungen sind?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 6 Abs. 2, Nr. 8 Abs. 1, Nr. 12 Abs. 1; Spec Abschnitt 3.4 Nr. 4 (Hinweis-Leiste); die genaue Zeile im Portal-Editor steht in `public/zeiten-leisten.js`.
|
||||
|
||||
**Unsere Annahme.** Der Hinweis im Editor und die Pflicht in der LB reichen als Warnung; die Prüfung bleibt beim Kunden. Die Zitate zur Preisangabenverordnung werden mit der Freigabe geprüft.
|
||||
|
||||
**Alternative.** Keinen Hinweis im Editor, nur die Pflicht in der LB; oder den Hinweis auf Preisermäßigungen insgesamt ausweiten.
|
||||
|
||||
### ZQ11: Aktivierung durch das Team ohne Zustimmung des Kunden
|
||||
|
||||
**Frage.** Das Team kann Öffnungszeiten live im WOMS für einen Kunden einschalten (Aktion „Aktivieren“, auch aus dem Zustand „abgeschaltet“). Das Programm speichert dabei keinen Zustimmungsnachweis (`einschaltenZeiten` mit `by: 'team'` ohne `nachweis`; Spec Abschnitt 5 und Ruling R32 lassen die Zustimmung für diesen Weg entfallen). Die LB setzt aber voraus, dass der Kunde den Haken setzt und auf „Einschalten“ oder „Zahlungspflichtig bestellen“ klickt (Nr. 1 Abs. 1). Beim Terminplaner verlangt TP2 für die Aktivierung durch das Team die Zustimmung des Kunden in Textform (F13a, `textformFuerAktivierung` in `server/routes/admin/funktionenAdmin.js` auf dem Zweig `feature/terminplaner-tp2`, Zeile 99: `if (key !== 'termine') return null`; die Prüfung gilt also nur für `termine`, und dort nur im Modus „on“, nicht für Pilotadressen). Soll diese Regel auf `zeiten` ausgeweitet werden (Kunde bestätigt in Textform, das Team vermerkt es), oder genügt es, dass die Funktion im Abo ohne Aufpreis ein freiwilliges Zusatzangebot ist und der Kunde die Aktivierung durch das Team ausdrücklich wünscht (Chat, Telefon)? Gilt das auch, wenn das Team ein Dokument wieder einschaltet, das der Kunde einmal gekauft hat?
|
||||
|
||||
**Fundstelle im Text.** LB Nr. 1 Abs. 1 und 2; Spec Abschnitt 5 (Team-Aktivierung), Ruling R32; Programm: `server/services/zeiten/einschalten.js` (`by: 'team'`), `server/routes/admin/funktionenAdmin.js` (`zeitenAktivieren`); TP2 F13a (Textform bei Aktivierung durch das Team, nur Terminplaner).
|
||||
|
||||
**Unsere Annahme.** Für die Gratis-Funktion genügt der ausdrückliche Wunsch des Kunden, den das Team im Feld „Notiz“ vermerkt; ein Zustimmungsnachweis fehlt dann bewusst. Bei einem Kunden ohne Wartungs-Abo wäre die Textform sauberer.
|
||||
|
||||
**Alternative.** Die Textform-Prüfung von TP2 auch für `zeiten` verlangen (Aktivieren nur mit Hinweis „Kunde hat in Textform zugestimmt“ und Notiz); die Aktivierung durch das Team nur für Kunden mit laufendem Wartungs-Abo zulassen.
|
||||
|
||||
### ZQ12: Pflichtinformationen als Haken und in K15
|
||||
|
||||
**Frage.** Beim Terminplaner gibt es neben `beginn` den Haken `information` („Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung …, Gesamtpreis …, voraussichtliche Dauer …“; `server/config.js`, `features.termine.consent`, TP2 Q20), und K15 (Bestätigung der Bestellung) trägt die Pflichtinformationen als Anhang. Für Öffnungszeiten live gibt es beides nicht: Das Register hat nur die Haken `leistung` und `beginn`, und K15 trägt für diese Funktion keine Vertragsunterlagen (der Block `anlagen` mit Fassung, Links und PDF-Anhang entfällt, weil das Register weder `avv` noch `widerruf` hat und `unterlagenAdressen` in `server/services/siteFeatures.js` dann `null` liefert; die Mail nennt nur Funktion, Preis und Dauer im Text). Genügt das für einen Verbrauchervertrag im elektronischen Geschäftsverkehr (§ 312d BGB, Art. 246a EGBGB, § 312j BGB: Information vor Vertragsschluss, Bestätigung auf einem dauerhaften Datenträger), weil die LB und die Bestellseite die Pflichtinformationen enthalten, oder braucht es wie beim Terminplaner einen eigenen Haken `information` und die Pflichtinformationen als Anhang oder Abschnitt der K15?
|
||||
|
||||
**Fundstelle im Text.** Register `features.zeiten.consent` (Haken `leistung`, `beginn`) gegen `features.termine.consent` (Haken `information`); Bestellseite `public/zeiten-bestellen.js` (Preis, Belehrung, Leistungsbeschreibung vor dem Knopf); K15 (`server/mail-templates/K15 Bestellung Funktion.html`, `bestellMail` und `unterlagenAdressen` in `server/services/siteFeatures.js`); LB Nr. 2 bis 4 und 11; TP2 Q20.
|
||||
|
||||
**Unsere Annahme.** Die Bestellseite zeigt Leistung, Preis, Umsatzsteuerhinweis, Widerrufsbelehrung und LB-Link vor dem Knopf; das genügt für die vorvertragliche Information. Ob die Bestätigungsmail (K15) die Pflichtinformationen und die Belehrung zusätzlich tragen muss (Textform auf einem dauerhaften Datenträger), ist offen; für den Terminplaner hat TP2 es mit dem Block `anlagen` gelöst.
|
||||
|
||||
**Alternative.** Wie beim Terminplaner: Haken `information` im Register (Fassung hochzählen) und die Pflichtinformationen als Abschnitt in K15.
|
||||
|
||||
## Nicht Teil der Kanzleiprüfung
|
||||
|
||||
- Der Hinweis in der Hinweis-Leiste („Aktion“ verlangt ein Enddatum, Texte höchstens 140 Zeichen) ist Oberfläche, keine Rechtsaussage.
|
||||
- Technische Beobachtung beim Schreiben der Texte (kein Kanzlei-Thema, inzwischen behoben): Die öffentliche Widerrufsseite `widerrufen.html` kannte die Auswahl „Terminplaner (Einrichtung)“, aber keine „Öffnungszeiten live“; ein Verbraucher, der sie für 30 € bestellt hat, hätte „Funktion (z. B. News-Pinnwand)“ wählen müssen, und die Abwicklung hätte das der News-Pinnwand zugeordnet. Die Auswahl „Öffnungszeiten live“ gibt es jetzt (`ORDERS`, `FEATURE_OF_ORDER` und `orderLabel` in `server/routes/abo.js`, Radioknopf mit `data-text` in `public/widerrufen.html`).
|
||||
@@ -0,0 +1,135 @@
|
||||
# Öffnungszeiten live – Livegang-Notiz
|
||||
|
||||
Stand 05.10.2026, für Kenso. Grundlage: Spec Abschnitt 13.6, das Protokoll der Umsetzung (`.superpowers/sdd/2026-10-04-oeffnungszeiten-live/progress.md`, alle LIVEGANG-Hinweise und Rechtsrunde-Punkte) und die Gesamtprüfung (Task 37, Bericht `task-37-report.md` im selben Ordner). Nichts davon ist gepusht. Keine Rechtsberatung.
|
||||
|
||||
## Stand der drei Branches
|
||||
|
||||
| Repo | Worktree / Branch | Stand | Basis und Abstand |
|
||||
|---|---|---|---|
|
||||
| Portal (+ Runner) | `dev-zeiten`, `feature/oeffnungszeiten` | Inhalt von `90e8607` plus diese Notiz und die Fixrunde nach der Abschlussprüfung (`ecf67d6` Server, `aa9ee35` Skript und Editor, danach die Doku) | enthält TP2 bis `a629b25`; TP2 steht inzwischen auf `af63b69` (34 Commits weiter) |
|
||||
| WOMS | `ticket.webklar.com/dev-zeiten`, `feature/oeffnungszeiten` | `919d96f` (Fixrunde: Statustext der Einrichtung) auf `3d67aa8` | auf WOMS-TP2 `72eedeb`; TP2 steht inzwischen auf `4c3f67e` (5 Commits weiter) |
|
||||
| Relaunch | `webklar.com/dev-zeiten-station`, `feature/zeiten-station` | `8f5d522` | auf `main` `4139f31` (kein Abstand, Stand 05.10.) |
|
||||
|
||||
Letzte Prüfung vor der Fixrunde 05.10.2026, alles grün: Portal 4544/4545 (der eine Fehler war der bekannte wacklige `previewDevRouter`-Test, einzeln 12/12), Portal unter Node 20 (wie in Produktion) 4543/4543 ohne `analyticsContract`, Runner 177/177, UI-Prüfungen 31/31 + 8/8 + alle OK, WOMS 377/377 + `vite build`, Relaunch 349/349 + Build (Haupt-JS 128 084 Byte gz, Grenze 133 120).
|
||||
|
||||
Nach der Fixrunde (Abschlussprüfung, 05.10.2026), alles grün: Portal 4564/4564 (`npm test`), Runner 177/177, UI-Prüfungen Skript 34/34 und Portal 9/9, WOMS 378/378 + `vite build`. Der Node-20-Lauf (Schritt A.2) und die Relaunch-Prüfung (A.4) stehen für nach der TP2-Zusammenführung an, sie laufen ohnehin neu.
|
||||
|
||||
## A. Reihenfolge am Livegang-Tag
|
||||
|
||||
1. **TP2 geht zuerst live** (Portal und WOMS) nach seiner eigenen Reihenfolge (Kanzlei-Paket Abschnitt 7.6, mit `termine-setup.mjs` und `feature-setup.mjs`). Öffnungszeiten live baut auf TP2 auf und braucht dessen Schema (`aboEndeMailAt`, `disabledReason`, `featureConsents`).
|
||||
2. **Portal neu aufsetzen.** In `dev-zeiten`: das jetzt live gegangene TP2 einmal hineinführen, mit `git fetch origin && git merge origin/main`. **Nicht** `git merge main`: die lokale Ref `main` steht noch auf `f1b43ca` (vor TP2), das gäbe „Already up to date“, und der Push würde abgelehnt. Ist TP2 noch nicht in `origin/main`: `git merge feature/terminplaner-tp2`. Es gibt einen Merge statt Rebase, damit die Commit-Nummern des Protokolls gültig bleiben. Eine Probe-Zusammenführung mit TP2 `af63b69` (`git merge-tree --write-tree`) ergab im Portal drei Textkonflikte, die so zu lösen sind (bei der ersten Zusammenführung `1d5a565` überlappten 14 Dateien):
|
||||
- `server/routes/admin/funktionenAdmin.js`: unser Zeiten-Zweig (`zeitenAktivieren`, `zeitenAbschalten`, `ersatzTicket` in der Antwort) **und** TP2s Textform-/K16-Warnung (`warnung`) bleiben beide; die Antwort trägt beides.
|
||||
- `server/services/retention.js`: unser `zeitenIds` (Zeiten vor dem Abschalten des Kunden löschen) **und** TP2s `consentIds`/`featureFieldIds` bleiben beide. TP2 `9a2a70b` („Fehlschlag hält den Kunden für den nächsten Lauf“) löst vermutlich die Retention-Hälfte von N9 (eine fehlgeschlagene `loescheZeitenVonKunde` wird nicht wiederholt): bei der Konfliktlösung prüfen, ob `loescheZeitenVonKunde` bei jedem Lauf aufgerufen wird, und nicht vorher separat patchen.
|
||||
- `server/services/siteFeatures.js`: Textkonflikt, beide Seiten behalten. TP2 `b974b3a` legt `disable()` unter die Sperre `feature-activate:<id>` (Sperren sind nicht wiederbetretbar): nach der Zusammenführung prüfen, dass **kein Halter von `feature-activate` `disable()` aufruft** (`aboAbgleich` und `zeitenAbschalten` rufen es unter `feature-admin` auf: erlaubte Reihenfolge).
|
||||
Danach alles erneut laufen lassen:
|
||||
- `nice npm test` (nur `previewDevRouter` darf wackeln, dann einzeln wiederholen), `cd runner && nice npm test`
|
||||
- Node 20: `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'`
|
||||
- UI, je einzeln mit `nice` und vorher `free -m`: `test/ui/zeiten-embed.ui.mjs`, `test/ui/zeiten-portal.ui.mjs`, `test/ui/funktionen.ui.mjs`
|
||||
3. **WOMS neu aufsetzen.** In `ticket.webklar.com/dev-zeiten`: die WOMS-Hauptlinie (`test`, mit TP2) hineinführen (`git fetch origin && git merge origin/test`). Erwarteter Konflikt (1 Datei): `src/components/customer/CustomerFunktionen.jsx`; dort bleiben unser `ersatzTicket` **und** TP2s `speicherStatus`/`K16_WARNUNG` (TP2 `4c3f67e` ändert dieselben `save()`-Zeilen sowie das Ende von `src/lib/funktionenView.js` und seinen Test, das Portal fügt dieselbe POST-Route um `warnung` ein). `K16_WARNUNG` nennt einen „AVV“, den Öffnungszeiten live nicht hat: nach der Zusammenführung prüfen und für die Zeiten anpassen. Danach `nice npm test` und `nice npx vite build`. `node_modules` ist dort im Git verfolgt und wurde von `npm ci` verändert: **nie stagen**.
|
||||
4. **Relaunch prüfen.** `git log --oneline 4139f31..main` im Relaunch-Repo; gibt es neue Commits, `feature/zeiten-station` darauf aufsetzen, dann `nice npm test`, `nice npm run build` und `gzip -9 -c dist/assets/index-*.js | wc -c` (höchstens 133 120).
|
||||
5. **Schema anlegen, vor dem Portal-Deploy.** `scripts/zeiten-setup.mjs` legt `oeffnungszeiten` und `zeitenErinnerungen` an und ergänzt `portalFeatures` um `unlockedBy`, `aboEndeAt`, `einbau`, `behaltenSessionId`, `einschaltMailAt`. Seit der Fixrunde schreibt `activate()` `aboEndeAt` und `behaltenSessionId` nur noch für Öffnungszeiten live (Parameter `extra`); News und Terminplaner brauchen die Felder nicht mehr und laufen auch vor dem Skript. Trotzdem erst das Schema, dann der Deploy: die Zeiten-Pfade schreiben die Felder. Das Skript braucht den neuen Code, also aus dem Worktree in einem Wegwerf-Container (kein `docker compose run`: der Container bekäme die Traefik-Labels des Portals). Die Env-Datei enthält Zugangsdaten: mit `umask 077` anlegen, nicht erst danach `chmod`. Erst `--check` (beim ersten Lauf Exit 1, erwartet: es fehlt ja noch alles), dann ohne, dann **noch einmal `--check`** (muss grün sein; die Index-Reihenfolge der Schema-Skripte hat am 30.09. schon einmal überrascht):
|
||||
```
|
||||
(umask 077; docker inspect webklar-kundenbereich-server -f '{{range .Config.Env}}{{println .}}{{end}}' > /root/zeiten-env.tmp)
|
||||
docker run --rm --env-file /root/zeiten-env.tmp --network webklar-network -v /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten:/app:ro -w /app node:20-alpine node scripts/zeiten-setup.mjs --check
|
||||
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
|
||||
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
|
||||
rm /root/zeiten-env.tmp
|
||||
```
|
||||
(Der Aufruf ist so noch nie gegen die Produktion gelaufen; er folgt dem Muster der früheren Schema-Skripte. Das Skript ist idempotent, `--check` schreibt nichts.)
|
||||
6. **Stripe prüfen.** `node scripts/stripe-products.mjs --pruefen` (gleicher Wegwerf-Container). Erwartet wird ein Preis `webklar_zeiten_einrichtung_3000` (30 €, einmalig, `tax_behavior` inclusive, Standard-Produktbild). Fehlt er: `--anlegen`; danach `--pruefen` nochmal.
|
||||
7. **Einstellungen, vor dem Push.** Zuerst Sicherungen: `cp /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-2026-10-05-zeiten` und `(umask 077; cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-2026-10-05-zeiten)` (`.env` enthält Zugangsdaten). Dann in `/home/webklar/apps/.env`: `ZEITEN_ENABLED=pilot`, `ZEITEN_PILOT_EMAILS=kenso.gri@gmail.com`, `ZEITEN_KEIN_NEUBAU=emmas-schatzkiste`. In `/home/webklar/apps/docker-compose.yml`, Dienst `kundenbereich-server`, drei Zeilen neben `TERMINE_ENABLED`: `- ZEITEN_ENABLED=${ZEITEN_ENABLED:-off}`, `- ZEITEN_PILOT_EMAILS=${ZEITEN_PILOT_EMAILS:-}`, `- ZEITEN_KEIN_NEUBAU=${ZEITEN_KEIN_NEUBAU:-emmas-schatzkiste}`. Prüfen mit `docker compose -f /home/webklar/apps/docker-compose.yml config -q` (keine Ausgabe = gültig), dann übernehmen mit `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Ein bloßes `docker restart` liest neue Umgebungswerte nicht. Das alles **vor dem Push** (Schritt 9): dann startet der Container mit dem alten Code und den neuen Werten (die kennt er nicht, harmlos), und der Neustart durch `deploy.sh` nach dem Push ist der einzige mit dem neuen Code (jeder Containerstart führt `apk add` und `npm install` aus).
|
||||
8. **Runner: kein Image-Bau nötig.** Der Runner ist `node:20-alpine` mit eingehängtem `build/runner` (das Protokoll sagt „Image neu bauen“, das stimmt für die echte Konfiguration nicht). `deploy.sh` startet ihn neu, **nur wenn sich `runner/` in der Deploy-Spanne ändert** und die Warteschlange leer ist. `ZEITEN_PUBLIC_BASE` nicht setzen: die Vorgabe `https://project.webklar.com/embed/` stimmt. **Nach dem Push prüfen:** `docker inspect -f '{{.State.StartedAt}}' webklar-preview-runner` muss nach dem Push liegen (vorher 2026-10-02T16:17Z); sonst von Hand `docker restart webklar-preview-runner` (das Deploy-Log im Container `webklar-kundenbereich-build` wird täglich um 00:00 rotiert und ist als Beleg unzuverlässig; „naechster Deploy versucht es erneut“ stimmt nicht, ohne Änderung an `runner/` kommt kein Neustart). Ein alter Runner verwirft das neue Feld `zeiten` stillschweigend, der Einbau meldete dann „angestoßen“, setzt aber nichts ein.
|
||||
9. **Pushen, in dieser Reihenfolge** (du pushst, ich nicht). Die Ziel-Ref steht immer dabei (`<lokaler Branch>:<Ziel>`), damit nichts unter dem falschen Namen ankommt:
|
||||
1. Portal samt Runner: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-zeiten && git push origin feature/oeffnungszeiten:main`. **Der Portal-Webhook deployt jeden gepushten Branch** (`webhook-server.js` setzt `DEPLOY_BRANCH` aus `payload.ref`, `deploy.sh` macht `reset --hard origin/<branch>`): den Feature-Branch nie unter seinem eigenen Namen pushen (`git push origin feature/oeffnungszeiten`), das würde ihn auf die Produktion bringen.
|
||||
2. WOMS erst **nach** dem Portal, sonst antwortet das Portal auf die neuen Zeiten-Aktionen mit 404: `cd /home/webklar/apps/ticket.webklar.com/dev-zeiten && git push origin feature/oeffnungszeiten:test`. Der WOMS-Webhook deployt nur `test` (`TICKET_DEPLOY_BRANCH`, Vorgabe `test`; Pushes auf andere Branches ignoriert er); `test` also nie vor dem Portal. Danach `setup-legal.mjs` (legt die Rechtliches-Karte `leistungsbeschreibung-zeiten` an, Schlüssel ≤ 36 Zeichen): **ungetestet gegen die Produktion**, zuerst `node scripts/setup-legal.mjs --dry-run` im WOMS-Worktree, dann `APPWRITE_API_KEY=… APPWRITE_ENDPOINT=… node scripts/setup-legal.mjs`. Der Schlüssel (`APPWRITE_API_KEY`) ist nötig; der Standard-Endpoint des Skripts ist `https://appwrite.webklar.com/v1`, das WOMS-Frontend nutzt in der Vorgabe `https://ticket.webklar.com/v1`: den Endpoint vorher gegen den ausliefernden Container prüfen und ausdrücklich setzen.
|
||||
3. Relaunch: `cd /home/webklar/apps/webklar.com/dev-zeiten-station && git push origin feature/zeiten-station:main`. Die Station „Öffnungszeiten live“ steht dort auf `bald: true` („Bald verfügbar“) und bleibt so bis Schritt C.
|
||||
10. **Live-Checks nach dem Deploy** (Portal und WOMS sind da):
|
||||
- Öffentliche Schnittstelle kennt einen unbekannten Schlüssel nicht: `curl -s https://project.webklar.com/api/public/zeiten/v1/aaaaaaaaaaaaaaaa` antwortet `{"aktiv":false}`.
|
||||
- Skript: `curl -sI https://project.webklar.com/embed/zeiten.js` ist 200, mit `Access-Control-Allow-Origin` und `Cache-Control`.
|
||||
- Leistungsbeschreibung: `curl -sI https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html` ist 200.
|
||||
- WOMS: Kunde → Reiter „Funktionen“ lädt ohne Fehler (auch für ein Projekt ohne Zeiten).
|
||||
- Portal-Log: `docker logs --since 15m webklar-kundenbereich-server 2>&1 | grep -i "unknown attribute"` bleibt leer (sonst fehlt ein Schema-Feld, Schritt 5).
|
||||
- Stripe: Webhook-Adresse und Ereignisliste im Dashboard sind unverändert (die Funktion nutzt dieselben Ereignisse wie News und Terminplaner; es kommt nichts Neues dazu).
|
||||
- **Rückweg**, falls etwas hakt: `ZEITEN_ENABLED=off` in `.env`, dann `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Die Kachel verschwindet, die Schnittstelle antwortet `aktiv: false`, die Platzhalter zeigen ihr Ersatz-HTML, der Abo-Abgleich läuft weiter. Das Schema ist rein additiv (neue Collections und Felder), es gibt nichts zurückzubauen; ein Code-Rollback ist bei `off` nicht nötig.
|
||||
11. **Pilot-Probelauf** (Abschnitt B), danach Rechtsrunde abschließen (Abschnitt D), dann Abschnitt C.
|
||||
|
||||
## B. Probelauf mit `ZEITEN_ENABLED=pilot` auf `test-website`
|
||||
|
||||
Nur dein Konto sieht die Kachel. Beachte: Das Portal prüft nur „Leistungsbeschreibung vorhanden“, nicht „freigegeben“ (`unterlagenBereit` ist für Zeiten immer wahr). Der Pilot ist deshalb der einzige Schutz vor dem Entwurfstext, bis `on` kommt.
|
||||
|
||||
1. Kachel im Funktionen-Tab sichtbar. Je nach Konto: „Einschalten“ (Wartungs-Abo aktiv) oder „30 € einmalig“. Den Bezahlweg nur mit echter 30-€-Zahlung testen (danach erstatten) oder weglassen; die Tests decken ihn ab.
|
||||
2. Einschalten: Ticket „Öffnungszeiten live einbauen“ in WOMS, Mail K27, `einbau` = `angestossen` (Runner-Log) oder `manuell`.
|
||||
3. Editor: Wochenplan mit Fenstern und Mitternacht, zweiter Plan, Bundesland und Feiertage, Urlaub (Hinweis-Vorschlag), Hinweis-Leiste, Kontakt-Leiste mit Telefon und Adresse, „Für Google kopieren“, Uhrzeit-Regler.
|
||||
4. Preview der `test-website` nach dem Neubau: im Quelltext `<script data-wk-zeiten … data-site="…">`; Hinweis-Leiste oben (schiebt den Header?), Kontakt-Leiste auf dem Handy, JSON-LD im Quelltext (kein Duplikat), Platzhalter mit Status, Tabelle und Badge; Ersatz-HTML aus WOMS („Ersatz-HTML kopieren“) in die Platzhalter. Dazu der Datenschutz-Absatz aus `docs/funktionen/website-datenschutz-absatz.md` (Entwurf) auf der Test-Seite.
|
||||
5. WOMS: Panel (Zeiten-Einstellungen `navSelektor`, Leisten aus), „Aktivieren ohne Zahlung“ und „Abschalten“ am Testprojekt; Ticket schließen ⇒ nach spätestens 5 Minuten `active` und Mail K16; Abschalten ⇒ Ticket „Ersatz-Tabelle aktualisieren“ (nur wenn der Wochenplan vom Ersatz-HTML abweicht).
|
||||
6. Mails K27 bis K30 ansehen (K28 über einen Feiertag in 1–7 Tagen, K29/K30 nur mit einem Abo-Ende; sonst über die Tests bewertet).
|
||||
7. Aufräumen: Funktion am Testprojekt abschalten, Ticket schließen. Das Abschalten durch das Team löscht **nie** Daten; die Testdaten löschst du von Hand in der Appwrite-Konsole (Datenbank des Portals): im Dokument der Funktion in `portalFeatures` (`featureKey` = `zeiten`, `projectId` = das Testprojekt) die ID notieren; in der Collection `oeffnungszeiten` das Dokument mit genau dieser ID löschen (es trägt die Eingaben, das Team-Feld und den Ersatz-Hash); in `zeitenErinnerungen` alle Dokumente mit `featureId` = dieser ID löschen (die Erinnerungs-Merker). Danach ist die Schnittstelle des Testprojekts nach spätestens 60 Sekunden (Zwischenspeicher) wieder leer. (Automatisch gelöscht wird nur beim Ende des Abos nach 30 Tagen und beim Löschen des Kundenkontos.)
|
||||
|
||||
## C. Schalter `on` (letzter Schritt)
|
||||
|
||||
Erst wenn Abschnitt B ohne offene Fehler und Abschnitt D vollständig erledigt ist:
|
||||
|
||||
1. `ZEITEN_ENABLED=on` in `.env`, `docker compose up -d kundenbereich-server`.
|
||||
2. Relaunch: in `src/components/home/Kundenportal.tsx` den Eintrag `oeffnungszeiten` auf `bald: false` (die Marke „Bald verfügbar“ verschwindet). **Dabei den Test anpassen**: `relaunch-kundenportal.test.tsx` (um Zeile 142 bis 156) pinnt genau zwei „Bald verfügbar“; nach dem Umschalten ist es eines weniger. Dann Tests und Build, du pushst. Das Handy-Bild `phone-12-zeiten` zeigt den Einleitungstext des Editors (und einen laufenden Urlaubs-Hinweis): nach der Textrunde **neu aufnehmen**, bevor `bald: false` live geht.
|
||||
3. Logs beobachten (`[zeiten]`, `[zeiten-jobs]`): Abo-Abgleich täglich, Feiertags-Erinnerung stündlich zwischen 08:00 und 20:00.
|
||||
4. Zurück auf `off`: Kachel verschwindet, die öffentliche Schnittstelle antwortet `aktiv: false`, die Platzhalter zeigen ihr Ersatz-HTML; der Abo-Abgleich läuft weiter.
|
||||
|
||||
## D. Rechtsrunde und Texte (Voraussetzung für `on`)
|
||||
|
||||
Reihenfolge laut Spec Abschnitt 9: gemeinsame TP2-Bausteine, Leistungsbeschreibung, Datenschutz, Oberflächen- und Mailtexte, Fragen an die Kanzlei, Uploads, `on`.
|
||||
|
||||
1. **Gemeinsame TP2-Bausteine** (Block F: Widerrufsbelehrung, AVV) zuerst.
|
||||
2. **Leistungsbeschreibung** `public/vertragsunterlagen/zeiten-leistungsbeschreibung.html`: Entwurf („ENTWURF – vor Livegang Rechtsrunde“ im Kopf). Wortlaut-Punkte der Gesamtprüfung: Nr. 4 Abs. 2 „sobald das Wartungs-Abo aktiv ist“; Nr. 1 Abs. 1 (wann die Vereinbarung bei Team-Aktivierung entsteht, siehe ZQ11); Nr. 3 Abs. 2 „oder stehen sollen“ streichen; Nr. 3 Abs. 3 „bestätigt den Einbau als abgeschlossen“ und „Tag, der nicht geschlossen ist“; Nr. 8 Abs. 7 zweiter Satz unklar; „Behalten nach Abschaltung“. Danach Fassung festlegen (Register `leistungsbeschreibung.version` und `consent.version` hochzählen, Tests nachziehen).
|
||||
3. **Datenschutz Portal § 6e (6d ist seit dem Webshop vergeben)** (`docs/rechtliches/zeiten-datenschutz-6e-entwurf.md`) in `public/datenschutz.html` einbauen, mit dem Anker `id="zeiten"`, plus der Satz in § 6 (Öffnungszeiten im Kundenauftrag). **Er muss live sein, bevor der Link der Leistungsbeschreibung funktioniert.** Danach `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), Prüfsumme nachziehen, `test/legalTexts.test.js` und `test/zeitenRecht.test.js` anpassen. Wortlaut-Punkte: „Ihre Eingaben“ gegen „der Kunde“, „keine Werbung“ gegen ZQ6.
|
||||
4. **Widerrufsbelehrung für das 30-€-Produkt freigeben.** Die Bestellseite muss genau `featureHaken('zeiten').belehrung()` vor dem Knopf zeigen (Ruling R32), sonst belegt der Nachweis einen Text, den der Kunde nie sah.
|
||||
5. **Wertersatz-Satz** (§ 357a Abs. 2 BGB) im Beginn-Haken der bezahlten Bestellung, wie bei News und Terminplaner (ZQ3): im Code ergänzt (Fixrunde, Fassung `2026-10-05`); der Wortlaut bleibt Entwurf bis zur Rechtsrunde, danach die Fassung auf den Tag der Freigabe setzen (Pins in `test/zeitenRegister.test.js`).
|
||||
6. **Widerruf-Seite:** in der Fixrunde ergänzt (`public/widerrufen.html`, `server/routes/abo.js` mit `ORDERS`, `FEATURE_OF_ORDER`, `orderLabel`); ein zahlender Verbraucher, der widerruft, wird jetzt den Öffnungszeiten zugeordnet. Nichts mehr zu tun, außer beim Livegang die Seite einmal zu öffnen.
|
||||
7. **Fragen an die Kanzlei:** `docs/rechtliches/zeiten-kanzlei-fragen.md` (ZQ1 bis ZQ12) ins Kanzlei-Paket des Terminplaners als Q27 ff. übernehmen (AVV Funktionen per Haken, Behalten-Haken, Wertersatz, Haftung ohne Abo, Ende mit dem Abo, Mails, Einstellung bei Unternehmern, Website-Absatz, Vertragsrahmen ohne Abo, Preisangaben in der Hinweis-Leiste, Aktivierung durch das Team ohne Zustimmung, Pflichtinformationen als Haken und in K15).
|
||||
8. **K02/K03:** Entwurf für den Block `funktionen` in `docs/rechtliches/zeiten-k02-k03-entwurf.md`. Nach der Freigabe die Vorlagen ändern und die Pins nachziehen (`test/mailTemplates.test.js`, `test/publicForms.test.js` `FUNKTIONEN_SATZ`).
|
||||
9. **Website-Absatz** `docs/funktionen/website-datenschutz-absatz.md` (Varianten A/B, Baustein Route und WhatsApp nur wenn die Leiste die Knöpfe zeigt): freigeben; der Einbau auf einer Kundenseite erst, wenn er in deren Datenschutzerklärung steht.
|
||||
10. **K15 für bezahlte Öffnungszeiten** trägt keine Vertragsunterlagen und keine Belehrung als Anhang (dauerhafter Datenträger): entscheiden, ob nötig (Frage ZQ12 zusammen mit dem Haken `information`).
|
||||
11. **Mailtexte K27 bis K30** und die Texte im Editor in deiner Textrunde: K28 sagt „deine Zeiten gelten normal“, obwohl die Seite an gesetzlichen Feiertagen „geschlossen“ zeigt, wenn du nichts änderst; K27-Variante „wieder eingeschaltet“ (ohne „per Ticket“ / „von Hand“); T19-Text spricht von „Einrichtung“; `disabledMessage` schreibt „du“ klein; eigene Texte des Editors (Preishinweis, Vorschau); Betreffzeile K29 nennt „Wartungs-Abo“ (so gewollt).
|
||||
12. **Uploads:** die Fassungen (Leistungsbeschreibung, Datenschutzerklärung) in WOMS „Rechtliches“ hochladen (Karte `leistungsbeschreibung-zeiten`).
|
||||
|
||||
## E. Offene Code-Punkte (Checkliste für die letzte Fixrunde)
|
||||
|
||||
Mit ☐ markiert, was noch im Code offen ist; die Fixrunde des Controllers kann einzelne schließen (dann hier abhaken).
|
||||
|
||||
**Vor `on` nötig**
|
||||
|
||||
- [x] N1 Wertersatz-Satz im Beginn-Haken `features.zeiten.consent` (`server/config.js`), Fassung `2026-10-05`, Pins in `test/zeitenRegister.test.js` (Fixrunde `ecf67d6`). Der Wortlaut bleibt Entwurf (ZQ3).
|
||||
- [x] N2 „Öffnungszeiten live“ in `ORDERS`, `FEATURE_OF_ORDER` und im Bestell-Label von `server/routes/abo.js`, im Radioknopf von `public/widerrufen.html` und in den Pins (`widerrufen_html_radio`, neuer Test `widerruf_zeiten`); `public/legal-form.js` braucht keine Änderung, es liest `data-text` (Fixrunde `ecf67d6`).
|
||||
- [ ] N3 Rechtstexte aus den Entwürfen übernehmen (Abschnitt D, Nr. 2 und 3) und den Entwurfskommentar der Leistungsbeschreibung entfernen.
|
||||
- [ ] N4 Entscheidung zu Anhängen in K15 für Zeiten (D, Nr. 10); K02/K03-Block (D, Nr. 8).
|
||||
|
||||
**Härtung (vor `on` empfohlen, für den Pilot nicht nötig)**
|
||||
|
||||
- [x] N5 Website-Skript: im JSON-LD `<` als `\u003c` schreiben (`jsonLdText`, Test); Kopf-Elemente bekommen bei Scroll ≥ Leistenhöhe ihren eigenen Inline-`top` zurück (UI-Prüfung `fester_header_versteckt_beim_scrollen`); dazu späte Platzhalter (Einzelseiten-Apps) über einen entprellten MutationObserver (Fixrunde `aa9ee35`).
|
||||
- [x] N6 Abo-Abgleich: K30 gilt als offen, solange `aboEndeMailAt` vor `disabledAt` liegt (das Zurücksetzen nach `disable()` entfällt); K30 geht erst nach dem Ersatz-Ticket raus, scheitert es, wiederholt der nächste Lauf beides (K30 so höchstens einmal); `activatedAt` wird bei der Löschung geleert (Fixrunde `ecf67d6`).
|
||||
- [ ] N7 Team: Sperre `zeiten-ersatz:<docId>` gegen zwei Ersatz-Tickets (paralleles Abschalten und Abgleich); `PATCH …/zeiten/team` nur für `setup`/`active`/`disabled` (409 sonst); `createFeatureTicket` mit `fallbackDetails` für das lange Ersatz-Ticket.
|
||||
- [x] N8 WOMS „Ticket nachlegen“ bei Abo- oder Team-Zeiten ohne `ticketId`: das Portal geht über `holeZeitenNach` (Fixrunde `ecf67d6`). Offen bleiben die Texte „keine Zahlung vermerkt“ (bei gekauften Zeiten ohne Zahlungsvermerk) und der 409 der Abnahme („noch keine Öffnungszeiten eingetragen“).
|
||||
- [ ] N9 Löschen: in `loescheFunktion` erst die Merker, dann das Dokument; fehlgeschlagene Löschung in `retention.js` beim nächsten Lauf wiederholen.
|
||||
- [ ] N10 `heileZeiten` in `try/finally`, wenn `activateClosedTickets` wirft.
|
||||
- [ ] N13 Nach dem Löschen (Abo-Ende + 30 Tage, Kundenlöschung) setzt `loesche()` keinen Rückgabe-Marker wie TP2 (`purchaseStatus: 'zurueckgesetzt'`): eine von Stripe erneut zugestellte `checkout.session.completed` einer früher bezahlten Zeiten-Bestellung würde neu einrichten (Ticket, K15, T16). Im Pilot praktisch ausgeschlossen (Merge-Bericht 06.10.2026, Punkt 3).
|
||||
|
||||
**Qualität (kann nach `on`)**
|
||||
|
||||
- [ ] N11 Zeitlogik: `wechselListe` um Schwellen (Ende minus 24 h und minus 7 Tage) ergänzen (Texte wie „bis Sonntag Mitternacht“ bleiben sonst den ganzen Sonntag stehen); Mitternachts-Einträge nicht zusammenfassen („heute“ hinkt sonst bis zu 10 Minuten nach).
|
||||
- [ ] N12 Oberfläche: Portal-Knopf auf dem Desktop 40 px hoch (Ziel 44 px); Relaunch-Stationsliste kann bei 721–780 px Fensterhöhe überlaufen (Schwelle prüfen).
|
||||
|
||||
**Weitere Punkte der Fixrunde nach der Abschlussprüfung (erledigt)**
|
||||
|
||||
- [x] `activate()` schreibt die Zeiten-Felder nur noch über `extra` (Abo-Rückkehr und Team-Aktivierung der Zeiten): News und Terminplaner brechen auch vor dem Schema-Skript nicht mehr mit „Unknown attribute“.
|
||||
- [x] Die Einschalten-Route (`/api/funktionen/zeiten/einschalten`) nimmt nur `{ zustimmung }`, nicht die Altform `{ consent: true }` ohne Fassungsprüfung.
|
||||
- [x] Die Team-Aktivierung behält `kauf` und `unlockedByPurchase`, wenn das Dokument vorher gekauft war (die Widerrufs-Zuordnung bleibt richtig; `einbau` bleibt dabei stehen, weil für gekaufte Dokumente das Nachholen nicht neu baut).
|
||||
- [x] WOMS: „Leisten live – Einbau läuft“ nur bei `einbau` = `angestossen`, sonst „Einbau läuft (Team setzt die Skript-Zeile von Hand)“ (`919d96f`).
|
||||
- [x] Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar; Website-Skript: Links mit Zugangsdaten werden verworfen, die Adresse der Schnittstelle bestimmt nur das eigene Script-Tag.
|
||||
|
||||
## F. Bewusste Abweichungen und Hinweise
|
||||
|
||||
- Schema der neuen `portalFeatures`-Felder liegt in `scripts/zeiten-setup.mjs` (nicht in `feature-setup.mjs`), weil dessen Tests genau drei Attribute festschreiben.
|
||||
- Das Angebot liefert `GET /api/funktionen` als `zeiten` auf oberster Ebene plus `zeitenStatus` je Projekt (die Spec nennt `zeitenAngebot` je Projekt); die Kachel entscheidet nach `inklusive` und `grund` (Ruling R20).
|
||||
- Der Editor einer abgeschalteten Funktion (`disabled`) ist nur lesbar (Spec Z13 sagt „nie gesperrt“); die Website zeigt dann ohnehin nichts mehr.
|
||||
- Der Block `funktionen` in K02/K03 ist bewusst nur ein Entwurf (Rechtstext mit Pins).
|
||||
- Schalter aus (`off`): `GET /api/public/zeiten/v1/<siteKey>` antwortet `aktiv: false`; die Jobs für Abo-Abgleich laufen unabhängig vom Schalter (ein beendetes Abo muss abschalten und löschen können).
|
||||
- Wie im Terminplaner-Livegang: den Code zu einer ruhigen Zeit deployen.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,587 @@
|
||||
# Öffnungszeiten live (Funktion im Kundenportal) – Design
|
||||
|
||||
> **Hinweis 2026-10-06:** Die Mails dieser Funktion heißen inzwischen K27 (eingeschaltet), K28 (Feiertage prüfen), K29 (Abo beendet) und K30 (abgeschaltet), weil K24–K26 seit dem Webshop-Livegang vergeben sind; das Feld `k24At` heißt `einschaltMailAt`. Dieses Dokument behält die alten Nummern.
|
||||
|
||||
Stand: 2026-10-04 · Status: vom Inhaber im Chat freigegeben (Abschnitte 1–5), Spec zur Durchsicht
|
||||
Branch `feature/oeffnungszeiten` (Worktree `dev-zeiten`), aufgesetzt auf `feature/terminplaner-tp2` (6ae0f67).
|
||||
WOMS: eigener Worktree auf `feature/terminplaner-tp2` des WOMS-Repos (6f03712), Branch `feature/oeffnungszeiten`.
|
||||
Ideenfindung und Bewertung: `/root/reports/funktionen-ultra-update-2026-10-04/ideen.md` (21 Ideen, 3 Jurys),
|
||||
Code-Fakten: `/root/reports/funktionen-ultra-update-2026-10-04/design-fakten.md`; Spec-Prüfung gegen den Code
|
||||
(19 Punkte) eingearbeitet.
|
||||
Keine Rechtsberatung – Texte gehen vor Livegang in die Rechtsrunde (Abschnitt 9).
|
||||
|
||||
## Ziel
|
||||
|
||||
Die „Killer-Funktion“ des Ultra-Updates im Funktionen-Tab. Der Kunde trägt seine Öffnungszeiten **einmal**
|
||||
im Portal ein. Seine Website zeigt daraufhin überall dieselben Zeiten und einen Live-Status („Jetzt geöffnet ·
|
||||
schließt um 18 Uhr“). Feiertage des Bundeslands sind schon eingerechnet, Betriebsurlaub und Sondertage
|
||||
ebenso. Dazu kommen eine zeitgesteuerte Hinweis-Leiste oben auf der Seite, eine Kontakt-Leiste auf dem Handy
|
||||
(Anrufen, Route, WhatsApp) und Google-Daten im Seitencode. Eine Mail erinnert vor Feiertagen, und ein Knopf
|
||||
gibt die Zeiten für das Google-Unternehmensprofil aus. Alles läuft auf dem eigenen Server, ohne
|
||||
Google-Cloud-Projekt und ohne Cookies.
|
||||
|
||||
## Entscheidungen (vom Inhaber)
|
||||
|
||||
| # | Entscheidung |
|
||||
|---|---|
|
||||
| Z1 | Eine einzelne Killer-Funktion statt eines Pakets: **Öffnungszeiten live** (mit Hinweis-Leiste). |
|
||||
| Z2 | Umfang V1: Kern (Wochenplan mit bis zu 2 Fenstern/Tag, Live-Status, Feiertage des Bundeslands, Ausnahmen) **plus** Hinweis-Leiste, mobile Kontakt-Leiste, Feiertags-Erinnerung mit Google-Hilfe, JSON-LD. |
|
||||
| Z3 | Google nur ohne Google Cloud. Automatisches Schreiben ins Unternehmensprofil braucht die Business-Profile-API (Cloud-Projekt, Zugangsantrag mit bis zu 14 Tagen Prüfung, vorher 0 Anfragen/Minute, Profil ≥ 60 Tage bestätigt mit Website). Deshalb V1 als Umweg: Kopieren in Googles Reihenfolge + Link zur Profilverwaltung + Erinnerung. Echter Abgleich ist eine mögliche spätere Stufe. |
|
||||
| Z4 | Preis: **30 € einmalig** für jeden Kunden; mit **laufendem Wartungs-Abo ohne Aufpreis enthalten**. |
|
||||
| Z5 | Einbau **hybrid**: Hinweis-Leiste, Kontakt-Leiste und JSON-LD kommen über das Skript, das der Runner beim Deploy einsetzt; Zeiten-Tabelle/Status/Badge setzt das Team per WOMS-Ticket an die richtige Stelle und ersetzt die fest eingetragenen Zeiten. |
|
||||
| Z6 | **Mehrere benannte Zeitpläne** je Website; der Hauptplan steuert Live-Status und Leisten. |
|
||||
| Z7 | Abo-Kunden **schalten selbst ein**; der Klick legt das Einbau-Ticket an. |
|
||||
| Z8 | Launch-Zugabe: nur eine **Live-Station auf der Relaunch-Seite**, keine Ankündigungsmail, kein Portal-Banner. |
|
||||
| Z9 | Ansatz A: **eigenes Modul** (eigene Collection, eigene öffentliche Schnittstelle, eigenes Skript), kein allgemeiner Inhalts-Baukasten, nicht aus den Terminplaner-Zeiten abgeleitet. |
|
||||
| Z10 | Gebaut **auf dem TP2-Branch** (Funktions-Register `config.features`); live zusammen mit oder direkt nach TP2. |
|
||||
| Z11 | Abo endet bei Gratis-Freischaltung: Mail mit „für 30 € behalten“, nach **30 Tagen** ohne Zahlung abgeschaltet, Daten noch 30 Tage, auf der Seite bleibt die Ersatz-Tabelle. |
|
||||
|
||||
Vom Inhaber in den Abschnitten 1–5 mitgetragene Festlegungen, die ich vorgeschlagen habe:
|
||||
|
||||
| # | Festlegung |
|
||||
|---|---|
|
||||
| Z12 | Bezahlen ist gesperrt, solange ein Wartungs-Abo läuft und nicht gekündigt ist; ein gekündigtes Abo führt auf den 30-€-Weg. |
|
||||
| Z13 | Der Editor ist ab Bestellen/Einschalten offen und wird nie gesperrt. |
|
||||
| Z14 | Hinweis-Leiste **schiebt den Inhalt** (liegt nicht darüber); kein Schließen-Knopf (keine Speicherung im Browser). |
|
||||
| Z15 | Kontakt-Leiste bleibt automatisch aus, wenn die Seite schon eine eigene feste Anruf-Leiste hat. |
|
||||
| Z16 | JSON-LD erzeugt das Skript im Browser: vorhandener LocalBusiness-Knoten wird ergänzt, sonst ein kleiner Block angelegt. Kein Neubau je Zeitenänderung. |
|
||||
| Z17 | In jedem Platzhalter bleibt eine **statische Ersatz-Tabelle** stehen (ohne JavaScript, nach Abschaltung). Sie zeigt den Stand beim Einbau; Pflege siehe 4.5. |
|
||||
| Z18 | Erinnerung **7 Tage** vorher, eine Mail für alle Tage im Fenster; Heiligabend, Silvester und landesweit geführte, aber örtlich nicht überall geltende Feiertage als „bitte prüfen“. |
|
||||
| Z19 | „Im Wartungs-Abo enthalten“ ist ein **freiwilliges Extra**, keine Änderung der Abo-Bedingungen. |
|
||||
| Z20 | Im Preis (und im Abo ohne Anrechnung aufs Stundenkontingent) ist **ein Einbau je Website** enthalten; Sonderwünsche nach Angebot/Stunden. |
|
||||
| Z21 | Relaunch-Station: 4. Eintrag der Funktionen-Gruppe, „Bald verfügbar“ bis zum Portal-Livegang, kein Preis, Uhr-Regler bedienbar mit Automatiklauf. |
|
||||
| Z22 | Schalter `ZEITEN_ENABLED=off|pilot|on` mit Pilotliste `ZEITEN_PILOT_EMAILS` wie beim Terminplaner. |
|
||||
| Z23 | „Route“ öffnet Google Maps (`https://www.google.com/maps/dir/?api=1&destination=…`, erst beim Klick). |
|
||||
|
||||
## 1. Bausteine
|
||||
|
||||
1. **Register-Eintrag `zeiten`** in `config.features` (TP2): Katalogpreis `zeitenSetup` (3000 Cent, einmalig),
|
||||
`requiresWartungsAbo: false`, `ticketFreischaltung: true`, neu `aboInklusive: true`, `consentFrei`
|
||||
(Zustimmung für den Gratisweg), `teilweiseLive: ['setup', 'active']`; Eintrag in `STANDARD_HAKEN`
|
||||
(`featureRegistry.js`) mit `verfuegbar` (Schalter/Pilot) und `freischaltbar` (4.4).
|
||||
2. **Collections** `oeffnungszeiten` und `zeitenErinnerungen`; neue Felder an `portalFeatures`.
|
||||
3. **Portal-Server:** Dienst `server/services/zeiten/` (Zeitlogik, Speichern, öffentliche Antwort, Freischalten,
|
||||
Abgleich, Erinnerung), Kundenrouten `server/routes/zeiten.js`, öffentliche Route
|
||||
`server/routes/publicZeiten.js`, Gratisweg `POST /api/funktionen/zeiten/einschalten`, Behalten-Checkout.
|
||||
4. **Portal-Oberfläche:** Kachel im Funktionen-Tab (`zeitenAngebot` in `GET /api/funktionen`) und
|
||||
Editor-Ansicht `public/zeiten*.js`.
|
||||
5. **Website-Skript** `public/embed/zeiten.js` (ohne Abhängigkeiten).
|
||||
6. **Runner:** zweite Einsetzung neben der Analyse, Feld `zeiten` im Build-Auftrag.
|
||||
7. **WOMS:** Funktionen-Register, Team-Aktionen und -Einstellungen, Protokoll-Label, Rechtliches-Karte.
|
||||
8. **Mails** K24–K27, Register-Texte für K15/K16/T16, Team-Ticket „Ersatz-Tabelle aktualisieren“.
|
||||
9. **Rechtstexte** (Abschnitt 9) und **Relaunch-Station** (Abschnitt 10, eigenes Repo).
|
||||
|
||||
## 2. Daten
|
||||
|
||||
### 2.1 `oeffnungszeiten` (neu)
|
||||
|
||||
Dokument-ID = ID des zugehörigen `portalFeatures`-Dokuments (TP2-Konvention, `featureDocId(projectId,
|
||||
'zeiten')`). Felder: `customerId`, `projectId`, `featureId`, `version` (Integer), `daten` (JSON-String,
|
||||
≤ 32 KB), `teamVersion` (Integer), `team` (JSON-String, nur über WOMS änderbar), `ersatzHash` (String, Hash des
|
||||
Hauptplan-Wochenplans beim letzten Ersatz-HTML), `updatedAt`, `updatedBy` (`kunde` | `team:<staffId>`).
|
||||
Rechte wie die übrigen Funktions-Collections (nur Server-Key, Label-Team). Alle Schreibzugriffe laufen unter
|
||||
`withLocks(['zeiten:<projectId>'])`; Versionen werden innerhalb der Sperre geprüft und hochgezählt.
|
||||
|
||||
`daten`:
|
||||
|
||||
```
|
||||
{
|
||||
land: 'RP', // Bundesland-Kürzel (16); Vorgabe RP
|
||||
plaene: [{ id, kurz, name, haupt: true|false, // kurz = unveränderlicher Kurzname [a-z0-9-]{1,24}, im Editor sichtbar
|
||||
tage: { 1..7: { modus: 'zu'|'zeiten'|'rund'|'vereinbarung',
|
||||
fenster: [['09:00','12:30'], ['13:30','18:00']] } } }],
|
||||
feiertage: { 'YYYY-MM-DD': { modus, fenster } }, // nur Abweichungen von der Vorgabe
|
||||
ausnahmen: [{ id, art: 'urlaub'|'sondertag', von, bis, text, modus, fenster, plan: null|planId }],
|
||||
hinweise: [{ id, art: 'info'|'urlaub'|'aktion'|'wichtig', text, knopf: null|{ art: 'link'|'anruf', ziel, text },
|
||||
von, bis }], // von/bis = Kalendertage (YYYY-MM-DD), Berliner Zeit
|
||||
kontakt: { an: bool, telefon, adresse, whatsapp: null|{ nummer, starttext, bestaetigtAt } }
|
||||
}
|
||||
```
|
||||
|
||||
`team`: `{ navSelektor: null|string, kontaktleisteAus: bool, hinweisleisteAus: bool }`.
|
||||
|
||||
**Grenzen und Prüfregeln** (Server, Fehler je Feld):
|
||||
- Höchstens 5 Pläne, genau ein Hauptplan, der letzte Plan ist nicht löschbar.
|
||||
- Zeiten `HH:MM` in 5-Minuten-Schritten; je Tag ≤ 2 Fenster ohne Überschneidung; `bis == von` ungültig;
|
||||
`bis < von` (über Mitternacht) nur beim letzten Fenster des Tages und nur, wenn es nicht in das erste
|
||||
Fenster des Folgetags hineinragt.
|
||||
- Ausnahmen ≤ 100, Hinweise ≤ 20; Urlaub `von ≤ bis`, höchstens 366 Tage; „Aktion“ braucht `bis`.
|
||||
Vergangene Ausnahmen/Hinweise und vergangene `feiertage`-Schlüssel (> 30 Tage) entfernt der Server beim
|
||||
Speichern.
|
||||
- Längen: Hinweistext ≤ 140, Plan-/Ausnahmetext ≤ 60, Knopftext ≤ 30, Starttext ≤ 200, Adresse ≤ 200,
|
||||
Telefon ≤ 30, Link ≤ 300 Zeichen.
|
||||
- `knopf.ziel` bei `link`: `new URL` muss gelingen, nur `https:`, ohne Benutzer/Passwort; bei `anruf`: nur
|
||||
Ziffern, Leerzeichen, `+`, `/`, `-`. Telefon und WhatsApp-Nummer werden auf Ziffern (mit führendem `49`
|
||||
für deutsche Nummern) normalisiert.
|
||||
- `navSelektor` (nur Team): ≤ 120 Zeichen, nur `[A-Za-z0-9 #.\-_>:\[\]="']`, kein `<`, `{`, `}`, `;`.
|
||||
- Schreib-Limit 60 Speichervorgänge/Stunde je Kunde (`writeLimiter`).
|
||||
|
||||
### 2.2 `zeitenErinnerungen` (neu)
|
||||
|
||||
Dokument-ID = sha1(`featureId|datum`) gekürzt; Felder `featureId`, `datum`, `createdAt`. Anlegen mit 409 =
|
||||
„schon erinnert“. Der Tagesjob löscht Merker, deren Datum > 30 Tage zurückliegt.
|
||||
|
||||
### 2.3 `portalFeatures` (Erweiterung, Schema-Skript `feature-setup.mjs`)
|
||||
|
||||
- `unlockedBy` (String 8): `kauf` | `abo` | `team`. `unlockedByPurchase` bleibt und wird abgeleitet
|
||||
(`kauf` ⇒ true, sonst false).
|
||||
- `aboEndeAt` (Datetime): Zeitpunkt, an dem der Abgleich das Ende des Abos erkannt hat.
|
||||
- `einbau` (String 12): `angestossen` | `manuell` (Ergebnis des Neubau-Anstoßes, 4.6).
|
||||
- `behaltenSessionId` (String 128): offene Behalten-Zahlung (3.3).
|
||||
- `k24At` (Datetime): Marker der K24 (Selbstheilung, 3.2).
|
||||
- `featureView` liefert zusätzlich `unlockedBy`, `aboEndeAt`, `einbau`, `loeschungAm` (auch für `zeiten`).
|
||||
|
||||
## 3. Portal
|
||||
|
||||
### 3.1 Kachel „Öffnungszeiten live“
|
||||
|
||||
`GET /api/funktionen` liefert `zeitenAngebot` je Projekt (analog `termineAngebot`), mit
|
||||
`bestellbar: null` bei einem Abo-Lesefehler (Kachel zeigt dann „Gerade nicht prüfbar – bitte später erneut“).
|
||||
|
||||
- **off, Wartungs-Abo läuft, nicht gekündigt, `active`:** „Im Wartungs-Abo enthalten“ · Haken
|
||||
Leistungsbeschreibung · Knopf **„Einschalten“** (3.2).
|
||||
- **off, Wartungs-Abo läuft, nicht gekündigt, aber nicht `active` (Probezeit, Zahlung offen):** „Im
|
||||
Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist“; kein Bezahlen.
|
||||
- **off, sonst** (kein Wartungs-Abo, Hosting-Abo, Kündigung vorgemerkt): „30 € einmalig“ · Bestellseite aus
|
||||
dem Register (Haken Leistungsbeschreibung und Beginn-Verlangen) · **„Zahlungspflichtig bestellen · 30 €“**.
|
||||
- **ordered:** „Zahlung wird bestätigt …“.
|
||||
- **setup:** je nach `einbau`: „Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen
|
||||
(meist in wenigen Minuten)“ bzw. „Unser Team baut die Funktion ein (Ticket #…)“; bei leeren Plänen
|
||||
zusätzlich „Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen“ ·
|
||||
**„Zeiten bearbeiten“**.
|
||||
- **active:** Kurzstatus der Website („Jetzt geöffnet · schließt um 18 Uhr“) · **„Zeiten bearbeiten“**.
|
||||
- **active/setup mit `aboEndeAt`:** zusätzlich „Dein Wartungs-Abo ist beendet – behalte die Funktion bis
|
||||
<Datum> für einmalig 30 €“ · Knopf zum Behalten-Checkout (3.3).
|
||||
- **disabled:** Grundtext aus `disabledMessage`; bei `abo_ende` bis zur Löschung Knopf
|
||||
„Für 30 € wieder einschalten“ (3.3).
|
||||
|
||||
Bei `ZEITEN_ENABLED=off` erscheint die Kachel nicht; bei `pilot` nur für die Pilotliste.
|
||||
|
||||
### 3.2 Gratisweg `POST /api/funktionen/zeiten/einschalten`
|
||||
|
||||
Vorbild `activateAnalyse` (`server/services/analyse.js`). Stufen in dieser Reihenfolge, gleiche Fehlertexte
|
||||
wie der Checkout: Kundenansicht (viewAs ⇒ 403, Mitarbeiter schalten nicht für den Kunden ein) → Zustimmung
|
||||
(`consentFrei`, Fassung) → eigenes, nicht archiviertes Projekt → Schalter/Pilot → Unterlagen bereit. Dann unter
|
||||
der Sperre `feature-order`:
|
||||
|
||||
1. Abo frisch prüfen: `bestellbar()` direkt (ohne Memo). `kein_abo`/`gekuendigt` ⇒ 409 mit Hinweis auf den
|
||||
30-€-Weg; `nicht_aktiv` ⇒ 409 „sobald dein Abo aktiv ist“; Lesefehler ⇒ 503, nie stillschweigend erlaubt.
|
||||
2. Steht das Dokument schon auf `setup`/`active` mit `unlockedBy: 'abo'` ⇒ **idempotent**: fehlende Schritte
|
||||
(Ticket, K24) nachholen, 200 mit aktuellem Zustand. Andere Zustände außer `off` ⇒ 409
|
||||
`orderedConflictMessage`.
|
||||
3. Zustimmung speichern (`speichereZustimmung` ohne `stripeSessionId`).
|
||||
4. Feature-Dokument auf `setup`, `unlockedBy: 'abo'`.
|
||||
5. Neubau anstoßen (4.6) und `einbau` setzen.
|
||||
6. Einbau-Ticket anlegen (Register-Text mit Ergebnis aus Schritt 5), dann K24 (Text nach `einbau`), dann
|
||||
`k24At` – jeweils erst nach Erfolg.
|
||||
|
||||
**Selbstheilung:** Der 5-Minuten-Job (`runActivation`) legt für `zeiten` in `setup` ohne `ticketId` das
|
||||
Ticket nach und sendet K24 nach, wenn `unlockedBy: 'abo'` und `k24At` leer ist. Ein erneuter Klick heilt
|
||||
ebenso (Schritt 2).
|
||||
|
||||
### 3.3 Bezahlweg und Behalten
|
||||
|
||||
**Neubestellung** über den TP2-Checkout je Funktion; der Register-Eintrag liefert Preis, Zustimmung, Ticket-
|
||||
und Mailtexte. Zusatz: Wartungs-Abo läuft und ist nicht gekündigt (`bestellbar` ok oder Grund `nicht_aktiv`)
|
||||
⇒ 409 `inklusive` („Im Wartungs-Abo bereits enthalten – einfach einschalten“).
|
||||
|
||||
**Behalten** (neuer Modus, nicht über `markOrdered`/`markPaid` der Neubestellung, weil diese bei
|
||||
`setup`/`active`/`disabled` mit 409 bzw. Anomalie antworten):
|
||||
|
||||
- `POST /api/funktionen/zeiten/behalten` (eigener Kunde, kein viewAs; nur wenn `aboEndeAt` gesetzt oder
|
||||
`disabled` mit `abo_ende` und Daten vorhanden): gleiche Zustimmung wie die Neubestellung, Stripe Checkout
|
||||
mit Metadata `zweck: 'behalten'`, `behaltenSessionId` am Dokument. **Der Zustand bleibt unverändert**
|
||||
(Leisten bleiben während der Zahlung live).
|
||||
- Webhook-Zweig `zweck: 'behalten'` unter `feature-admin:<projectId>`: Session passt zu
|
||||
`behaltenSessionId` ⇒ `unlockedBy: 'kauf'`, `aboEndeAt` leer, `behaltenSessionId` leer; war das Dokument
|
||||
`disabled`, zurück auf `active` (Ticket war geschlossen) bzw. `setup`, Neubau anstoßen (4.6).
|
||||
**Kein neues Einbau-Ticket.** K15 mit Behalten-Wortlaut. Passt die Session nicht (schon behalten, Daten
|
||||
gelöscht) ⇒ T19 „Erstattung fällig“ wie bei anderen Anomalien.
|
||||
|
||||
### 3.4 Editor (Ansicht `#zeiten`)
|
||||
|
||||
Oben eine **Live-Vorschau**: „So steht es gerade auf deiner Seite: *Jetzt geöffnet · schließt um 18 Uhr*“
|
||||
(dieselbe Serverlogik) und ein Regler **„Uhrzeit testen“** (Tag + Uhrzeit, `GET /api/zeiten/:projectId/
|
||||
vorschau?zeit=…`). Vor dem ersten Speichern: leerer Hauptplan und „Trag deine Öffnungszeiten ein“.
|
||||
Darunter sechs Bereiche:
|
||||
|
||||
1. **Zeitpläne:** Hauptplan „Öffnungszeiten“ (Kurzname `oeffnungszeiten`), „Plan hinzufügen“ (Name, der
|
||||
Kurzname wird einmalig daraus gebildet und angezeigt), Hauptplan wechseln, Plan löschen (nicht den letzten,
|
||||
nicht den Hauptplan). Je Wochentag: Haken „geöffnet“, zwei `<input type="time" step="300">`-Paare,
|
||||
Auswahl „rund um die Uhr“ / „nach Vereinbarung“.
|
||||
2. **Feiertage:** Bundesland-Auswahl; Liste der Feiertage der nächsten 12 Monate mit Namen; je Eintrag
|
||||
„geschlossen“ (Vorgabe) oder eigene Zeiten. „Bitte prüfen“ (Vorgabe: Wochenplan, nicht „geschlossen“):
|
||||
Heiligabend, Silvester sowie Feiertage, die `feiertagejs` landesweit führt, die aber nur örtlich gelten
|
||||
(Mariä Himmelfahrt in BY, Fronleichnam in SN und TH). Hinweis: Gemeinde-Feiertage sind nicht enthalten.
|
||||
3. **Ausnahmen:** Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten); Geltung
|
||||
„alle Pläne“ (Vorgabe) oder ein Plan; Liste kommend/vergangen. Beim Speichern eines Urlaubs ≥ 2 Tage:
|
||||
„Hinweis-Leiste dazu anlegen?“ (füllt Text und Zeitraum vor; sichtbar ab 7 Tage vor Beginn bis Urlaubsende).
|
||||
4. **Hinweis-Leiste:** Text, Art (Info/Urlaub/Aktion/Wichtig mit festen Farben), optionaler Knopf
|
||||
(Link `https://…` oder Anrufen), Zeitraum von–bis (Kalendertage); „Aktion“ verlangt ein Enddatum. Enthält
|
||||
der Text „%“ oder „statt“: Hinweis zu Preisangaben (Endpreise, bei Waren niedrigster Preis der letzten
|
||||
30 Tage). Liste geplanter Hinweise.
|
||||
5. **Kontakt-Leiste (Handy):** Ein/Aus; Telefon; Adresse für „Route“; WhatsApp optional mit Nummer,
|
||||
Starttext und Pflicht-Haken „Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen“
|
||||
(Zeitpunkt in `bestaetigtAt`; in der Kundenansicht setzen Mitarbeiter den Haken **nicht** – 403 wie
|
||||
`routes/termine.js`).
|
||||
6. **Google:** Knopf **„Für Google kopieren“** (Zwischenablage: Montag–Sonntag im Format
|
||||
„Montag: 09:00–12:30, 13:30–18:00“, „geschlossen“, danach Sonderzeiten der nächsten 60 Tage) und Link
|
||||
„Google-Profil öffnen“ (`https://business.google.com/`). Text: Die Google-Daten im Seitencode laufen
|
||||
automatisch.
|
||||
|
||||
Speichern: `PUT /api/zeiten/:projectId` mit `version`; veraltete Version ⇒ 409 „Inzwischen geändert – bitte
|
||||
neu laden“. Mitarbeiter können über die Kundenansicht speichern; Protokoll
|
||||
`VIEW_ACTIONS += 'zeiten_gespeichert'` (Test-Pin und WOMS-`viewLog.js`-Label anpassen).
|
||||
|
||||
## 4. Website
|
||||
|
||||
### 4.1 Öffentliche Schnittstelle `GET /api/public/zeiten/v1/:siteKey`
|
||||
|
||||
CORS `*`, nur GET, `Cache-Control: public, max-age=60`, weiches ETag über den Body **ohne** `stand`,
|
||||
Speichercache 30 s je siteKey als **LRU mit höchstens 500 Einträgen** (auch für „unbekannt“), 120
|
||||
Anfragen/Minute je IP, `Access-Control-Expose-Headers: Date`. siteKey-Format wird vor jedem Appwrite-Zugriff
|
||||
geprüft. Unbekannter Schlüssel, Schalter aus, Kunde gelöscht oder Zustand nicht in `setup`/`active` ⇒
|
||||
`{ aktiv: false }`. Keine internen IDs, keine Kundendaten außer den veröffentlichten Kontaktangaben.
|
||||
|
||||
Antwort (gekürzt):
|
||||
|
||||
```
|
||||
{ aktiv: true, stand: <ISO>,
|
||||
plaene: [{ kurz, name, haupt,
|
||||
woche: [{ tag: 1, modus, fenster }],
|
||||
tage: [{ datum, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name, modus, fenster }], // 60 Tage
|
||||
wechsel: [{ ab: <epoch-ms>, offen: true|false|null, text, kurz }] }], // 8 Tage
|
||||
hinweise: [{ art, text, knopf, ab: <epoch-ms>, bis: <epoch-ms>, rang }], // aktuell gültige + Beginn in 8 Tagen
|
||||
kontakt: null|{ telefon, routeUrl, whatsappUrl },
|
||||
jsonld: { openingHoursSpecification: [...], specialOpeningHoursSpecification: [...] },
|
||||
firma: { name, adresse },
|
||||
team: { navSelektor, kontaktleisteAus, hinweisleisteAus } }
|
||||
```
|
||||
|
||||
- `hinweise[].ab/bis` rechnet der Server aus den Kalendertagen in Berliner Zeit (Beginn 00:00, Ende 24:00).
|
||||
Der Browser zeigt zu jedem Zeitpunkt den gültigen Hinweis mit dem höchsten `rang`
|
||||
(Wichtig > Urlaub > Aktion > Info; bei Gleichstand späterer Beginn).
|
||||
- `firma.name` = Firmenname des Kunden; fehlt das Kundendokument, Projektname, sonst leer (nie 500).
|
||||
`firma.adresse` = `kontakt.adresse`.
|
||||
- Solange keine Zeiten gespeichert sind, ist `plaene` leer: kein Status, kein JSON-LD, Platzhalter behalten
|
||||
ihr Ersatz-HTML; Leisten erscheinen nur, wenn eingerichtet.
|
||||
- `whatsappUrl` = `https://wa.me/<ziffern>?text=<encodeURIComponent(starttext)>`; `routeUrl` nach Z23.
|
||||
|
||||
### 4.2 Skript `zeiten.js`
|
||||
|
||||
IIFE ohne Abhängigkeiten, Schutz gegen doppeltes Laden (`window.__wkZeiten`). siteKey aus `data-site` des
|
||||
eigenen Script-Tags oder aus `[data-webklar-zeiten]`; API-Basis aus `data-api` oder der Herkunft des Skripts.
|
||||
**Ein** Abruf je Seite für alle Teile. Der Browser rechnet keine Feiertage und keine Zeitzonen: Er wählt aus
|
||||
`wechsel` den letzten Eintrag mit `ab ≤ jetzt`, stellt einen Timer auf den nächsten Wechsel (Statuswechsel
|
||||
oder Hinweis-Beginn/-Ende), lädt höchstens alle 10 Minuten und bei `visibilitychange` neu. „Jetzt“ =
|
||||
`Date.now()`; weicht die Uhr laut `Date`-Header um mehr als 5 Minuten ab, rechnet das Skript mit der
|
||||
Serverzeit. Schlägt der Abruf fehl oder sind die Daten älter als 24 h: Leisten aus, Platzhalter behalten ihr
|
||||
Ersatz-HTML. Keine Cookies, kein Storage, keine Anfragen an Dritte; Kundentexte nur über `textContent`;
|
||||
Links aus der API werden im Browser erneut geprüft (`https:`/`tel:`), `rel="noopener noreferrer"`;
|
||||
`querySelectorAll(navSelektor)` in try/catch.
|
||||
|
||||
### 4.3 Teile auf der Seite
|
||||
|
||||
1. **Hinweis-Leiste:** Shadow-DOM-Host als erstes Kind von `body`, schiebt den Inhalt. Feste/klebende
|
||||
Elemente mit `top: 0` (oder `team.navSelektor`) werden, solange die Leiste sichtbar ist, um
|
||||
`max(0, Leistenhöhe − scrollY)` nach unten gerückt (Inline-`top`, beim Entfernen zurückgesetzt);
|
||||
`scroll-padding-top` wird um die Höhe erhöht. Farben je Art mit Kontrast ≥ 4,5:1; `role="region"`,
|
||||
`aria-label="Hinweis"`. Aus bei `team.hinweisleisteAus`.
|
||||
2. **Kontakt-Leiste** (nur bei `matchMedia('(max-width: 768px)')`): Shadow DOM, unten fest, `z-index: 40`,
|
||||
Abstandhalter am Ende von `body` in Leistenhöhe, `scroll-padding-bottom`; Knöpfe Anrufen (`tel:`), Route,
|
||||
WhatsApp – erst der Klick öffnet den fremden Dienst. Aus, wenn `team.kontaktleisteAus` oder die Seite
|
||||
schon ein festes Element am unteren Rand mit `a[href^="tel:"]` hat.
|
||||
3. **JSON-LD:** Gibt es einen `application/ld+json`-Block mit einem Knoten vom Typ LocalBusiness oder einem
|
||||
Untertyp (auch in `@graph`), ersetzt das Skript dort nur `openingHoursSpecification` und
|
||||
`specialOpeningHoursSpecification` (Text des Blocks per `JSON.stringify` neu, unparsbare Blöcke bleiben
|
||||
unberührt); sonst legt es einen Block `{ @type: LocalBusiness, name, address?, url, … }` an. Regeln:
|
||||
Fenster über Mitternacht mit `closes < opens`; „rund um die Uhr“ 00:00–23:59; „nach Vereinbarung“ wird
|
||||
ausgelassen; Sonderzeiten der nächsten 60 Tage mit `validFrom`/`validThrough`, geschlossen als
|
||||
`00:00`–`00:00`.
|
||||
4. **Platzhalter** (vom Team gesetzt; das Skript füllt sie ab `setup`, damit das Team vor dem Schließen des
|
||||
Tickets prüfen kann):
|
||||
`<div data-webklar-zeiten data-modus="tabelle|status|badge" data-plan="<kurz>">Ersatz-HTML</div>`.
|
||||
- *Tabelle:* echtes `<table>` mit Caption, Zeilenköpfe `th scope="row"`, heutiger Tag mit
|
||||
`aria-current="date"`, darunter „Abweichungen in den nächsten 14 Tagen“.
|
||||
- *Status:* Punkt + Text („Jetzt geöffnet · schließt um 18 Uhr“), kein `aria-live`.
|
||||
- *Badge:* Kurzform („Geöffnet“ / „Geschlossen“ / „Nach Vereinbarung“).
|
||||
Light DOM mit Präfix `wkz-`, `@layer wkz`, Variablen `--wkz-*`; erbt Schrift und Farben der Seite.
|
||||
Unbekannter `data-plan`: Platzhalter bleibt beim Ersatz-HTML, Konsole-Warnung (kein stilles Umschalten
|
||||
auf den Hauptplan). Bei `aktiv: false` bleibt das Ersatz-HTML unverändert stehen.
|
||||
|
||||
Barrierefreiheit: Tippflächen ≥ 44 px, sichtbarer Fokus, `prefers-reduced-motion` (keine Einblend-Animation).
|
||||
|
||||
### 4.4 Freischalten (Ticket schließen ⇒ `active`)
|
||||
|
||||
Der vorhandene 5-Minuten-Job setzt `active`, wenn das Ticket geschlossen ist **und** `freischaltbar`
|
||||
(Register-Haken) erfüllt ist: Hauptplan gespeichert und mindestens ein Tag nicht „zu“. Sonst bleibt `setup`,
|
||||
WOMS zeigt „wartet auf die Zeiten des Kunden“. Bei `active`: K16, `ersatzHash` setzen.
|
||||
|
||||
### 4.5 Ersatz-HTML
|
||||
|
||||
`GET /api/team/funktionen/:customerId/zeiten/ersatz?projectId=…&plan=<kurz>` liefert eine schlichte
|
||||
Wochentabelle des Plans als ASCII mit Entities (`ä`, `–`); bei leeren Plänen 409. Das Team setzt sie
|
||||
beim Einbau in jeden Platzhalter. Ändert sich der Wochenplan des Hauptplans später, bleibt das Ersatz-HTML
|
||||
zunächst stehen (mit Skript unsichtbar). Wird die Funktion **abgeschaltet** und weicht der aktuelle Wochenplan
|
||||
von `ersatzHash` ab, legt der Abschalt-Pfad ein WOMS-Ticket „Ersatz-Tabelle aktualisieren“ mit dem aktuellen
|
||||
Ersatz-HTML an (gehört zum enthaltenen Einbau). K27 sagt dem Kunden, dass ab jetzt wieder feste Zeiten auf der
|
||||
Seite stehen. Statisches Vorbefüllen durch den Runner bei jedem Deploy bleibt V1.1.
|
||||
|
||||
### 4.6 Einbau des Skripts
|
||||
|
||||
- **Seite läuft über den Runner:** `previewDeploy` liefert im Auftrag `zeiten: { siteKey } | null` über
|
||||
`buildZeiten(project)` (nur bei `setup`/`active`; nie im Dev-Pfad – dort wie `analytics` auf `undefined`;
|
||||
`null` für Projekte auf der Sperrliste; `isPublic` nicht nötig; wirft nie). Der Runner prüft das Feld
|
||||
(Whitelist in `validate.js`, siteKey-Regex) und setzt in derselben Runde wie die Analyse vor `</head>` jeder
|
||||
HTML-Datei ein:
|
||||
`<script data-wk-zeiten src="<ZEITEN_PUBLIC_BASE>zeiten.js" data-site="<siteKey>" defer></script>`
|
||||
(neue Runner-Variable `ZEITEN_PUBLIC_BASE`, Vorgabe `https://project.webklar.com/embed/`;
|
||||
`injectIntoHtml` wird auf eine Marke je Tag verallgemeinert, heute fest `data-wk-analytics`).
|
||||
- **Neubau anstoßen** bei jedem Übergang in einen ausgebenden Zustand (`setup`/`active`) aus `off`, `ordered`
|
||||
oder `disabled` (Gratisweg, Zahlung, Team-Aktivierung, Abo-Rückkehr, Behalten nach Abschaltung):
|
||||
`triggerRebuild` (Muster `analyseDeps.rebuild`), **serialisiert** über eine Portal-weite Sperre
|
||||
`zeiten-neubau` (höchstens ein angestoßener Neubau gleichzeitig). Ergebnis in `einbau`: `angestossen`
|
||||
(Auftrag angenommen) oder `manuell` (kein Repo, Preview aus, Dev-Modus, Sperrliste, Fehler oder Runner
|
||||
ausgelastet). Änderungen an Zeiten/Leisten brauchen **keinen** Neubau. Beim Abschalten kein Pflicht-Neubau;
|
||||
die API antwortet `aktiv: false`, das Tag verschwindet beim nächsten Deploy.
|
||||
- **Sperrliste** `ZEITEN_KEIN_NEUBAU` (Subdomains; Vorgabe: Emmas Seiten, deren Dateien teilweise nicht in
|
||||
Git liegen): weder angestoßener Neubau noch Einsetzen bei normalen Deploys (`buildZeiten` ⇒ `null`); das
|
||||
Team setzt die Zeile von Hand ins Repo.
|
||||
- **Seite liegt woanders** oder `einbau: manuell`: Das Ticket enthält die Einbau-Zeile; das Team setzt sie ein.
|
||||
- **Ticket-Inhalt** (Register `ticket.details`): siteKey, Einbau-Zeile, Ergebnis `einbau`, Beispiele der
|
||||
Platzhalter mit Plan-Kurznamen, Link auf das Ersatz-HTML (nach dem ersten Speichern des Kunden), Prüfliste:
|
||||
feste Zeiten im Repo suchen und ersetzen (`grep -ril "öffnungszeit"` u. Ä., auch Footer, Kontaktseite,
|
||||
eigene Status-Skripte), Header-Überlappung der Hinweis-Leiste prüfen (sonst `navSelektor`), eigene
|
||||
Anruf-Leiste vorhanden?, vorhandenes JSON-LD, Datenschutz-Absatz auf der Website ergänzt (Abschnitt 9).
|
||||
|
||||
## 5. Zustände und Abläufe
|
||||
|
||||
```
|
||||
off ──Checkout──▶ ordered ──Zahlung──▶ setup ──Ticket zu + freischaltbar──▶ active
|
||||
├──Einschalten (Abo)───────────────▶ setup
|
||||
└──Team „Aktivieren ohne Zahlung“───▶ setup
|
||||
setup/active ──Abo-Ende + 30 Tage ohne Zahlung (nur abo) / Team „Abschalten“──▶ disabled
|
||||
disabled (abo_ende) ──30 Tage──▶ Daten gelöscht, state off
|
||||
```
|
||||
|
||||
- Die öffentliche Schnittstelle liefert in `setup` und `active` dasselbe (`teilweiseLive`);
|
||||
`findActiveBySiteKey` bekommt eine Zustandsliste. `active` heißt „Einbau abgeschlossen“ (Ticket zu, K16).
|
||||
- **Team** (WOMS): „Aktivieren ohne Zahlung“ läuft für `zeiten` über denselben Pfad wie der Gratisweg
|
||||
(`setup`, `unlockedBy: 'team'`, Neubau, Ticket; Zustimmung entfällt, Aktion protokolliert) statt über
|
||||
`adminActivate` (setzt heute direkt `active`). „Abschalten“ ⇒ `disabled`, Grund `team`, ohne Löschfrist.
|
||||
`activate()` und jede Wiederherstellung räumen `aboEndeAt` und `behaltenSessionId` ab; eine Neubestellung
|
||||
nach Datenlöschung setzt `ticketId`, `einbau`, `k24At`, `ersatzHash` zurück.
|
||||
- **Abo-Abgleich** (eigener Tagesjob, nicht an `TERMINE_ENABLED` gekoppelt), nur `unlockedBy === 'abo'`,
|
||||
Abo-Lage über `aboLage()` (Fehler ⇒ Kunde überspringen, nie abschalten):
|
||||
1. Abo läuft nicht mehr und `aboEndeAt` leer ⇒ `aboEndeAt = jetzt`, **K26** (Variante nach `art`:
|
||||
beendet / Wechsel zu Hosting / Wechsel widerrufen; Behalten-Link, Frist = `aboEndeAt` + 30 Tage).
|
||||
2. `aboEndeAt` + 30 Tage erreicht ⇒ `disabled`, `disabledReason: 'abo_ende'`, **K27**, ggf. Ticket
|
||||
„Ersatz-Tabelle aktualisieren“ (4.5).
|
||||
3. Abo läuft wieder, solange die Daten noch da sind (also bis `aboEndeAt` + 60 Tage) ⇒ `aboEndeAt` leer,
|
||||
Zustand zurück (`active`, wenn das Ticket geschlossen ist, sonst `setup`), Neubau bei Rückkehr aus
|
||||
`disabled`, K24-Variante „wieder eingeschaltet“.
|
||||
4. `disabled` mit `abo_ende` + 30 Tage ⇒ `daten`, `team` und Merker gelöscht, Feature-Dokument
|
||||
`state: 'off'`. Bei Grund `team` keine automatische Löschung.
|
||||
K16 kann nach K26 noch kommen (Ticket wird während der Frist geschlossen); das ist gewollt.
|
||||
- **Kunde gelöscht** (`customerAdmin.js`, Löschpfad): `zeiten` wird wie `termine` abgeschaltet;
|
||||
`oeffnungszeiten`, `zeitenErinnerungen` und der Cache-Eintrag werden gelöscht. `retention.js` bekommt
|
||||
`zeiten` (Löschfristen wie oben).
|
||||
|
||||
## 6. Zeitlogik (Server, `server/services/zeiten/zeitlogik.js`)
|
||||
|
||||
Reine Funktionen auf Basis von `termine/zeit.js` (Europe/Berlin über `Intl`) und einem erweiterten
|
||||
`termine/feiertage.js` (neu: `feiertagName(tag, land)` und `feiertageImZeitraum(von, bis, land)` über
|
||||
`feiertagejs.getHolidays(jahr, land)` und die Methode `translate('de')` der Feiertags-Objekte). Der Browser
|
||||
rechnet nichts davon nach.
|
||||
|
||||
- **Tagesauflösung** je Plan und Datum, Vorrang (spezifisch vor allgemein):
|
||||
Sondertag > Betriebsurlaub > Feiertag (Abweichung oder Vorgabe) > Wochenplan. Bei gleicher Art schlägt eine
|
||||
planbezogene Ausnahme eine für alle Pläne; zwei gleichrangige überlappende Ausnahmen: die später angelegte.
|
||||
Bewusst anders als `slots.js` (dort schlägt Abwesenheit die Sonderöffnung), weil ein einzelner geöffneter
|
||||
Tag im Urlaub hier gewollt ist.
|
||||
- **Fenster** gehören zum Starttag; `bis < von` heißt „bis zum Folgetag“. Ein über Mitternacht laufendes
|
||||
Fenster folgt der Regel seines **Starttags**, auch wenn der Folgetag Feiertag, Urlaub oder geschlossen ist
|
||||
(Bar hat Samstag 18–02 offen, Sonntag ist Feiertag ⇒ bis 02:00 offen). Der Status zu einem Zeitpunkt prüft
|
||||
die Fenster des Tages **und** das über Mitternacht laufende Fenster des Vortags. Aneinander grenzende Fenster
|
||||
(auch über Tage, z. B. zweimal „rund um die Uhr“) werden vor der Textbildung zusammengefügt.
|
||||
- **Sommerzeit:** Fenster sind Wanduhrzeiten. `zuUtc` liefert in der März-Lücke `null`; die Zeitlogik
|
||||
rückt dann selbst auf die erste existierende Minute danach vor (03:00). In der doppelten Oktober-Stunde gilt
|
||||
das erste Vorkommen (Verhalten von `zuUtc`).
|
||||
- **Texte** (ohne Anrede):
|
||||
- offen: „Jetzt geöffnet · schließt um 18 Uhr“ / „… um 18:30 Uhr“; Schluss um 00:00 des Folgetags:
|
||||
„… schließt um Mitternacht“; Schluss mehr als 24 h entfernt: „Jetzt geöffnet · bis Dienstag 14 Uhr“;
|
||||
durchgehend ≥ 7 Tage: „Rund um die Uhr geöffnet“. Nie „24 Uhr“.
|
||||
- zu: „Geschlossen · öffnet heute um 14 Uhr“ / „morgen um 9 Uhr“ / „Montag um 9 Uhr“ (≤ 6 Tage) /
|
||||
„am 18.08. um 9 Uhr“; keine Öffnung in 60 Tagen: „Derzeit geschlossen“. Grund an geschlossenen Tagen:
|
||||
„Heute geschlossen (Betriebsurlaub bis 17.08.)“, „(Feiertag: Tag der Deutschen Einheit)“.
|
||||
- nach Vereinbarung: „Heute nach Vereinbarung“ (`offen: null`).
|
||||
- kurz: „Geöffnet“ / „Geschlossen“ / „Nach Vereinbarung“.
|
||||
- **`wechsel`**: alle Statuswechsel der nächsten 8 Tage ab Beginn der aktuellen Minute; zusätzlich ein Eintrag
|
||||
zu jeder Tagesgrenze, an der sich der Text ändert („morgen“ → „heute“).
|
||||
|
||||
## 7. Erinnerung und Mails
|
||||
|
||||
**Feiertags-Erinnerung** (Job in `server/index.js`, stündlich, nur 08:00–20:00 Berliner Zeit,
|
||||
Überlappsperre, nur bei Schalter `pilot`/`on` und nur für freigegebene Kunden): Für jedes Dokument in
|
||||
`setup`/`active` alle Tage in 1–7 Tagen, die ein Feiertag des Landes oder ein „bitte prüfen“-Tag (3.4) sind.
|
||||
Nicht erinnert wird, wenn der Tag im Hauptplan ohnehin geschlossen ist, eine Ausnahme ihn abdeckt oder der
|
||||
Kunde für den Tag schon eine Abweichung gespeichert hat. Für die übrigen Tage: erst Merker anlegen (409 ⇒
|
||||
überspringen), dann **eine** Mail **K25** mit allen neuen Tagen („Stimmen deine Zeiten an diesen Tagen?“,
|
||||
Knopf zum Editor, Hinweis „bei Google auch eintragen“). Lieber einmal zu wenig als doppelt; kein BCC ans Team.
|
||||
|
||||
**Mails** (im Design der Mailvorlagen vom 27.09., Texte in die Mailrunde des Inhabers):
|
||||
|
||||
| ID | Anlass |
|
||||
|---|---|
|
||||
| K15/K16/T16 | Wie gehabt, Texte aus dem Register (Kauf, bereit, Team); T16 beim Gratis-/Team-Weg mit Zusatz „im Wartungs-Abo enthalten“ bzw. „vom Team freigeschaltet“; K15 mit Behalten-Wortlaut (3.3). |
|
||||
| K24 (neu) | Im Abo eingeschaltet; Text nach `einbau` („Leisten erscheinen nach dem nächsten Veröffentlichen“ / „das Team baut sie ein“), Tabelle folgt per Ticket; Variante „wieder eingeschaltet“. |
|
||||
| K25 (neu) | Feiertags-Erinnerung. |
|
||||
| K26 (neu) | Wartungs-Abo beendet (Varianten nach `art`): Funktion läuft noch bis <Datum>, „für einmalig 30 € behalten“. |
|
||||
| K27 (neu) | Abgeschaltet: Leisten sind weg, auf der Seite stehen wieder feste Zeiten; Daten noch 30 Tage, Wiedereinschalten für 30 €. |
|
||||
|
||||
Mail-Pin-Test (K01–K27) und der Block `funktionen` in K02/K03 werden angepasst.
|
||||
|
||||
## 8. WOMS (eigener Worktree auf WOMS `feature/terminplaner-tp2`)
|
||||
|
||||
- `src/lib/funktionenView.js`: Eintrag `zeiten` (Name, Zustandstexte, „Leisten live – Einbau läuft“ bzw.
|
||||
„wartet auf die Zeiten des Kunden“ für `setup`, Abo-Ende-Hinweis mit Frist, `loeschungAm`), `siteKeyOf` um
|
||||
`zeiten` erweitert, Test-Pin angepasst.
|
||||
- Funktionen-Panel: Aktionen „Aktivieren ohne Zahlung“ / „Abschalten“; Team-Einstellungen `navSelektor`,
|
||||
`kontaktleisteAus`, `hinweisleisteAus` (`PATCH /api/team/funktionen/:customerId/zeiten/team` mit
|
||||
`teamVersion`); Knopf „Ersatz-HTML kopieren“ je Plan.
|
||||
- `viewLog.js`: Label für `zeiten_gespeichert`.
|
||||
- Rechtliches: Karte „Leistungsbeschreibung Öffnungszeiten live“ über `scripts/setup-legal.mjs` (nach den
|
||||
Terminplaner-Karten aus TP2 F14; setzt TP2 Block F voraus).
|
||||
|
||||
## 9. Rechtstexte
|
||||
|
||||
Grundsatz Z19: freiwilliges Extra, Abo-Bedingungen bleiben unverändert (kein § 15-Verfahren, kein P10-Risiko).
|
||||
|
||||
1. **Leistungsbeschreibung „Öffnungszeiten live“** (eigenständig, Gerüst der Analyse-Leistungsbeschreibung):
|
||||
Leistung; automatischer Einbau nur bei WEBklar-Hosting, sonst durch das Team; Preis 30 € ohne
|
||||
Umsatzsteuer nach § 19 UStG, mit laufendem Wartungs-Abo ohne Aufpreis und ohne Anrechnung aufs
|
||||
Stundenkontingent („gehört nicht zum Leistungsumfang der Abo-Bedingungen; Leistungsbeschreibung B Nr. 3
|
||||
gilt dafür nicht; der Abo-Preis ändert sich nicht“); ein Einbau je Website inkl. Aktualisierung der
|
||||
Ersatz-Tabelle beim Abschalten, Sonderwünsche nach Angebot/Stunden; nicht enthalten: Übertragung zu
|
||||
Google, Gemeinde-Feiertage; Pflichten des Kunden (Zeiten, Feiertage und Hinweistexte prüfen,
|
||||
Preise/Rabatte rechtmäßig angeben, Datenschutz-Absatz auf der Website); **Haftung** (Angaben vom Kunden,
|
||||
Live-Anzeige als Hilfsmittel, Softwarefehler inkl. Feiertagsdaten nur im Rahmen der üblichen Grenzen –
|
||||
spiegelt Abo § 13 Abs. 1 und 2, damit Kunden ohne Abo abgedeckt sind); Ende der Leistung (Abo-Ende nach Z11)
|
||||
und Einstellung/Änderung nach Vorbild Analyse Nr. 8.
|
||||
2. **Zustimmung:** bezahlt mit Beginn-Verlangen (§ 356 Abs. 5 Nr. 2 BGB, Belehrung über TP2-Baustein F3);
|
||||
gratis nur Haken für die Leistungsbeschreibung, Knopf „Einschalten“; Nachweis in beiden Fällen.
|
||||
3. **Datenschutz Portal:** neuer § 6d (gespeicherte Angaben, Erinnerungsmail, Art. 6 Abs. 1 lit. b,
|
||||
Löschfristen); § 6 um „Öffnungszeiten“ im Kundenauftrag erweitert.
|
||||
4. **Absatz für die Kundenseite** (`docs/funktionen/website-datenschutz-absatz.md`, Varianten A/B):
|
||||
Abruf von project.webklar.com (IP 14 Tage im Server-Log, keine Cookies, nichts im Browser gespeichert);
|
||||
WhatsApp (Meta) und Google Maps erst nach Klick.
|
||||
5. **AVV:** mit Abo reicht Anlage 1 (Server-Protokolle); ohne Abo/bei Fremdhosting gleiche Lücke wie bei
|
||||
der News-Pinnwand ⇒ Frage ins Kanzlei-Paket (⚖ „AVV Funktionen“ per Haken?).
|
||||
6. **Mitkorrektur News** (TP2 F2): „inkl. USt.“ → § 19 UStG, § 356 Abs. 4 → Abs. 5.
|
||||
|
||||
Reihenfolge der Rechtsrunde (gemeinsam mit TP2): gemeinsame Bausteine (TP2 Block F) → Leistungsbeschreibung →
|
||||
Datenschutz § 6d/§ 6 und Website-Absatz → Oberflächen- und Mailtexte (Mailrunde) → ⚖-Fragen ans Kanzlei-Paket
|
||||
→ Inhaber lädt Fassungen in WOMS „Rechtliches“ hoch → Schalter `on`.
|
||||
|
||||
## 10. Relaunch-Station (Repo webklar.com, eigener Branch ab `main`)
|
||||
|
||||
`main` des Relaunch-Repos ist live (Push = Deploy) – Arbeit auf eigenem Branch im eigenen Worktree, der Inhaber
|
||||
pusht.
|
||||
|
||||
- `Kundenportal.tsx` `FUNKTIONEN`: 4. Eintrag nach der News-Pinnwand, `demo: 'zeiten'`, `bald: true`
|
||||
(bis Portal-Livegang), kein Preis. Indizes 0–6 bleiben, „Als App“ rückt auf 8.
|
||||
- Neuer Ordner `src/components/home/demos/zeiten/`: `ablauf.ts`, `ZeitenSeite.tsx`, `ZeitenHandy.tsx`,
|
||||
`ZeitenRegler.tsx`, `zeiten.css`. Gemeinsame Bäckerei-Teile (`Logo`, `Pfeil`, `Stempel`) nach
|
||||
`baeckerei/teile.tsx`; eigenes Wurzelelement ohne `.bk-inhalt` (sonst greift `--kp-v`).
|
||||
- **MacBook:** Bäckerei Sonnenschein mit großer Status-Pille (≥ 30 pt) und Hinweis-Leiste
|
||||
„Betriebsurlaub 12.–26. Oktober“ (≥ 24 pt, nicht unter dem iPhone). Demo-Datum Mo 5. Okt. 2026 (liegt im
|
||||
7-Tage-Vorlauf des Urlaubs).
|
||||
- **Handy:** Portal-Editor-Shot `phone-12-zeiten` (Fixture-Kopie mit Portal aus `dev-zeiten`, Profil
|
||||
`baeckerei` mit `features.zeiten`, fester Uhrzeit, freier Port aus 5326/5327/5329), Finger tippt
|
||||
„Hinweis-Leiste anlegen“, danach gleitet die Leiste auf der Seite ein.
|
||||
- **Uhr-Regler:** echtes `<input type="range">` mit Label, Tippfläche ≥ 44 px; Automatiklauf
|
||||
Mo 05:50 → 18:10 („Jetzt geöffnet“ → „Geschlossen · öffnet morgen um 6 Uhr“), danach Hinweis-Leiste; stoppt
|
||||
bei Bedienung; bei reduzierter Bewegung fester Endzustand.
|
||||
- Animation nur über `transform`/`opacity`; keine `https://` in Demo-Dateien; kein „24 h“; kein
|
||||
Google-Ranking-Versprechen; WhatsApp nur als Text/Piktogramm ohne Logo.
|
||||
- Budget: Haupt-JS ≤ +0,7 KB gz (Grenze 130 KB), neue Chunks ≤ 8 KB gz, Handy-Bild ≤ 60 KB.
|
||||
- Tests: neuer `demo-zeiten.test.tsx` (Endzustand, nichts fokussierbar außer Regler, Status zu jeder Uhrzeit);
|
||||
`relaunch-kundenportal.test.tsx`: `TITEL`, Karten-Obergrenze, `ziele`-Länge, Bilderzahl („2 + n“),
|
||||
Handy-Bildzählung und `meldeMarke`-Indizes („Als App“ 7 → 8) aus der Stationsliste ableiten bzw. anpassen;
|
||||
Spec-Text „höchstens 7 Karten“ anpassen.
|
||||
- Das Handy-Bild entsteht erst, wenn der Portal-Editor steht.
|
||||
|
||||
## 11. Fehlerfälle
|
||||
|
||||
| Fall | Verhalten |
|
||||
|---|---|
|
||||
| API nicht erreichbar / Daten > 24 h alt | Leisten aus, Platzhalter zeigen Ersatz-HTML. |
|
||||
| Browser-Uhr falsch (> 5 min laut `Date`) | Serverzeit wird verwendet. |
|
||||
| Gleichzeitiges Speichern (Kunde + Team) | 409, Editor lädt neu und zeigt die neue Fassung. |
|
||||
| Neubau nicht möglich / Sperrliste / Fremdhosting | `einbau: manuell`, Ticket enthält Einbau-Zeile; Kachel und K24 sagen „das Team baut ein“. |
|
||||
| Ticket-Anlage oder K24 scheitert beim Gratisweg | Zustand bleibt `setup`; 5-Minuten-Job bzw. erneuter Klick holt nach. |
|
||||
| Ticket geschlossen, Kunde hat keine Zeiten eingetragen | bleibt `setup`, WOMS „wartet auf die Zeiten des Kunden“. |
|
||||
| Abo endet während `setup` | Abgleich wie 5 (Frist läuft unabhängig vom Einbau). |
|
||||
| Behalten-Zahlung nach Datenlöschung | T19 „Erstattung fällig“. |
|
||||
| Abo-Lesefehler | Einschalten 503, Kachel „gerade nicht prüfbar“, Abgleich überspringt den Kunden. |
|
||||
| Hauptplan löschen | nicht möglich; erst anderen Plan zum Hauptplan machen. |
|
||||
| Platzhalter mit unbekanntem `data-plan` | Ersatz-HTML bleibt, Konsole-Warnung. |
|
||||
| Skript zweimal eingebunden (Runner + manuell) | zweiter Lauf beendet sich sofort. |
|
||||
| Beliebige siteKeys (Abtasten) | Format-Prüfung, LRU-Cache mit Obergrenze, Rate-Limit. |
|
||||
| Kunde gelöscht | API `aktiv: false`, Daten gelöscht (5). |
|
||||
| Projekt archiviert | Funktion läuft weiter, solange `setup`/`active` (wie News); Neubestellung gesperrt. |
|
||||
|
||||
## 12. Tests
|
||||
|
||||
- **Zeitlogik** (`test/zeitenZeitlogik.test.js`): 29.03.2026 (Fenster 02:00–04:00, Öffnung 02:30 ⇒ 03:00),
|
||||
25.10.2026 (Fenster 02:00–06:00, „schließt um 02:30“); Fenster 18:00–02:00 (Status um 01:00 am Folgetag;
|
||||
Feiertag/Urlaub am Folgetag ⇒ trotzdem offen bis 02:00; Sondertag am Folgetag); Mo+Di rund um die Uhr (kein
|
||||
„24 Uhr“, Wechsel am Ende von Dienstag); Feiertag an geschlossenem Tag; Feiertag mit Abweichung; „bitte
|
||||
prüfen“-Tage (Heiligabend, Mariä Himmelfahrt BY); Urlaub über Feiertag; Sondertag im Urlaub (Sondertag
|
||||
gewinnt); überlappende Urlaube; Urlaub über den Jahreswechsel; „nach Vereinbarung“ (Status `null`, nicht im
|
||||
JSON-LD); mehrere Pläne mit planbezogenen Ausnahmen; Hauptplan wechseln; Texte
|
||||
„heute/morgen/Wochentag/Datum/Derzeit geschlossen/Mitternacht“; `wechsel` über 8 Tage; Hinweis-`ab/bis`
|
||||
über Sommerzeitgrenzen.
|
||||
- **Prüfregeln:** Fenster-Regeln, Längen, Link-/Telefon-/Selektor-Regeln, Aufräumen vergangener Einträge.
|
||||
- **Abläufe:** Gratisweg (alle Stufen, `kein_abo`/`gekuendigt`/`nicht_aktiv`, Lesefehler 503, Doppelklick
|
||||
idempotent, Selbstheilung Ticket/K24 im Job), Bezahlweg mit 409 `inklusive`, Team-Aktivierung über den
|
||||
Gratis-Pfad, Ausgabe in `setup` wie in `active`, leere Pläne, `freischaltbar`-Gate, Neubau bei jedem
|
||||
Übergang in einen ausgebenden Zustand und Serialisierung, Abo-Abgleich (K26-Varianten, 30 Tage, K27, Ticket
|
||||
Ersatz-Tabelle nur bei Abweichung, Rückkehr bis Tag 60, Löschung nur bei `abo_ende`), Behalten (Zustand
|
||||
bleibt während der Zahlung, kein neues Ticket, Session-Abgleich, T19 nach Löschung), Kunde gelöscht,
|
||||
Erinnerung genau einmal (Neustart zwischen Merker und Mail, Bundeslandwechsel, mehrere Tage in einer Mail,
|
||||
keine Mail bei geschlossenem Tag/Ausnahme/Abweichung).
|
||||
- **Schnittstelle:** `aktiv:false`-Fälle, ETag/304 trotz `stand`, LRU-Grenze, Rate-Limit, keine internen IDs,
|
||||
`firma` ohne Kundendokument.
|
||||
- **Runner:** Einsetzen beider Tags in einem Durchlauf, idempotent, Whitelist, Dev-Pfad ohne `zeiten`,
|
||||
Sperrliste ohne Einsetzen.
|
||||
- **Skript** (Playwright, Testseiten): fester Header (Rücken), klebender Header, eigene Anruf-Leiste
|
||||
(Kontakt-Leiste aus), vorhandenes JSON-LD (ergänzt, kein Duplikat, unparsbarer Block unberührt), kein JSON-LD
|
||||
(Block neu), `aktiv:false` (Ersatz-HTML bleibt), unbekannter `data-plan`, keine Cookies/kein Storage,
|
||||
doppeltes Laden, Handy- und Desktop-Breite, reduzierte Bewegung, ungültiger `navSelektor`.
|
||||
- **Editor** (UI-Test mit Fixture): Pläne mit Kurznamen, Fenster über Mitternacht, Validierungsfehler je Feld,
|
||||
Urlaub ⇒ Hinweis-Vorschlag, „Für Google kopieren“, Vorschau-Regler, Kundenansicht ohne WhatsApp-Haken.
|
||||
- **WOMS:** Register-Pin, Zustandstexte, Team-Einstellungen, viewLog-Label.
|
||||
- Bestehende Suiten bleiben grün (Portal `npm test` inkl. Mail-Pin und `customerViewLog`-Pin, WOMS `npm test`
|
||||
+ `vite build`, Relaunch-Tests).
|
||||
|
||||
## 13. Reihenfolge und Livegang
|
||||
|
||||
1. Spec (diese Datei) → Durchsicht durch den Inhaber.
|
||||
2. Umsetzungsplan (`docs/superpowers/plans/2026-10-04-oeffnungszeiten-live.md`) → Freigabe.
|
||||
3. Umsetzung in `dev-zeiten` (Portal) und einem WOMS-Worktree auf `feature/terminplaner-tp2` mit
|
||||
Sonnet-Agenten; regelmäßig auf die TP2-Branches rebasen. Die TP2-Worktrees `dev-termine` werden nicht
|
||||
angefasst.
|
||||
4. Relaunch-Station auf eigenem Branch im Relaunch-Repo.
|
||||
5. Rechtsrunde gemeinsam mit TP2 (Abschnitt 9).
|
||||
6. Livegang nach TP2: Rebase auf `main` (mit TP2), Schema-Skripte (`feature-setup.mjs`, neue Collections),
|
||||
Stripe-Katalog prüfen (`scripts/stripe-products.mjs --pruefen`, `PRODUCT_METADATA`), Runner-Variable
|
||||
`ZEITEN_PUBLIC_BASE` und Sperrliste setzen, Inhaber pusht, `ZEITEN_ENABLED=pilot` (Testkonto), Probelauf auf
|
||||
einer Testseite, dann `on`; Relaunch-Station `bald: false`.
|
||||
|
||||
## 14. Nicht im Umfang (V1)
|
||||
|
||||
Automatischer Abgleich mit dem Google-Unternehmensprofil (API/Cloud); statisches Vorbefüllen der Platzhalter
|
||||
durch den Runner bei jedem Deploy (V1.1); Gemeinde-Feiertage; Schließen-Knopf der Hinweis-Leiste; Übernahme
|
||||
der Zeiten in den Terminplaner; mehrere Hinweise gleichzeitig; Mehrsprachigkeit; Ankündigungsmail/Portal-Banner
|
||||
(Z8).
|
||||
+5
-3
@@ -139,7 +139,7 @@ function projectState(project) {
|
||||
return ['kb-state-off', 'Noch keine Website']
|
||||
}
|
||||
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop' }
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', zeiten: 'Öffnungszeiten' }
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
@@ -1910,7 +1910,7 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', zeiten: 'zeiten' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
@@ -1920,6 +1920,8 @@ const viewRefreshers = {
|
||||
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
|
||||
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
|
||||
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
|
||||
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2112,7 +2114,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="/analyse.css">
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
<link rel="stylesheet" href="/shop.css">
|
||||
<link rel="stylesheet" href="/zeiten.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -206,6 +207,15 @@
|
||||
<div id="webshop-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
|
||||
<section class="kb-view" id="view-zeiten">
|
||||
<header class="kb-head">
|
||||
<h1>Öffnungszeiten live</h1>
|
||||
<p>Deine Website zeigt immer den aktuellen Stand: geöffnet oder geschlossen, mit Feiertagen, Urlaub und Hinweisen.</p>
|
||||
</header>
|
||||
<div id="zeiten-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Öffnungszeiten Testseite</title>
|
||||
<style>
|
||||
body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937;line-height:1.5}
|
||||
header,footer{padding:16px 24px;background:#e8ebf0}
|
||||
main{max-width:760px;margin:0 auto;padding:32px 16px}
|
||||
h2{margin:32px 0 12px}
|
||||
.ersatz{margin:0;padding:12px 16px;border:2px dashed #94a3b8;border-radius:12px;background:#fff}
|
||||
.ersatz table{border-collapse:collapse}
|
||||
.ersatz th,.ersatz td{padding:.25em .75em .25em 0;text-align:left}
|
||||
/* Gestaltung ueber CSS-Variablen der Seite (alle optional): [data-webklar-zeiten]{ --wkz-offen:#0f766e; --wkz-heute:#fde68a; } */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
|
||||
<main>
|
||||
<h1>Öffnungszeiten</h1>
|
||||
<p>Der siteKey unten ist ein Platzhalter: für echte Daten <code>data-site</code> durch den siteKey des Projekts ersetzen (und bei Bedarf <code>data-api</code> am Script-Tag ergänzen). Ohne Daten (oder bei einem Fehler) bleibt überall das Ersatz-HTML stehen.</p>
|
||||
|
||||
<h2>Tabelle (Hauptplan)</h2>
|
||||
<div data-webklar-zeiten data-modus="tabelle">
|
||||
<div class="ersatz">
|
||||
<table>
|
||||
<caption>Öffnungszeiten</caption>
|
||||
<tr><th scope="row">Montag</th><td>09:00 – 18:00</td></tr>
|
||||
<tr><th scope="row">Dienstag</th><td>09:00 – 18:00</td></tr>
|
||||
<tr><th scope="row">Mittwoch</th><td>09:00 – 18:00</td></tr>
|
||||
<tr><th scope="row">Donnerstag</th><td>09:00 – 18:00</td></tr>
|
||||
<tr><th scope="row">Freitag</th><td>09:00 – 18:00</td></tr>
|
||||
<tr><th scope="row">Samstag</th><td>09:00 – 13:00</td></tr>
|
||||
<tr><th scope="row">Sonntag</th><td>geschlossen</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Status (Hauptplan)</h2>
|
||||
<div data-webklar-zeiten data-modus="status">
|
||||
<p class="ersatz">Mo–Fr 09:00 – 18:00 Uhr, Sa 09:00 – 13:00 Uhr.</p>
|
||||
</div>
|
||||
|
||||
<h2>Badge (Plan „haupt“, im Fließtext)</h2>
|
||||
<p>Wir haben gerade <span data-webklar-zeiten data-modus="badge" data-plan="haupt">Mo–Fr 09:00 – 18:00 Uhr</span> für Sie da.</p>
|
||||
|
||||
<h2>Unbekannter Plan (Ersatz-HTML bleibt, Warnung in der Konsole)</h2>
|
||||
<div data-webklar-zeiten data-modus="status" data-plan="gibt-es-nicht">
|
||||
<p class="ersatz">Dieser Platzhalter nennt einen Plan, den es nicht gibt.</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>Fußbereich</footer>
|
||||
<script src="/embed/zeiten.js" data-site="abcdefghijklmnop" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
+129
-1
@@ -72,6 +72,7 @@ const ICONS = {
|
||||
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||
shop: '<circle cx="9" cy="20" r="1.5"/><circle cx="18" cy="20" r="1.5"/><path d="M2 3h3l2.7 12.4a1 1 0 0 0 1 .8h9.1a1 1 0 0 0 1-.8L21 7H6"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
}
|
||||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
|
||||
@@ -259,6 +260,123 @@ function shopTile(projects, features) {
|
||||
</article>`
|
||||
}
|
||||
|
||||
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
|
||||
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
|
||||
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
|
||||
sagt grund warum: kein_abo | gekuendigt | nicht_aktiv | null: Abo nicht lesbar) und preis; je Projekt features.zeiten (state, einbau, ticketWoid,
|
||||
aboEndeAt, disabledReason, loeschungAm), zeitenStatus ({ offen, kurz, text } des Hauptplans) und zeitenLeer (keine Plaene gespeichert).
|
||||
Ruling R20: nicht_aktiv (Probezeit, Zahlung offen) ist einschaltbar, sobald das Abo aktiv ist, und bietet kein Bezahlen an. */
|
||||
const ZEITEN_TEXT = 'Deine Website zeigt live, ob du gerade geöffnet hast – mit Feiertagen, Urlaub und Hinweisen.'
|
||||
const ZEITEN_ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
|
||||
const ZEITEN_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
|
||||
const zeitenOf = (project) => project.features?.zeiten || { state: 'off' }
|
||||
|
||||
// Zielprojekt: aktiv, dann in Einrichtung/Zahlung, dann abgeschaltet, sonst das erste
|
||||
function pickZeitenProject(projects) {
|
||||
return projects.find((p) => zeitenOf(p).state === 'active')
|
||||
|| projects.find((p) => isSetup(zeitenOf(p).state))
|
||||
|| projects.find((p) => zeitenOf(p).state === 'disabled')
|
||||
|| 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 K29);
|
||||
// ein unlesbares Datum gibt '' (dann steht kein Hinweis da, nie ein erfundenes Datum). Doppelt zu zeiten.js (funktionen.js importiert dort nichts).
|
||||
function zeitenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
if (Number.isNaN(ms)) return ''
|
||||
const [y, m, d] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Behalten (30 €) ist nur bezahlbar, wenn das Wartungs-Abo wirklich beendet ist: inklusive false UND grund kein_abo (Z12, R20, R25). nicht_aktiv und
|
||||
// gekuendigt heissen, dass das Abo laeuft (Probezeit/Zahlung offen bzw. Kuendigung vorgemerkt): 'abo', nie ein Bezahlknopf. Doppelt zu behaltenLage in
|
||||
// zeiten-bestellen.js (funktionen.js importiert dort nichts); beide pinnen die Tests. 'bezahlen' | 'abo' | 'inklusive' | 'unklar' (Abo nicht lesbar)
|
||||
function zeitenBehaltenLage(angebot) {
|
||||
if (angebot.inklusive === true) return 'inklusive'
|
||||
if (angebot.inklusive !== false) return 'unklar'
|
||||
if (angebot.grund === 'kein_abo') return 'bezahlen'
|
||||
return angebot.grund === 'nicht_aktiv' || angebot.grund === 'gekuendigt' ? 'abo' : 'unklar'
|
||||
}
|
||||
const ZEITEN_ABO_WIEDER = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
|
||||
// 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''
|
||||
const zeitenTag = (iso) => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof iso === 'string' ? iso : '')
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
function zeitenTile(projects, angebot = {}) {
|
||||
const target = pickZeitenProject(projects)
|
||||
const f = target ? zeitenOf(target) : { state: 'off' }
|
||||
const preis = textOr(angebot.preis, PRICE)
|
||||
const knopf = (text, { weg = '', klasse = 'kb-btn-primary fn-tile-cta' } = {}) => `<button type="button" class="${klasse}" data-view="zeiten" data-project="${esc(target.projectId)}"${weg ? ` data-zeiten-weg="${weg}"` : ''}>${text}</button>`
|
||||
const chat = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
|
||||
let pill = ['Aus', 'off']
|
||||
let extra = ''
|
||||
let action = ''
|
||||
if (!target) {
|
||||
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.</p>'
|
||||
} else if (f.state === 'active' || f.state === 'setup') {
|
||||
const aktiv = f.state === 'active'
|
||||
pill = aktiv ? ['Aktiv', 'on'] : ['In Einrichtung', 'setup']
|
||||
const status = typeof target.zeitenStatus?.text === 'string' ? target.zeitenStatus.text : ''
|
||||
if (aktiv && status) extra += `<p class="fn-count" data-zeiten-status>${esc(status)}</p>`
|
||||
if (!aktiv) {
|
||||
extra += f.einbau === 'angestossen'
|
||||
? '<p>Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).</p>'
|
||||
: `<p>Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.</p>`
|
||||
}
|
||||
if (target.zeitenLeer === true) extra += `<p>${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}</p>`
|
||||
action = knopf('Zeiten bearbeiten')
|
||||
// Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten, aber nur ohne Abo (kein_abo); laeuft es (nicht_aktiv, gekuendigt), steht der Hinweis ohne Knopf da
|
||||
const lage = f.aboEndeAt ? zeitenBehaltenLage(angebot) : ''
|
||||
const frist = lage === 'bezahlen' ? zeitenFrist(f.aboEndeAt) : ''
|
||||
if (frist) {
|
||||
extra += `<p class="fn-count">Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.</p>`
|
||||
action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' })
|
||||
} else if (lage === 'abo') {
|
||||
extra += `<p class="fn-count">${ZEITEN_ABO_WIEDER}</p>`
|
||||
}
|
||||
} else if (f.state === 'ordered') {
|
||||
pill = ['Zahlung offen', 'setup']
|
||||
extra = '<p>Zahlung wird bestätigt …</p>'
|
||||
action = knopf('Status ansehen')
|
||||
} else if (f.state === 'disabled') {
|
||||
pill = ['Abgeschaltet', 'off']
|
||||
const aboEnde = f.disabledReason === 'abo_ende'
|
||||
// Daten da: Plaene gespeichert (zeitenLeer false) und die Loeschfrist (loeschungAm, Berliner Tag) ist nicht vorbei
|
||||
const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay())
|
||||
extra = `<p>${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}</p>`
|
||||
if (daten && zeitenTag(f.loeschungAm)) extra += `<p class="fn-count">Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.</p>`
|
||||
const lage = daten ? zeitenBehaltenLage(angebot) : ''
|
||||
if (!daten) action = chat
|
||||
else if (lage === 'bezahlen') action = knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' })
|
||||
else if (lage === 'unklar') extra += `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||||
else extra += '<p class="fn-count">Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).</p>'
|
||||
} else if (angebot.inklusive === true) {
|
||||
extra = '<p>Im Wartungs-Abo enthalten.</p>'
|
||||
action = knopf('Einschalten')
|
||||
} else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') {
|
||||
extra = '<p class="fn-count">Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.</p>'
|
||||
} else if (angebot.inklusive === false) {
|
||||
extra = `<p>${esc(preis)} einmalig</p>`
|
||||
action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`)
|
||||
} else {
|
||||
extra = `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||||
}
|
||||
const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length
|
||||
const count = projects.length > 1 && aktiveAnzahl ? `<p class="fn-count">aktiv für ${aktiveAnzahl} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="zeiten">
|
||||
<div class="fn-tile-head">${svg('clock')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Öffnungszeiten live</h3>
|
||||
<p>${ZEITEN_TEXT}</p>
|
||||
${extra}
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -268,12 +386,13 @@ const soonTile = (icon, title, text) => `
|
||||
|
||||
export async function initFunktionen(root) {
|
||||
if (!root) return
|
||||
const { projects = [], termine, features, shop } = await request('/api/funktionen')
|
||||
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen')
|
||||
root.innerHTML = `
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects, newsOffer(features).price)}
|
||||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
||||
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
@@ -288,6 +407,15 @@ export async function initFunktionen(root) {
|
||||
const body = document.getElementById('webshop-body')
|
||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||
})
|
||||
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
|
||||
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
||||
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
||||
const body = knopf && document.getElementById('zeiten-body')
|
||||
if (!body) return
|
||||
body.dataset.wunschProjekt = knopf.dataset.project
|
||||
if (knopf.dataset.zeitenWeg === 'behalten') body.dataset.wunschWeg = 'behalten'
|
||||
else delete body.dataset.wunschWeg
|
||||
})
|
||||
if (termine?.verfuegbar) await showTermineCount(root, projects)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<!-- ENTWURF – vor Livegang Rechtsrunde -->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Leistungsbeschreibung Öffnungszeiten live – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
td:first-child { font-weight: 600; width: 30%; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm; }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html#zeiten">Datenschutz</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Leistungsbeschreibung: Öffnungszeiten live</h1>
|
||||
<p class="meta">Stand 04.10.2026</p>
|
||||
|
||||
<div class="box">
|
||||
<strong>Das Wichtigste auf einen Blick</strong>
|
||||
<table>
|
||||
<tr><td>Leistung</td><td>Sie tragen Ihre Öffnungszeiten einmal im Kundenportal ein. Ihre Website zeigt sie überall gleich an, als Tabelle und als Live-Status („Jetzt geöffnet · schließt um 18 Uhr“). Feiertage Ihres Bundeslands sind eingerechnet. Dazu kommen eine Hinweis-Leiste, auf dem Handy eine Kontakt-Leiste und Angaben für Suchmaschinen im Seitencode.</td></tr>
|
||||
<tr><td>Preis</td><td>30 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG); mit laufendem Wartungs-Abo ohne Aufpreis</td></tr>
|
||||
<tr><td>Einbau</td><td>ein Einbau je Website ist enthalten; erfahrungsgemäß meist innerhalb weniger Tage (keine verbindliche Zusage)</td></tr>
|
||||
<tr><td>Ende</td><td>Haben Sie die Funktion im Wartungs-Abo eingeschaltet, läuft sie nach dem Ende des Abos noch 30 Tage weiter; Sie können sie dann für einmalig 30 € behalten (Nr. 9)</td></tr>
|
||||
<tr><td>Widerruf</td><td>Sind Sie Verbraucher, können Sie die Bestellung für 30 € innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 5 Nr. 2 BGB erlöschen (Nr. 11)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 id="geltung">1. Geltung</h2>
|
||||
<p>(1) Diese Leistungsbeschreibung regelt die Funktion „Öffnungszeiten live“. Sie gilt für Kunden mit Wartungs-Abo, die die Funktion im Kundenportal einschalten, und für Kunden ohne Wartungs-Abo, die sie für 30 € bestellen. Vereinbart wird sie, wenn der Kunde im Kundenportal den Haken für diese Leistungsbeschreibung setzt und auf „Einschalten“ oder auf „Zahlungspflichtig bestellen“ klickt.</p>
|
||||
<p>(2) Die Funktion ist ein freiwilliges Zusatzangebot von WEBklar. Im Wartungs-Abo ist sie ohne Aufpreis enthalten; sie gehört nicht zum Leistungsumfang der Abo-Bedingungen. Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Nicht enthalten“: neue Funktionen und Rechtstexte) gilt dafür nicht: Die Funktion und der Datenschutz-Absatz nach Nr. 6 Abs. 4 sind Teil dieser Leistung. Der Preis des Wartungs-Abos ändert sich nicht.</p>
|
||||
<p>(3) Im Übrigen gelten die Abo-Bedingungen, soweit der Kunde ein Abo hat. Bei Widersprüchen geht für Öffnungszeiten live diese Leistungsbeschreibung vor. Für Kunden ohne Wartungs-Abo gilt sie allein; deshalb stehen die Haftungsregeln in Nr. 8 ausgeschrieben da.</p>
|
||||
|
||||
<h2 id="leistung">2. Leistung</h2>
|
||||
<p>(1) Der Kunde trägt seine Öffnungszeiten einmal im Kundenportal ein: bis zu fünf benannte Zeitpläne, je Wochentag bis zu zwei Zeitfenster („rund um die Uhr“ und „nach Vereinbarung“ sind möglich), Abweichungen an Feiertagen, Betriebsurlaub und Sondertage. Die Website zeigt diese Zeiten überall gleich an und berechnet daraus den Live-Status („Jetzt geöffnet · schließt um 18 Uhr“).</p>
|
||||
<p>(2) An den Stellen der Website, die nach Nr. 3 Abs. 2 dafür vorgesehen sind, erscheinen die Zeiten als Tabelle, als Status oder als Kurzanzeige („Geöffnet“, „Geschlossen“, „Nach Vereinbarung“). Dazu gehören, soweit der Kunde sie einrichtet:</p>
|
||||
<ul>
|
||||
<li>eine Hinweis-Leiste am Seitenanfang für befristete Hinweise (Info, Urlaub, Aktion, Wichtig) mit Beginn und Ende und auf Wunsch einem Knopf (Link oder Anruf),</li>
|
||||
<li>auf dem Handy eine Kontakt-Leiste am unteren Rand mit den Knöpfen „Anrufen“, „Route“ und, wenn der Kunde sie einrichtet, „WhatsApp“,</li>
|
||||
<li>die Öffnungszeiten im Seitencode in maschinenlesbarer Form (strukturierte Daten), die Suchmaschinen lesen können,</li>
|
||||
<li>eine E-Mail vor Feiertagen, die den Kunden bittet, seine Zeiten für diese Tage zu prüfen, und</li>
|
||||
<li>im Kundenportal der Knopf „Für Google kopieren“, der die Zeiten in der Reihenfolge von Google in die Zwischenablage legt, damit der Kunde sie in sein Google-Unternehmensprofil einträgt.</li>
|
||||
</ul>
|
||||
<p>(3) Die Funktion rechnet die Feiertage des Bundeslands ein, das der Kunde wählt; je Feiertag kann der Kunde eigene Zeiten eintragen. Feiertage, die nur örtlich gelten, sowie Heiligabend und Silvester zeigt das Kundenportal als „bitte prüfen“. Alle Zeiten gelten in der Zeitzone Europe/Berlin.</p>
|
||||
<p>(4) Beim Aufruf der Website lädt der Browser des Besuchers ein kleines Programm und die Öffnungszeiten von project.webklar.com, einem Server der WEBklar GbR bei der Hetzner Online GmbH in Falkenstein (Deutschland). Dabei werden keine Cookies gesetzt, und es wird nichts im Browser gespeichert. Das Programm lädt nichts von anderen Anbietern. WhatsApp und Google Maps werden erst nach dem Klick des Besuchers auf „WhatsApp“ oder „Route“ geöffnet; vorher werden keine Daten an diese Dienste übertragen.</p>
|
||||
<p>(5) Das Team von WEBklar kann die Hinweis-Leiste und die Kontakt-Leiste für eine Website abschalten, etwa wenn die Seite schon eine eigene Anruf-Leiste hat. Auf Wunsch des Kunden können Mitarbeiter die Öffnungszeiten in der Kundenansicht für ihn ändern; ein Anspruch darauf besteht nicht (Nr. 5 Abs. 1). Den Haken für den WhatsApp-Hinweis (Nr. 6 Abs. 3) setzen sie nicht.</p>
|
||||
|
||||
<h2 id="einbau">3. Einbau und Freischaltung</h2>
|
||||
<p>(1) Der Einbau des Programms erfolgt automatisch nur bei Websites, die WEBklar hostet und über den eigenen Veröffentlichungsablauf bereitstellt: Beim nächsten Veröffentlichen setzt WEBklar die Einbau-Zeile in jede Seite ein, und Hinweis-Leiste, Kontakt-Leiste und Angaben für Suchmaschinen erscheinen ohne weiteren Aufwand. In allen anderen Fällen, etwa bei einer Website bei einem anderen Anbieter, setzt das Team die Einbau-Zeile von Hand ein. Hat WEBklar keinen Zugriff auf die Website, erhält der Kunde die Zeile und setzt sie selbst oder durch seinen Anbieter ein.</p>
|
||||
<p>(2) Die Tabelle, den Status und die Kurzanzeige setzt das Team an den Stellen ein, an denen auf der Website feste Öffnungszeiten stehen oder stehen sollen, und ersetzt dort die fest eingetragenen Zeiten. In jedem dieser Platzhalter bleibt eine statische Ersatz-Tabelle stehen. Sie erscheint, wenn der Browser kein JavaScript ausführt, wenn die Daten nicht erreichbar oder älter als 24 Stunden sind und nachdem die Funktion abgeschaltet wurde. Sie zeigt den Wochenplan zum Zeitpunkt des Einbaus.</p>
|
||||
<p>(3) WEBklar schaltet die Funktion frei, sobald der Einbau abgeschlossen ist und der Kunde Öffnungszeiten eingetragen hat (mindestens ein geöffneter Tag im Hauptplan). Solange der Kunde keine Zeiten eingetragen hat, kann das Team die Tabelle nicht einbauen.</p>
|
||||
<p>(4) Der Einbau dauert erfahrungsgemäß meist wenige Tage. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins.</p>
|
||||
|
||||
<h2 id="preis">4. Preis</h2>
|
||||
<p>(1) Öffnungszeiten live kostet 30 € einmalig je Website-Projekt (ohne Umsatzsteuer gemäß § 19 UStG). Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.</p>
|
||||
<p>(2) Hat der Kunde ein laufendes Wartungs-Abo, ist die Funktion ohne Aufpreis enthalten; er schaltet sie dann im Kundenportal ein, statt sie zu bestellen. Der Einbau wird weder auf das Stundenkontingent angerechnet noch damit bezahlt. Der Preis des Wartungs-Abos ändert sich nicht.</p>
|
||||
<p>(3) Im Preis ist ein Einbau je Website enthalten, einschließlich der Aktualisierung der Ersatz-Tabelle beim Abschalten (Nr. 9 Abs. 4). Weitere Wünsche, etwa zusätzliche Stellen für die Anzeige, eine Sondergestaltung oder andere Änderungen an der Website, gehören nicht dazu. WEBklar führt solche Sonderwünsche nach Angebot aus, für Kunden mit Wartungs-Abo auf Wunsch nach Stunden (§ 4 der Abo-Bedingungen).</p>
|
||||
<p>(4) Solange ein Wartungs-Abo läuft und nicht gekündigt ist, nimmt WEBklar für Öffnungszeiten live keine Zahlung an; der Kunde schaltet die Funktion dann ohne Aufpreis ein.</p>
|
||||
|
||||
<h2 id="nicht-enthalten">5. Nicht enthalten</h2>
|
||||
<p>(1) Nicht enthalten sind:</p>
|
||||
<ul>
|
||||
<li>die Übertragung der Öffnungszeiten zu Google: WEBklar schreibt nichts automatisch in das Google-Unternehmensprofil des Kunden; der Kunde trägt die Zeiten dort selbst ein, der Knopf „Für Google kopieren“ und die Erinnerung vor Feiertagen helfen dabei,</li>
|
||||
<li>Feiertage einer Gemeinde und andere örtliche Besonderheiten (zum Beispiel Brückentage oder Stadtfeste); der Kunde trägt sie als Ausnahmen selbst ein,</li>
|
||||
<li>eine Zusage, dass Suchmaschinen die Angaben im Seitencode übernehmen oder wie sie sie anzeigen,</li>
|
||||
<li>die Pflege der Zeiten und der Hinweise durch WEBklar und</li>
|
||||
<li>Änderungen an der Website, die über den Einbau nach Nr. 3 hinausgehen (Nr. 4 Abs. 3).</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="pflichten">6. Pflichten des Kunden</h2>
|
||||
<p>(1) Der Kunde prüft seine Zeiten, Feiertage und Hinweistexte vor dem Speichern und hält sie aktuell. Er wählt das richtige Bundesland und trägt örtliche Feiertage und Abweichungen selbst ein.</p>
|
||||
<p>(2) Preise und Rabatte in Hinweisen gibt er rechtmäßig an, zum Beispiel als Endpreise und, bei Preisermäßigungen für Waren, mit dem niedrigsten Preis der letzten 30 Tage. Hinweise zu Preisen sind Angaben des Kunden, nicht von WEBklar.</p>
|
||||
<p>(3) Die Kontaktangaben (Telefonnummer, Anschrift, WhatsApp-Nummer) sind auf der Website öffentlich sichtbar. Der Kunde trägt nur Angaben ein, die er veröffentlichen darf. Die WhatsApp-Schaltfläche schaltet er erst ein, nachdem er den WhatsApp-Hinweis in seine Datenschutzerklärung aufgenommen hat, und bestätigt das im Kundenportal mit einem Haken.</p>
|
||||
<p>(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz zu Öffnungszeiten live ein; das gehört zum Einbau und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (für Kunden mit Wartungs-Abo § 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.</p>
|
||||
<p>(5) Der Kunde prüft nach dem Einbau, ob die Anzeige auf seiner Website stimmt, und meldet Fehler unverzüglich im Chat des Kundenportals.</p>
|
||||
<p>(6) Er entfernt oder verändert das Programm von WEBklar nicht. Wünscht er die Hinweis-Leiste oder die Kontakt-Leiste nicht, teilt er das dem Team mit (Nr. 2 Abs. 5).</p>
|
||||
|
||||
<h2 id="verfuegbarkeit">7. Verfügbarkeit und Aktualität</h2>
|
||||
<p>(1) Für Kunden mit Wartungs-Abo gilt das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen für die Website, nicht für die Live-Anzeige. Störungen der Anzeige behebt WEBklar in angemessener Frist.</p>
|
||||
<p>(2) Ist die Anzeige nicht erreichbar oder sind die Daten älter als 24 Stunden, bleibt auf der Website die Ersatz-Tabelle stehen (Nr. 3 Abs. 2); Hinweis-Leiste und Kontakt-Leiste erscheinen dann nicht.</p>
|
||||
<p>(3) Änderungen, die der Kunde im Kundenportal speichert, erscheinen nicht sofort auf der Website, sondern mit einer Verzögerung von einigen Minuten (bis zu etwa zehn Minuten). Der Status folgt der Zeitzone Europe/Berlin; geht die Uhr des Geräts des Besuchers um mehr als fünf Minuten falsch, rechnet das Programm mit der Serverzeit von WEBklar.</p>
|
||||
|
||||
<h2 id="haftung">8. Haftung</h2>
|
||||
<p>(1) Die Live-Anzeige ist ein Hilfsmittel. Die Richtigkeit der Zeiten, Ausnahmen, Hinweise und Kontaktangaben liegt beim Kunden: WEBklar zeigt die Angaben des Kunden an und prüft sie nicht auf Richtigkeit.</p>
|
||||
<p>(2) Die Feiertage stammen aus einer Feiertagsliste für das gewählte Bundesland. WEBklar beseitigt Fehler in den Feiertagsdaten und andere Softwarefehler nach Meldung in angemessener Frist (Nr. 7 Abs. 1). Für Schäden daraus gelten die Absätze 3 bis 5.</p>
|
||||
<p>(3) Für entgangenen Gewinn und Folgeschäden, etwa weil ein Besucher wegen einer falschen oder fehlenden Angabe vor verschlossener Tür steht, haftet WEBklar nur im Rahmen der Absätze 4 und 5.</p>
|
||||
<p>(4) WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.</p>
|
||||
<p>(5) Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</p>
|
||||
<p>(6) Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar. Dieser Abschnitt gilt auch für Kunden ohne Wartungs-Abo; bei Kunden mit Wartungs-Abo entspricht er § 13 Abs. 1 und 2 der Abo-Bedingungen.</p>
|
||||
<p>(7) Gesetzliche Rechte des Kunden, die sich nicht ausschließen lassen, bleiben unberührt. Der Haftungsrahmen des Kunden gegenüber seinen Besuchern und Kunden bleibt ebenfalls unberührt.</p>
|
||||
|
||||
<h2 id="ende">9. Ende der Leistung</h2>
|
||||
<p>(1) Auf Wunsch des Kunden in Textform (im Chat des Kundenportals oder per E-Mail) schaltet WEBklar Öffnungszeiten live jederzeit ab. Eine Erstattung des Preises folgt daraus nicht; für den Widerruf gilt Nr. 11.</p>
|
||||
<p>(2) Hat der Kunde die Funktion im Wartungs-Abo ohne Aufpreis eingeschaltet, endet sie mit dem Wartungs-Abo, aber nicht sofort: Endet das Wartungs-Abo (auch bei einem Wechsel ins Hosting-Abo und beim Widerruf eines Wechsels ins Wartungs-Abo), informiert WEBklar den Kunden per E-Mail, und die Funktion läuft noch 30 Tage weiter. In dieser Zeit kann der Kunde sie im Kundenportal für einmalig 30 € behalten; er behält sie dann unabhängig vom Abo. Ohne Zahlung schaltet WEBklar sie ab, sobald diese 30 Tage vorbei sind. Hat der Kunde die Funktion für 30 € bestellt, endet sie nicht mit einem Abo.</p>
|
||||
<p>(3) Sobald ein neues Wartungs-Abo beginnt, solange die Eingaben des Kunden noch gespeichert sind (Absatz 4), läuft die Funktion mit den vorhandenen Eingaben weiter oder WEBklar schaltet sie ohne weitere Kosten wieder ein.</p>
|
||||
<p>(4) Mit dem Abschalten verschwinden Hinweis-Leiste, Kontakt-Leiste, Live-Status und die Angaben für Suchmaschinen; auf der Website steht wieder die Ersatz-Tabelle (Nr. 3 Abs. 2). Hat sich der Wochenplan seit dem Einbau geändert, aktualisiert WEBklar die Ersatz-Tabelle einmal beim Abschalten; das gehört zum Einbau nach Nr. 4 Abs. 3. Hat WEBklar keinen Zugriff auf die Website, erhält der Kunde den aktuellen Text und setzt ihn selbst ein. Nach dem Abschalten wegen des Endes des Abos bleiben die Eingaben des Kunden noch weitere 30 Tage gespeichert und werden danach gelöscht; bis dahin kann der Kunde sie im Kundenportal ansehen und die Funktion für einmalig 30 € wieder einschalten. Nach der Löschung ist eine neue Bestellung nach Nr. 4 nötig.</p>
|
||||
<p>(5) Schaltet WEBklar die Funktion auf Wunsch des Kunden oder aus einem anderen Grund als dem Ende des Abos ab, löscht WEBklar die Eingaben des Kunden auf sein Verlangen in Textform, spätestens mit dem Löschen des Kundenkontos.</p>
|
||||
|
||||
<h2 id="aenderungen">10. Änderungen und Einstellung</h2>
|
||||
<p>(1) WEBklar darf die Technik von Öffnungszeiten live verbessern, wenn der Leistungsumfang nicht schlechter wird (für Kunden mit Wartungs-Abo § 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten die Funktion verarbeitet, passt WEBklar die Datenschutzerklärung an und informiert den Kunden, weil sich dann sein Datenschutz-Absatz (Nr. 6 Abs. 4) ändern kann.</p>
|
||||
<p>(2) WEBklar darf die Funktion für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn</p>
|
||||
<ul>
|
||||
<li>ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung dieser Leistungsbeschreibung nicht zustimmt, oder</li>
|
||||
<li>eine Sicherheitslücke sich nicht anders beheben lässt.</li>
|
||||
</ul>
|
||||
<p>Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.</p>
|
||||
<p>(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Funktion ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.</p>
|
||||
<p>(4) Hat der Kunde die Funktion im Wartungs-Abo ohne Aufpreis eingeschaltet und ist er Unternehmer, darf WEBklar sie außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Wartungs-Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.</p>
|
||||
<p>(5) Weil im Wartungs-Abo für die Funktion kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Wartungs-Abos nicht. Hat der Kunde die Funktion für 30 € bestellt, kann WEBklar sie nur aus einem Grund nach Absatz 2 beenden oder einstellen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.</p>
|
||||
|
||||
<h2 id="widerruf">11. Widerruf</h2>
|
||||
<p>(1) Verbraucher können die Bestellung für 30 € nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „<a href="/rechtliches/widerrufsbelehrung-funktionen.html">Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)</a>“.</p>
|
||||
<p>(2) Hat der Kunde bei der Bestellung ausdrücklich verlangt, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt, erlischt sein Widerrufsrecht nach § 356 Abs. 5 Nr. 2 BGB mit der vollständigen Erbringung der Leistung, wenn er vorher ausdrücklich zugestimmt und seine Kenntnis davon bestätigt hat. Widerruft er vor der vollständigen Erbringung, schuldet er einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).</p>
|
||||
<p>(3) Mit dem Widerruf endet die Funktion. Beim Einschalten im Wartungs-Abo ohne Aufpreis fällt kein Preis an; der Kunde kann die Funktion dort jederzeit nach Nr. 9 Abs. 1 abschalten lassen.</p>
|
||||
|
||||
<h2 id="hinweise">12. Hinweise</h2>
|
||||
<p>(1) <strong>Keine Rechtsberatung.</strong> Der Datenschutz-Absatz, der WhatsApp-Hinweis und die Hinweise des Kundenportals zu Preisangaben sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine rechtliche Beratung im Einzelfall, etwa zu Preisangaben, zum Wettbewerbsrecht oder zum Datenschutz der Website des Kunden, schuldet WEBklar nicht.</p>
|
||||
<p>(2) <strong>Datenschutz.</strong> Wie WEBklar im Kundenportal Daten zu Öffnungszeiten live verarbeitet, steht in der <a href="/datenschutz.html#zeiten">Datenschutzerklärung</a> von WEBklar (Abschnitt 6e). Was beim Aufruf der Website des Kunden übertragen wird, beschreibt Nr. 2 Abs. 4; die Information der Besucher gehört in die Datenschutzerklärung der Website (Nr. 6 Abs. 4).</p>
|
||||
|
||||
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Für Kunden mit Wartungs-Abo gelten ergänzend die <a href="/abo-bedingungen.html">Abo-Bedingungen</a>, für alle die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,6 +23,7 @@
|
||||
<label class="radio"><input type="radio" name="order" value="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
|
||||
<label class="radio"><input type="radio" name="order" value="termine" data-text="Terminplaner (Einrichtung)"> Terminplaner (Einrichtung)</label>
|
||||
<label class="radio"><input type="radio" name="order" value="shop" data-text="Webshop (Einrichtung)"> Webshop (Einrichtung)</label>
|
||||
<label class="radio"><input type="radio" name="order" value="zeiten" data-text="Öffnungszeiten live"> Öffnungszeiten live</label>
|
||||
</fieldset>
|
||||
|
||||
<label for="name" data-label-for="name" data-label="Name">Name</label>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 2 „Feiertage“ und 3 „Ausnahmen“ (Spec 3.4). Jeder Bereich ist eine Funktion
|
||||
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne
|
||||
Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt,
|
||||
ohne Knoepfe zum Aendern. Stil: termine-team-ausnahmen.js (Abwesenheiten, Feiertage, Sonderoeffnungen).
|
||||
Nach einem Speichern wird nur neu gezeichnet, was sich geaendert hat (nachSpeichern mit neuZeichnen): ungespeicherte Eingaben in den anderen Abschnitten
|
||||
und Formularen bleiben stehen.
|
||||
Feiertage: Bundesland (ein eigenes Speichern; die Liste kommt vom Server und gilt danach fuer das neue Land) und die Feiertage der naechsten
|
||||
12 Monate (ctx.stand.feiertage). Gespeichert werden nur Abweichungen von der Vorgabe (daten.feiertage): ein gesetzlicher Feiertag ist
|
||||
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
|
||||
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
|
||||
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
|
||||
daten.hinweise an und speichert (danach werden die Ausnahmen neu gezeichnet und die Hinweis-Liste aufgefrischt, ohne offene Hinweis-Formulare anzufassen). */
|
||||
|
||||
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
|
||||
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hinweisVorschlag, neueZeitenId, tagFehler } from './zeiten-view.js'
|
||||
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
|
||||
import { MODUS_WAHL, tagFelder, tagText } from './zeiten-plaene.js'
|
||||
import { hinweisListeErneuern } from './zeiten-leisten.js'
|
||||
|
||||
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
|
||||
const zeitraum = (a) => (a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`)
|
||||
|
||||
/* -------------------------------------------------------------- Feiertage --- */
|
||||
const FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
|
||||
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL])
|
||||
|
||||
// Pro Abschnitt „Feiertage“ die Tageseingaben je Datum: nach einem Wechsel des Bundeslands bekommen Tage, die es auch im neuen Land gibt, ihre Eingaben zurueck
|
||||
const ZEILEN = new WeakMap()
|
||||
|
||||
/** Wird nach dem Neuzeichnen des Abschnitts (Bundesland gewechselt) mit dem alten und dem neuen Abschnitt gerufen: ungespeicherte Eingaben wandern mit. */
|
||||
export function feiertageUebernehmen(alt, neu) {
|
||||
const frueher = ZEILEN.get(alt)
|
||||
const jetzt = ZEILEN.get(neu)
|
||||
if (!frueher || !jetzt) return
|
||||
for (const [tag, eingabe] of jetzt) if (frueher.has(tag)) eingabe.stelleWieder(frueher.get(tag).merke())
|
||||
}
|
||||
|
||||
export function feiertage(t) {
|
||||
const { ctx, sp } = t
|
||||
const daten = ctx.stand.daten
|
||||
const nurLesen = ctx.nurLesen
|
||||
const gespeichert = daten.feiertage !== null && typeof daten.feiertage === 'object' ? daten.feiertage : {}
|
||||
|
||||
// Bundesland: eigenes Formular, denn die Liste darunter kommt nach dem Speichern vom Server neu
|
||||
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: (daten.land || 'RP') === key }, name)))
|
||||
const landForm = h('form', { novalidate: true, 'aria-label': 'Bundesland' },
|
||||
gesperrt(nurLesen, feld('land', 'Bundesland', land, 'Danach richten sich die Feiertage unten.')),
|
||||
allgemeinerFehler(),
|
||||
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'land-speichern' }, 'Bundesland speichern')))
|
||||
landForm.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => { entwurf.land = land.value }, { container: landForm })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
const name = BUNDESLAENDER.find(([key]) => key === land.value)?.[1] ?? land.value
|
||||
// die Liste darunter gilt jetzt fuer das neue Land: nur dieser Abschnitt wird neu gezeichnet
|
||||
nachSpeichern(t, `Bundesland gespeichert: ${name}. Die Feiertage unten gelten jetzt dafür.`, 'form[aria-label="Bundesland"] button[type="submit"]', () => t.zeichne(['feiertage']))
|
||||
})
|
||||
|
||||
const zeilen = ctx.stand.feiertage.map((f) => {
|
||||
const pruefen = f.art === 'pruefen'
|
||||
const abweichung = Object.hasOwn(gespeichert, f.tag) ? gespeichert[f.tag] : null
|
||||
// ein gespeichertes „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe
|
||||
const modus = abweichung && MODI.includes(abweichung.modus) && !(!pruefen && abweichung.modus === 'zu') ? abweichung.modus : ''
|
||||
const tag = tagFelder({ pfad: `feiertage.${f.tag}`, label: `${f.name}, ${datumKurz(f.tag)}`, wahl: pruefen ? PRUEFEN_WAHL : FEIERTAG_WAHL, modus, fenster: abweichung?.fenster })
|
||||
const el = h('li', { class: 'tm-eintrag zt-feiertag', 'data-feiertag': f.tag },
|
||||
h('div', { class: 'tm-kopf' },
|
||||
h('div', { class: 'tm-haupt' }, h('strong', { class: 'tm-name' }, datumLang(f.tag)), h('span', { class: 'tm-meta' }, f.name)),
|
||||
pruefen ? h('span', { class: 'tm-pille tm-pille-wait' }, 'bitte prüfen') : null),
|
||||
tag.el)
|
||||
return { f, tag, el }
|
||||
})
|
||||
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'feiertage-speichern' }, 'Feiertage speichern')
|
||||
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
|
||||
zeilen.length
|
||||
? gesperrt(nurLesen, h('ul', { class: 'tm-eintraege', 'aria-label': 'Feiertage der nächsten 12 Monate' }, zeilen.map(({ el }) => el)))
|
||||
: h('p', { class: 'tm-leer' }, 'Für dieses Bundesland gibt es in den nächsten 12 Monaten keine Feiertage.'),
|
||||
allgemeinerFehler(),
|
||||
nurLesen || !zeilen.length ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
// nur Abweichungen von der Vorgabe: alles andere (auch Tage ausserhalb der Liste) bleibt, wie es war
|
||||
const neu = { ...(entwurf.feiertage || {}) }
|
||||
const fehler = {}
|
||||
for (const { f, tag } of zeilen) {
|
||||
const r = tag.lies()
|
||||
if (r.fehler) {
|
||||
Object.assign(fehler, r.fehler)
|
||||
continue
|
||||
}
|
||||
if (r.modus === '' || (f.art !== 'pruefen' && r.modus === 'zu')) {
|
||||
delete neu[f.tag]
|
||||
continue
|
||||
}
|
||||
const eintrag = { modus: r.modus, fenster: r.fenster }
|
||||
const meldung = tagFehler(eintrag)
|
||||
if (meldung) fehler[`feiertage.${f.tag}`] = meldung
|
||||
neu[f.tag] = eintrag
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
entwurf.feiertage = neu
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
// die Liste zeigt schon, was gespeichert ist; nichts neu zeichnen (das Bundesland darueber koennte noch ungespeichert gewaehlt sein)
|
||||
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]', () => {})
|
||||
})
|
||||
|
||||
const el = abschnitt('Feiertage',
|
||||
'An gesetzlichen Feiertagen ist deine Website als „geschlossen“ eingetragen. Willst du an einem Feiertag öffnen, wähle eigene Zeiten. „Bitte prüfen“ sind Tage, die nicht überall oder nicht für jeden frei sind (zum Beispiel Heiligabend, Silvester oder Fronleichnam): Hier gilt als Vorgabe dein Wochenplan.',
|
||||
landForm,
|
||||
h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'),
|
||||
form)
|
||||
ZEILEN.set(el, new Map(zeilen.map(({ f, tag }) => [f.tag, tag])))
|
||||
return el
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Ausnahmen --- */
|
||||
const gibtHinweis = (hinweise, v) => (Array.isArray(hinweise) ? hinweise : []).some((x) => x?.text === v.text && x.von === v.von && x.bis === v.bis)
|
||||
const planName = (daten, id) => (daten.plaene || []).find((p) => p?.id === id)?.name
|
||||
|
||||
export function ausnahmen(t) {
|
||||
const { ctx } = t
|
||||
const daten = ctx.stand.daten
|
||||
const alle = Array.isArray(daten.ausnahmen) ? daten.ausnahmen : []
|
||||
const heute = heuteBerlin()
|
||||
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
|
||||
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
|
||||
const voll = alle.length >= ZEITEN_LIMITS.ausnahmen
|
||||
const grenzText = `Mehr als ${ZEITEN_LIMITS.ausnahmen} Ausnahmen sind nicht möglich.`
|
||||
const urlaubNeu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Betriebsurlaub eintragen', werkzeug: 'urlaub-neu', voll, grenzText,
|
||||
baue: (schliessen) => formular(t, { a: null, art: 'urlaub', schliessen }),
|
||||
})
|
||||
const sonderNeu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Sondertag eintragen', werkzeug: 'sondertag-neu', voll, grenzText: '',
|
||||
baue: (schliessen) => formular(t, { a: null, art: 'sondertag', schliessen }),
|
||||
})
|
||||
|
||||
const reihe = (a) => {
|
||||
const art = a.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'
|
||||
const plan = a.plan ? `nur ${planName(daten, a.plan) || 'ein gelöschter Plan'}` : ''
|
||||
return editorZeile({
|
||||
attrs: { 'data-id': a.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: `${art} ${datumKurz(a.von)}`,
|
||||
haupt: [
|
||||
h('strong', { class: 'tm-name' }, zeitraum(a)),
|
||||
h('span', { class: 'tm-meta' }, [art, a.art === 'sondertag' ? tagText(a) : '', a.text, plan].filter(Boolean).join(' · ')),
|
||||
],
|
||||
baue: (schliessen) => formular(t, { a, art: a.art === 'sondertag' ? 'sondertag' : 'urlaub', schliessen }),
|
||||
})
|
||||
}
|
||||
|
||||
return abschnitt('Ausnahmen',
|
||||
'Betriebsurlaub und einzelne Sondertage gehen dem Wochenplan und den Feiertagen vor. Jede Ausnahme gilt für alle Pläne oder nur für einen.',
|
||||
hinweisFrage(t),
|
||||
h('div', { class: 'zt-werkzeuge' }, urlaubNeu.leiste, sonderNeu.leiste),
|
||||
urlaubNeu.box,
|
||||
sonderNeu.box,
|
||||
kommend.length
|
||||
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Kommende Ausnahmen' }, kommend.map(reihe))
|
||||
: h('p', { class: 'tm-leer' }, 'Keine kommenden Ausnahmen eingetragen.'),
|
||||
vorbei.length
|
||||
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Ausnahmen (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
|
||||
: null)
|
||||
}
|
||||
|
||||
/**
|
||||
* „Hinweis-Leiste dazu anlegen?“ nach einem Betriebsurlaub ab 2 Tagen (ctx.hinweisFrage = { vorschlag }): Text und Zeitraum sind vorbefuellt
|
||||
* (hinweisVorschlag: sichtbar ab 7 Tage vor Beginn bis zum Urlaubsende). „Ja“ legt den Hinweis in daten.hinweise an und speichert; „Nein“ laesst es.
|
||||
*/
|
||||
function hinweisFrage(t) {
|
||||
const { ctx, sp } = t
|
||||
const f = ctx.hinweisFrage
|
||||
if (!f || ctx.nurLesen) return null
|
||||
const v = f.vorschlag
|
||||
const form = frage('Hinweis-Leiste dazu anlegen?',
|
||||
h('p', null, `„${v.text}“ – sichtbar vom ${datumKurz(v.von)} bis ${datumKurz(v.bis)}, oben auf deiner Website.`),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-ja' }, 'Ja, anlegen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => {
|
||||
ctx.hinweisFrage = null
|
||||
form.remove() // nur die Frage verschwindet, alles andere in der Seite bleibt
|
||||
t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
|
||||
} }, 'Nein, danke')))
|
||||
form.dataset.frage = 'hinweis'
|
||||
form.addEventListener('submit', async () => {
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), { id: neueZeitenId('h'), art: v.art, text: v.text, knopf: null, von: v.von, bis: v.bis }]
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
ctx.hinweisFrage = null
|
||||
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => {
|
||||
t.zeichne(['ausnahmen'])
|
||||
hinweisListeErneuern(t) // der neue Hinweis steht in der Liste; ein offenes, ungespeichertes Hinweis-Formular bleibt, wie es ist
|
||||
})
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular einer neuen (a null) oder bestehenden Ausnahme der Art 'urlaub' (von, bis) oder 'sondertag' (ein Datum, Betriebsart, Zeiten).
|
||||
* Die Felder heissen nach dem Pfad der Fehler des Servers (ausnahmen.<ID>.von …); Absenden sucht die Ausnahme per ID.
|
||||
*/
|
||||
function formular(t, { a, art, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const daten = ctx.stand.daten
|
||||
const neu = a === null
|
||||
const alt = a || { id: neueZeitenId('x'), art, von: heuteBerlin(), bis: heuteBerlin(), text: '', modus: 'zu', fenster: [], plan: null }
|
||||
const p = `ausnahmen.${alt.id}`
|
||||
const urlaub = art === 'urlaub'
|
||||
const wort = urlaub ? 'Betriebsurlaub' : 'Sondertag'
|
||||
|
||||
const von = h('input', { type: 'date', value: alt.von })
|
||||
const bis = h('input', { type: 'date', value: alt.bis })
|
||||
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.ausnahmeText, autocomplete: 'off', placeholder: urlaub ? 'z. B. Sommerpause' : 'z. B. Tag der offenen Tür', value: alt.text })
|
||||
const tag = urlaub ? null : tagFelder({ pfad: `${p}.modus`, fensterPfad: `${p}.fenster`, label: 'Sondertag', wahl: MODUS_WAHL, sichtbar: true, modus: alt.modus, fenster: alt.fenster })
|
||||
const plaene = Array.isArray(daten.plaene) ? daten.plaene : []
|
||||
const geltung = plaene.length > 1 || alt.plan
|
||||
? h('select', null,
|
||||
h('option', { value: '', selected: !alt.plan }, 'Alle Pläne'),
|
||||
plaene.map((x) => h('option', { value: x.id, selected: alt.plan === x.id }, x.name)))
|
||||
: null
|
||||
|
||||
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
|
||||
titel: `${wort} entfernen?`,
|
||||
text: urlaub ? 'In dieser Zeit gilt danach wieder der Wochenplan.' : 'An diesem Tag gilt danach wieder der Wochenplan (und der Feiertag, falls einer ist).',
|
||||
aendere: (entwurf) => { entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((x) => x?.id !== alt.id) },
|
||||
nachher: () => nachSpeichern(t, `${wort} entfernt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen'])),
|
||||
})
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, neu ? `${wort} eintragen` : 'Speichern')
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? `${wort} eintragen` : `${wort} bearbeiten` },
|
||||
h('h3', null, neu ? (urlaub ? 'Neuer Betriebsurlaub' : 'Neuer Sondertag') : wort),
|
||||
gesperrt(ctx.nurLesen, [
|
||||
urlaub
|
||||
? h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis))
|
||||
: feld(`${p}.von`, 'Datum', von),
|
||||
tag?.el,
|
||||
feld(`${p}.text`, 'Text (optional)', text, 'Höchstens 60 Zeichen.'),
|
||||
geltung ? feld(`${p}.plan`, 'Gilt für', geltung) : null,
|
||||
]),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
ctx.nurLesen ? null : speichern,
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
|
||||
entfernen?.knopf))
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
let eintrag = null
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const fehler = {}
|
||||
if (!istTag(von.value)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
|
||||
let ende = von.value
|
||||
if (urlaub) {
|
||||
ende = bis.value
|
||||
if (!istTag(bis.value)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
|
||||
else if (istTag(von.value) && bis.value < von.value) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
|
||||
}
|
||||
let betrieb = { modus: 'zu', fenster: [] }
|
||||
if (tag) {
|
||||
const r = tag.lies()
|
||||
if (r.fehler) Object.assign(fehler, r.fehler)
|
||||
else {
|
||||
const meldung = tagFehler(r)
|
||||
if (meldung) fehler[`${p}.fenster`] = meldung
|
||||
betrieb = r
|
||||
}
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
eintrag = { ...alt, art, von: von.value, bis: ende, text: text.value.trim(), modus: betrieb.modus, fenster: betrieb.fenster, plan: geltung ? (geltung.value || null) : alt.plan ?? null }
|
||||
if (neu) {
|
||||
entwurf.ausnahmen = [...(Array.isArray(entwurf.ausnahmen) ? entwurf.ausnahmen : []), eintrag]
|
||||
return true
|
||||
}
|
||||
const stelle = stelleVon(entwurf.ausnahmen, alt.id, form, `Dieser ${wort}`)
|
||||
if (stelle < 0) return false
|
||||
entwurf.ausnahmen[stelle] = eintrag
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
// Urlaub ab 2 Tagen: die Hinweis-Leiste dazu anbieten (nicht noch einmal, wenn es sie schon gibt oder sich der Zeitraum nicht geaendert hat)
|
||||
if (urlaub) {
|
||||
const vorschlag = hinweisVorschlag(eintrag)
|
||||
const geaendert = neu || alt.von !== eintrag.von || alt.bis !== eintrag.bis
|
||||
ctx.hinweisFrage = vorschlag && geaendert && !gibtHinweis(ctx.stand.daten.hinweise, vorschlag) ? { vorschlag } : null
|
||||
}
|
||||
nachSpeichern(t, neu ? `${wort} eingetragen.` : `${wort} gespeichert.`, `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`, () => t.zeichne(['ausnahmen']))
|
||||
})
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Bestell-, Einschalt- und Behalten-Seite und die Statuskarte (ES-Modul, wird relativ von zeiten.js
|
||||
geladen; Spec 3.1 bis 3.3, Server: server/routes/funktionen.js). Alles, was hier steht, kommt vom Server: Preis, Zustimmungen, Knopftexte
|
||||
und Belehrung aus GET /api/funktionen (zeiten = Angebot, features.zeiten = Register-Ansicht der bezahlten Zustimmung), nie aus einer Kopie
|
||||
im Browser. Fremdtext kommt nur ueber textContent in die Seite (h aus termine-dom.js), nie ueber innerHTML.
|
||||
|
||||
renderZeitenBestellen(root, { ctx, angebot, projekt, modus }) zeichnet je nach Abo-Lage (angebot.inklusive, Ruling R20):
|
||||
true Einschalten: Wartungs-Abo laeuft, ein Haken (Leistungsbeschreibung), Knopf "Einschalten", kein Preis und keine
|
||||
Belehrung (es gibt kein Widerrufsverlangen); POST /api/funktionen/zeiten/einschalten
|
||||
false Bestellen: 30 € einmalig, Preis, Widerrufsbelehrung samt Muster-Formular als Text vor dem Knopf (Ruling R32:
|
||||
exakt der Text, den der Server im Nachweis speichert, angebot.belehrung), zwei Haken, Knopf aus dem Register;
|
||||
POST /api/funktionen/zeiten/checkout, dann Weiterleitung zu Stripe
|
||||
false, grund nicht_aktiv Hinweis "einschaltbar, sobald dein Abo aktiv ist", kein Knopf
|
||||
null "Gerade nicht prüfbar – bitte später erneut" mit Knopf "Noch einmal versuchen"
|
||||
modus 'behalten' (nach dem Ende des Wartungs-Abos, Spec 3.3): dieselbe bezahlte Seite, POST /api/funktionen/zeiten/behalten.
|
||||
Fehlt die Belehrung oder die Zustimmung, bestellt die Seite nie (der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat).
|
||||
|
||||
ctx (baut zeiten.js): { projectId, features, ustHinweis, kundenansicht (Boolean), neuZeichnen({ meldung }), fertig({ danke }), zurueck() }.
|
||||
Bei 409 (inklusive, kein_abo, gekuendigt, nicht_aktiv, fassung_veraltet, schon bestellt …) holt neuZeichnen die Liste neu und zeichnet die Ansicht
|
||||
nach dem neuen Stand mit der Meldung des Servers; fehlt ctx.neuZeichnen oder scheitert es, bleibt die Meldung am Knopf.
|
||||
zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung }, { behalten, weiter }) ist die Karte fuer ordered, setup, active
|
||||
und disabled (Zustand off hat die Seite oben). */
|
||||
|
||||
import { bestellKoerper, sicherLink } from './funktion-bestellen.js'
|
||||
import { fehlerKarte, h, hakenFeld, request } from './termine-dom.js'
|
||||
|
||||
const WEGE = {
|
||||
einschalten: '/api/funktionen/zeiten/einschalten',
|
||||
bestellen: '/api/funktionen/zeiten/checkout',
|
||||
behalten: '/api/funktionen/zeiten/behalten',
|
||||
}
|
||||
const KUNDENANSICHT = {
|
||||
einschalten: 'In der Kundenansicht kann nicht eingeschaltet werden.',
|
||||
bestellen: 'In der Kundenansicht kann nicht bestellt werden.',
|
||||
behalten: 'In der Kundenansicht kann nicht bestellt werden.',
|
||||
}
|
||||
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
|
||||
const NICHT_GELADEN = 'Die Seite konnte gerade nicht geladen werden. Bitte lade sie neu.'
|
||||
const NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
|
||||
const ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
|
||||
const FEHLER = {
|
||||
einschalten: 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
bestellen: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
behalten: 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
}
|
||||
|
||||
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||
const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && typeof wert.knopf === 'string' && Array.isArray(wert.items)
|
||||
const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
|
||||
|
||||
/**
|
||||
* Wo steht das Wartungs-Abo, wenn das Abo-Ende erkannt ist (Behalten)? 'bezahlen': kein Abo (inklusive false UND grund kein_abo), nur dann gibt es
|
||||
* Behalten gegen 30 €. 'abo': das Abo laeuft (grund nicht_aktiv = Probezeit/Zahlung offen, gekuendigt = Kuendigung vorgemerkt; der Server antwortet
|
||||
* beim Behalten dort mit 409 „Abo läuft wieder“): nie ein Bezahlknopf (Z12, R20, R25). 'inklusive': enthalten (inklusive true). 'unklar': Abo nicht
|
||||
* lesbar oder unbekannter Grund (fail-closed, kein Bezahlknopf auf Verdacht). Doppelt zu zeitenBehaltenLage in funktionen.js (die Kachel importiert nichts).
|
||||
*/
|
||||
export function behaltenLage(angebot) {
|
||||
if (angebot?.inklusive === true) return 'inklusive'
|
||||
if (angebot?.inklusive !== false) return 'unklar'
|
||||
if (angebot.grund === 'kein_abo') return 'bezahlen'
|
||||
return angebot.grund === 'nicht_aktiv' || angebot.grund === 'gekuendigt' ? 'abo' : 'unklar'
|
||||
}
|
||||
|
||||
/** Adresse einer Unterlage: https oder /rechtliches/ (sicherLink) oder eine Datei unter /vertragsunterlagen/ dieses Portals, sonst ''. */
|
||||
function unterlagenLink(url) {
|
||||
const sicher = sicherLink(url)
|
||||
if (sicher) return sicher
|
||||
return typeof url === 'string' && /^\/vertragsunterlagen\/[A-Za-z0-9._-]+\.(?:html|pdf)$/.test(url) && !url.includes('..') ? url : ''
|
||||
}
|
||||
|
||||
/** Zur Zahlungsseite darf nur eine https-Adresse fuehren, nie ein /rechtliches/-Pfad. */
|
||||
function zahlungsUrl(url) {
|
||||
const sicher = sicherLink(url)
|
||||
return sicher.startsWith('https://') ? sicher : ''
|
||||
}
|
||||
|
||||
/** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungueltigem Wert ''. */
|
||||
function datumTag(wert) {
|
||||
const tag = new Date(wert)
|
||||
if (!wert || Number.isNaN(tag.getTime())) return ''
|
||||
return tag.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail
|
||||
* K29) als TT.MM.JJJJ; ein unlesbares Datum gibt '' (kein erfundenes Datum). Doppelt zu zeitenFrist in funktionen.js (die Kachel importiert nichts).
|
||||
*/
|
||||
export function behaltenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
if (Number.isNaN(ms)) return ''
|
||||
const [jahr, monat, tag] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
|
||||
return new Date(Date.UTC(jahr, monat - 1, tag + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''. */
|
||||
export function tagText(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text(iso))
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Bestellseite --- */
|
||||
/**
|
||||
* Bestell-, Einschalt- oder Behalten-Seite in root zeichnen und das Element zurueckgeben (siehe Kopf der Datei).
|
||||
* Der Knopf ist gesperrt, solange ein Haken fehlt, ein Klick laeuft oder die Kundenansicht aktiv ist.
|
||||
*/
|
||||
export function renderZeitenBestellen(root, { ctx = {}, angebot = {}, projekt = {}, modus = 'bestellen' } = {}) {
|
||||
const zeige = (element) => {
|
||||
root.replaceChildren(element)
|
||||
return element
|
||||
}
|
||||
const projectId = text(ctx.projectId) || text(projekt.projectId)
|
||||
const zurueck = typeof ctx.zurueck === 'function' && modus === 'behalten'
|
||||
? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => ctx.zurueck() }, 'Zurück')
|
||||
: null
|
||||
const nochmal = typeof ctx.neuZeichnen === 'function' ? () => ctx.neuZeichnen({}).catch(() => {}) : undefined
|
||||
|
||||
// Welche Seite? (Abo-Lage laut Server, Ruling R20)
|
||||
let variante
|
||||
if (angebot.inklusive === null || angebot.inklusive === undefined) {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck))
|
||||
}
|
||||
if (modus === 'behalten' && behaltenLage(angebot) !== 'bezahlen') {
|
||||
// das Abo laeuft (wieder) oder ist nicht sicher lesbar: nichts zu bezahlen, nie ein Bezahlknopf auf Verdacht
|
||||
if (behaltenLage(angebot) === 'unklar') {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck))
|
||||
}
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'abo-laeuft' }, h('h3', null, 'Dein Wartungs-Abo läuft wieder'), h('p', null, 'Öffnungszeiten live ist darin enthalten – du musst nichts bezahlen.'), zurueck))
|
||||
}
|
||||
if (modus === 'behalten') variante = 'behalten'
|
||||
else if (angebot.inklusive === true) variante = 'einschalten'
|
||||
else if (angebot.grund === 'nicht_aktiv') {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-aktiv' }, h('h3', null, 'Im Wartungs-Abo enthalten'), h('p', null, 'Öffnungszeiten live ist in deinem Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.')))
|
||||
} else variante = 'bestellen'
|
||||
|
||||
const bezahlt = variante !== 'einschalten'
|
||||
const consent = bezahlt ? ctx.features?.zeiten?.consent : angebot.einschalten?.consent
|
||||
const belehrung = text(angebot.belehrung)
|
||||
// Ohne Zustimmung oder (bezahlt) ohne Belehrung wird nie bestellt: der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat
|
||||
if (!istConsent(consent) || (bezahlt && belehrung.trim() === '')) return zeige(fehlerKarte(NICHT_GELADEN))
|
||||
const eintraege = consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object')
|
||||
const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true)
|
||||
const kundenansicht = ctx.kundenansicht === true
|
||||
const preis = text(angebot.preis) || text(ctx.features?.zeiten?.priceLabel) || '30 €'
|
||||
const einrichtung = text(ctx.features?.zeiten?.setupEstimate)
|
||||
const gewaehlt = new Set()
|
||||
let laeuft = false
|
||||
|
||||
const einleitung = {
|
||||
einschalten: 'Öffnungszeiten live ist in deinem Wartungs-Abo ohne Aufpreis enthalten. Du schaltest die Funktion ein, danach trägst du deine Zeiten ein.',
|
||||
bestellen: 'Deine Website zeigt live, ob du gerade geöffnet hast. Die Einrichtung kostet einmalig – danach läuft die Funktion ohne laufende Kosten.',
|
||||
behalten: projekt.features?.zeiten?.state === 'disabled'
|
||||
? `Öffnungszeiten live ist abgeschaltet, deine Eingaben sind noch gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`
|
||||
: `Dein Wartungs-Abo ist beendet. Für einmalig ${preis} behältst du Öffnungszeiten live – ohne neuen Einbau, an deiner Website ändert sich nichts.`,
|
||||
}[variante]
|
||||
|
||||
// Preis: nur auf dem bezahlten Weg; die Einrichtungsdauer nur bei einer Neubestellung (beim Behalten gibt es keinen neuen Einbau)
|
||||
const preisBlock = bezahlt
|
||||
? h('div', { class: 'tm-bestellen-preis' },
|
||||
h('h4', null, variante === 'bestellen' ? 'Preis und Einrichtung' : 'Preis'),
|
||||
h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, preis), ` einmalig${text(ctx.ustHinweis) ? `, ${ctx.ustHinweis}` : ''}`),
|
||||
variante === 'bestellen' && einrichtung ? h('p', null, `Einrichtung: ${einrichtung}`) : null)
|
||||
: null
|
||||
|
||||
// Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, nie eingeklappt, genau der Text des Nachweises (R32)
|
||||
const belehrungBlock = bezahlt
|
||||
? h('div', { class: 'tm-bestellen-belehrung' },
|
||||
h('h4', null, 'Widerrufsbelehrung'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Lies die Belehrung bitte ganz. Das Muster-Widerrufsformular gehört dazu.'),
|
||||
h('div', { class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' }, belehrung))
|
||||
: null
|
||||
|
||||
const fassung = bezahlt ? ctx.features?.zeiten?.leistungsbeschreibung : angebot.einschalten?.leistungsbeschreibung
|
||||
const link = unterlagenLink(fassung?.url)
|
||||
const unterlagenBlock = link === '' ? null : h('div', { class: 'tm-bestellen-unterlagen' },
|
||||
h('h4', null, 'Unterlagen'),
|
||||
h('ul', { class: 'tm-bestellen-dokumente' }, h('li', null, h('a', { href: link, target: '_blank', rel: 'noopener noreferrer' }, 'Leistungsbeschreibung „Öffnungszeiten live“'))))
|
||||
|
||||
// Zustimmungen in Registerreihenfolge: Eintraege mit haken: true sind Haken, die anderen reiner Text
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': variante, disabled: true, onclick: () => absenden() }, consent.knopf)
|
||||
const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'bestellen' })
|
||||
const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
|
||||
const hakenZeile = (eintrag) => {
|
||||
const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
|
||||
feld.addEventListener('change', () => {
|
||||
if (feld.checked) gewaehlt.add(eintrag.id)
|
||||
else gewaehlt.delete(eintrag.id)
|
||||
aktualisiere()
|
||||
})
|
||||
return hakenFeld(text(eintrag.id), feld, text(eintrag.text))
|
||||
}
|
||||
const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' },
|
||||
h('h4', null, 'Deine Zustimmung'),
|
||||
h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text))))))
|
||||
|
||||
const titel = { einschalten: 'Öffnungszeiten live einschalten', bestellen: 'Öffnungszeiten live bestellen', behalten: 'Öffnungszeiten live behalten' }[variante]
|
||||
const seite = h('section', { class: 'kb-panel tm-bestellen zt-bestellen', 'data-variante': variante },
|
||||
h('h3', null, titel),
|
||||
h('p', { class: 'tm-abschnitt-text tm-hinweis' }, einleitung),
|
||||
preisBlock,
|
||||
belehrungBlock,
|
||||
unterlagenBlock,
|
||||
zustimmungBlock,
|
||||
h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl, zurueck))
|
||||
zeige(seite)
|
||||
|
||||
function meldung(nachricht) {
|
||||
meldungEl.textContent = nachricht
|
||||
}
|
||||
|
||||
function aktualisiere() {
|
||||
const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id))
|
||||
knopf.disabled = laeuft || hakenFehlen || kundenansicht
|
||||
if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT[variante]
|
||||
else hinweisEl.textContent = hakenFehlen ? (variante === 'einschalten' ? 'Setze den Haken, um einzuschalten.' : 'Setze alle Haken, um zu bestellen.') : ''
|
||||
}
|
||||
|
||||
async function absenden() {
|
||||
if (knopf.disabled) return
|
||||
laeuft = true
|
||||
aktualisiere()
|
||||
meldung('')
|
||||
let antwort
|
||||
try {
|
||||
antwort = await request(WEGE[variante], { method: 'POST', body: bestellKoerper({ projectId, bestellung: { consent }, gewaehlt }) })
|
||||
} catch (err) {
|
||||
// 409: der Stand hat sich geaendert (Abo, schon bestellt, neue Fassung): die Ansicht neu nach dem Server zeichnen, mit seiner Meldung
|
||||
if (err?.status === 409 && typeof ctx.neuZeichnen === 'function') {
|
||||
try {
|
||||
await ctx.neuZeichnen({ meldung: err.message })
|
||||
return
|
||||
} catch {
|
||||
// die Meldung steht dann am Knopf
|
||||
}
|
||||
}
|
||||
meldung(err?.message || FEHLER[variante])
|
||||
laeuft = false
|
||||
aktualisiere()
|
||||
return
|
||||
}
|
||||
if (variante === 'einschalten') {
|
||||
// eingeschaltet ist eingeschaltet, auch wenn das Neuladen der Ansicht danach scheitert (ein zweiter Klick waere harmlos, ist aber unnoetig)
|
||||
try {
|
||||
if (typeof ctx.fertig === 'function') await ctx.fertig({ danke: 'eingeschaltet', antwort })
|
||||
else meldung('Öffnungszeiten live ist eingeschaltet.')
|
||||
} catch {
|
||||
meldung('Öffnungszeiten live ist eingeschaltet. Lade die Seite neu, dann siehst du den aktuellen Stand.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const ziel = zahlungsUrl(antwort?.url)
|
||||
if (ziel) {
|
||||
window.location.href = ziel // die Seite wird verlassen, der Knopf bleibt gesperrt
|
||||
return
|
||||
}
|
||||
meldung(ZAHLUNGSSEITE)
|
||||
laeuft = false
|
||||
aktualisiere()
|
||||
}
|
||||
|
||||
aktualisiere()
|
||||
return seite
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Statuskarte --- */
|
||||
const PILLE = { ordered: ['Zahlung offen', 'wait'], setup: ['In Einrichtung', 'wait'], active: ['Aktiv', 'ok'], disabled: ['Abgeschaltet', 'off'] }
|
||||
const DANKE = {
|
||||
bestellt: 'Danke! Sobald die Zahlung da ist, legen wir los.',
|
||||
bestelltBezahlt: 'Danke! Deine Zahlung ist bei uns angekommen.',
|
||||
behalten: 'Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live. An deiner Website ändert sich nichts.',
|
||||
behaltenBezahlt: 'Danke! Du behältst Öffnungszeiten live.',
|
||||
eingeschaltet: 'Öffnungszeiten live ist eingeschaltet.',
|
||||
}
|
||||
|
||||
/** Danke-Satz zur Rueckkehr: 'bestellt' | 'behalten' | 'eingeschaltet', je nachdem, ob die Zahlung schon gebucht ist; sonst ''. */
|
||||
function dankeText(danke, feature) {
|
||||
if (danke === 'bestellt') return feature?.state === 'ordered' ? DANKE.bestellt : DANKE.bestelltBezahlt
|
||||
if (danke === 'behalten') return feature?.aboEndeAt ? DANKE.behalten : DANKE.behaltenBezahlt
|
||||
return danke === 'eingeschaltet' ? DANKE.eingeschaltet : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte fuer ordered („Zahlung offen“), setup („In Einrichtung“), active und disabled.
|
||||
* projekt = Eintrag aus GET /api/funktionen (projectId, zeitenLeer), feature = projekt.features.zeiten (state, einbau, ticketWoid, orderedAt,
|
||||
* invoiceUrl, aboEndeAt, disabledReason, loeschungAm), angebot = zeiten (inklusive, preis), kundenansicht = Sitzung in der Kundenansicht,
|
||||
* danke = 'bestellt' | 'behalten' | 'eingeschaltet' (Rueckkehr von Stripe oder Einschalten), meldung = Text am Knopf.
|
||||
* behalten() oeffnet die Behalten-Seite (Knopf „Für 30 € behalten“ nur ohne Abo: behaltenLage 'bezahlen', also inklusive false UND grund kein_abo; laeuft das Abo, steht nur der Hinweis);
|
||||
* weiter({ bestellen, meldung }) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (bestellen: es gibt keine offene Zahlungsseite mehr, die Ansicht
|
||||
* zeigt wieder die Bestellseite).
|
||||
*/
|
||||
export function zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung } = {}, { behalten, weiter } = {}) {
|
||||
const state = text(feature?.state) || 'off'
|
||||
const [pilleText, pilleArt] = PILLE[state] || PILLE.setup
|
||||
const preis = text(angebot?.preis) || '30 €'
|
||||
const leer = projekt?.zeitenLeer === true
|
||||
const ticket = feature?.ticketWoid
|
||||
const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== ''
|
||||
const bestelltAm = datumTag(feature?.orderedAt)
|
||||
const rechnung = sicherLink(feature?.invoiceUrl)
|
||||
const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
|
||||
const fakten = [
|
||||
state === 'setup' || state === 'active' ? (hatTicket ? zeile('Ticketnummer', `#${ticket}`) : null) : null,
|
||||
bestelltAm ? zeile('Bestellt am', bestelltAm) : null,
|
||||
].filter(Boolean)
|
||||
|
||||
const aboEnde = feature?.disabledReason === 'abo_ende'
|
||||
const daten = state === 'disabled' && aboEnde && projekt?.zeitenLeer === false && !(text(feature?.loeschungAm) !== '' && feature.loeschungAm < heuteBerlin())
|
||||
const frist = (state === 'setup' || state === 'active') && feature?.aboEndeAt ? behaltenFrist(feature.aboEndeAt) : ''
|
||||
|
||||
const titel = {
|
||||
ordered: 'Zahlung wird bestätigt …',
|
||||
setup: 'Wir richten Öffnungszeiten live ein',
|
||||
active: 'Öffnungszeiten live ist aktiv',
|
||||
disabled: 'Öffnungszeiten live ist abgeschaltet',
|
||||
}[state] || 'Öffnungszeiten live'
|
||||
const absatz = {
|
||||
ordered: 'Sobald deine Zahlung bei uns ankommt, bauen wir Öffnungszeiten live ein.',
|
||||
setup: feature?.einbau === 'angestossen'
|
||||
? 'Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website (meist in wenigen Minuten).'
|
||||
: 'Unser Team baut die Funktion ein.',
|
||||
active: 'Deine Öffnungszeiten stehen live auf deiner Website. Was du hier änderst, übernimmt die Website automatisch.',
|
||||
disabled: aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ABGESCHALTET,
|
||||
}[state] || ''
|
||||
|
||||
const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'karte' }, text(meldung) || null)
|
||||
const sperre = kundenansicht === true
|
||||
|
||||
// Behalten (30 €): in Einrichtung/aktiv nach dem erkannten Abo-Ende, abgeschaltet solange die Eingaben noch da sind
|
||||
const behaltenKnopf = (beschriftung) => h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'behalten', disabled: sperre, onclick: () => { if (typeof behalten === 'function') behalten() } }, beschriftung)
|
||||
const lage = behaltenLage(angebot)
|
||||
let behaltenBox = null
|
||||
if (frist && lage === 'bezahlen') {
|
||||
behaltenBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'behalten' },
|
||||
h('span', null, `Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${preis}.`),
|
||||
behaltenKnopf(`Für ${preis} behalten`))
|
||||
} else if (frist && lage === 'abo') {
|
||||
behaltenBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'))
|
||||
}
|
||||
let abgeschaltetBox = null
|
||||
if (daten && lage === 'bezahlen') {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'wieder-einschalten' },
|
||||
h('span', null, `Deine Eingaben bleiben${tagText(feature?.loeschungAm) ? ` bis ${tagText(feature.loeschungAm)}` : ''} gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`),
|
||||
behaltenKnopf(`Für ${preis} wieder einschalten`))
|
||||
} else if (daten && (lage === 'inklusive' || lage === 'abo')) {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).'))
|
||||
} else if (daten) {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'nicht-pruefbar' }, h('span', null, NICHT_PRUEFBAR))
|
||||
}
|
||||
|
||||
// Zahlung fortsetzen (nur ordered): dieselbe Zahlungsseite, ohne neue Zustimmung; gibt es keine mehr, zeigt die Ansicht wieder die Bestellseite
|
||||
const fortsetzenKnopf = state === 'ordered'
|
||||
? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: sperre, onclick: () => fortsetzen() }, 'Zahlung fortsetzen')
|
||||
: null
|
||||
async function fortsetzen() {
|
||||
if (fortsetzenKnopf.disabled) return
|
||||
fortsetzenKnopf.disabled = true
|
||||
meldungEl.textContent = ''
|
||||
try {
|
||||
const antwort = await request(WEGE.bestellen, { method: 'POST', body: { projectId: projekt?.projectId, fortsetzen: true } })
|
||||
const ziel = zahlungsUrl(antwort?.url)
|
||||
if (ziel) {
|
||||
window.location.href = ziel
|
||||
return
|
||||
}
|
||||
meldungEl.textContent = ZAHLUNGSSEITE
|
||||
} catch (err) {
|
||||
if (typeof weiter === 'function' && err?.code === 'zustimmung_fehlt') {
|
||||
weiter({ bestellen: true, meldung: err.message })
|
||||
return
|
||||
}
|
||||
meldungEl.textContent = err?.message || ZAHLUNGSSEITE
|
||||
}
|
||||
fortsetzenKnopf.disabled = false
|
||||
}
|
||||
|
||||
const dankeSatz = dankeText(danke, feature)
|
||||
return h('section', { class: 'kb-panel tm-statuskarte zt-status', 'data-state': state },
|
||||
dankeSatz ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': danke }, dankeSatz) : null,
|
||||
h('span', { class: `tm-pille tm-pille-${pilleArt}` }, pilleText),
|
||||
h('h3', null, titel),
|
||||
absatz ? h('p', null, absatz) : null,
|
||||
state === 'setup' && leer ? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.')) : null,
|
||||
state === 'active' && leer ? h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein.')) : null,
|
||||
fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
|
||||
state === 'ordered' ? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.') : null,
|
||||
behaltenBox,
|
||||
abgeschaltetBox,
|
||||
meldungEl,
|
||||
sperre && (fortsetzenKnopf || behaltenBox || abgeschaltetBox) ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT.bestellen) : null,
|
||||
h('div', { class: 'tm-aktionen' },
|
||||
fortsetzenKnopf,
|
||||
state === 'setup' && rechnung ? h('a', { class: 'kb-btn-outline', href: rechnung, target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
|
||||
chatKnopf()))
|
||||
}
|
||||
|
||||
/** Heutiger Berliner Kalendertag 'JJJJ-MM-TT' (zum Vergleich mit loeschungAm). */
|
||||
function heuteBerlin() {
|
||||
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: der Editor (ES-Modul, zeiten.js haengt ihn in .zt-editor ein; Spec 3.4). Server:
|
||||
server/routes/zeiten.js. renderEditor(root, ctx) laedt GET /api/zeiten/<projectId> und zeichnet die Abschnitte; die Eingaben des Kunden
|
||||
bleiben im Editor, solange der Platz (root) steht – zeiten.js baut ihn bei einem neuen Stand der Funktion nicht neu auf.
|
||||
root.dataset.state = Zustand der Funktion (setup, active, disabled): disabled heisst nur lesen (alles gesperrt, PUT antwortet mit 403).
|
||||
ctx von zeiten.js: { projectId, kundenansicht, neuZeichnen() } – neuZeichnen holt die Liste der Funktionen neu und zeichnet den Kopf der
|
||||
Ansicht (Statuskarte); der Editor ruft es einmal nach dem ersten Speichern (zeitenLeer wechselt), nie sonst.
|
||||
Der Editor-ctx t.ctx der Abschnitte:
|
||||
{ projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }),
|
||||
beiStand(fn), leereDaten(), leer() }
|
||||
Die sechs Abschnitte (ABSCHNITTE): Zeitplaene, Feiertage, Ausnahmen (zeiten-plaene.js, zeiten-ausnahmen.js), Hinweis-Leiste, Kontakt-Leiste (zeiten-leisten.js) und
|
||||
Google (zeiten-google.js). Oben im Kopfbereich steht die Live-Vorschau „So steht es gerade auf deiner Seite: …“ (Status aus GET /api/zeiten bzw. der Antwort des
|
||||
letzten Speicherns) mit dem Regler „Uhrzeit testen“ (Wochentag und Uhrzeit, GET /api/zeiten/<projectId>/vorschau?zeit=JJJJ-MM-TTTHH:MM, dieselbe Serverlogik; vor dem
|
||||
ersten Speichern antwortet der Server mit 409 keine_zeiten: das ist ein Hinweis, kein Fehler). Abgeschaltet (nur lesen) gibt es keine Vorschau: die Website zeigt dann nichts mehr.
|
||||
ctx.beiStand(fn): fn() laeuft nach jedem Speichern, wenn ctx.stand neu ist (Vorschau, Google-Text); gibt fn false zurueck, wird sie nicht mehr gerufen (Abschnitt weg).
|
||||
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem
|
||||
Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }.
|
||||
t.zeichne() zeichnet alles neu (nur nach „Neu laden“, wenn die Daten vom Server neu sind); t.zeichne(['ausnahmen', …]) zeichnet nur die genannten Abschnitte
|
||||
neu und laesst alle anderen Formulare mit ihren ungespeicherten Eingaben stehen – das ist der Weg nach einem Speichern.
|
||||
Vor dem ersten Speichern (stand.daten null) gibt es nur den leeren Hauptplan; die anderen Abschnitte (mitDaten) erscheinen danach, denn der
|
||||
Server verlangt mindestens einen Plan. Die Kontaktdaten (daten.kontakt) gehen unveraendert zurueck: der Editor setzt nie ein neues
|
||||
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
|
||||
|
||||
import { fehlerKarte, h, request } from './termine-dom.js'
|
||||
import { WOCHENTAGE_PLAN, datumLang, heuteBerlin, istTag } from './termine-view.js'
|
||||
import { leererPlan, naechstesVorkommen, wochentagVon } from './zeiten-view.js'
|
||||
import { neuerZeitenSpeicher } from './zeiten-speichern.js'
|
||||
import { plaene } from './zeiten-plaene.js'
|
||||
import { ausnahmen, feiertage, feiertageUebernehmen } from './zeiten-ausnahmen.js'
|
||||
import { hinweise, kontakt } from './zeiten-leisten.js'
|
||||
import { google } from './zeiten-google.js'
|
||||
|
||||
/**
|
||||
* Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. uebernehmen(alt, neu): wird gerufen, wenn der Abschnitt allein
|
||||
* neu gezeichnet wird, und nimmt ungespeicherte Eingaben aus dem alten Abschnitt in den neuen mit (optional).
|
||||
*/
|
||||
const ABSCHNITTE = [
|
||||
{ key: 'plaene', bauen: plaene, mitDaten: false },
|
||||
{ key: 'feiertage', bauen: feiertage, mitDaten: true, uebernehmen: feiertageUebernehmen },
|
||||
{ key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
|
||||
{ key: 'hinweise', bauen: hinweise, mitDaten: true },
|
||||
{ key: 'kontakt', bauen: kontakt, mitDaten: true },
|
||||
{ key: 'google', bauen: google, mitDaten: true },
|
||||
]
|
||||
|
||||
const NICHT_GELADEN = 'Deine Öffnungszeiten konnten gerade nicht geladen werden. Bitte versuch es noch einmal.'
|
||||
const NUR_LESEN = 'Diese Funktion ist abgeschaltet. Du kannst deine Eingaben nur noch ansehen.'
|
||||
const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnungszeiten des Kunden. Jedes Speichern steht im Protokoll.'
|
||||
const LEER = 'Trag deine Öffnungszeiten ein. Sobald du speicherst, kennt deine Website sie.'
|
||||
const SPAETER = 'Feiertage, Ausnahmen und alles Weitere kannst du eintragen, sobald deine Öffnungszeiten gespeichert sind.'
|
||||
|
||||
const VORSCHAU_LEER = 'Noch keine Öffnungszeiten gespeichert.'
|
||||
const VORSCHAU_ERST_SPEICHERN = 'Speichere zuerst deine Öffnungszeiten, dann kannst du Uhrzeiten testen.'
|
||||
const VORSCHAU_KEINE_ANZEIGE = 'Dazu gibt es gerade keine Anzeige.'
|
||||
|
||||
// Welcher Aufruf von renderEditor gehoert zu einem Platz? Ein neuer Aufruf macht den alten ungueltig (seine Antworten fassen nichts mehr an).
|
||||
const LAEUFE = new WeakMap()
|
||||
|
||||
/** Antwort des Servers pruefen und auf die Felder reduzieren, die der Editor braucht; wirft bei einer unbrauchbaren Antwort. */
|
||||
function standAus(antwort) {
|
||||
if (antwort === null || typeof antwort !== 'object' || !Number.isInteger(antwort.version)) throw new Error(NICHT_GELADEN)
|
||||
const daten = antwort.daten !== null && typeof antwort.daten === 'object' && !Array.isArray(antwort.daten) ? antwort.daten : null
|
||||
if (daten !== null && !Array.isArray(daten.plaene)) throw new Error(NICHT_GELADEN)
|
||||
return {
|
||||
state: typeof antwort.state === 'string' ? antwort.state : '',
|
||||
// ohne Plaene ist nichts gespeichert (wie vor dem ersten Speichern)
|
||||
daten: daten !== null && daten.plaene.length > 0 ? daten : null,
|
||||
version: antwort.version,
|
||||
kundenansicht: antwort.kundenansicht === true,
|
||||
vorschau: antwort.vorschau ?? null,
|
||||
feiertage: (Array.isArray(antwort.feiertage) ? antwort.feiertage : []).filter((f) => f && istTag(f.tag) && typeof f.name === 'string'),
|
||||
googleText: typeof antwort.googleText === 'string' ? antwort.googleText : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Berliner Uhrzeit 'HH:MM' (Vorgabe des Reglers). */
|
||||
const berlinUhrzeit = (jetzt = new Date()) => jetzt.toLocaleTimeString('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
/**
|
||||
* Live-Vorschau oben im Editor: der Status, wie ihn die Website jetzt zeigt (ec.stand.vorschau = { offen, text, kurz }), und der Regler „Uhrzeit testen“. Der
|
||||
* Regler fragt den Server mit dem naechsten Vorkommen des gewaehlten Wochentags (ab heute, Berliner Kalendertag) und der Uhrzeit (Berliner Wanduhr); die Logik ist
|
||||
* die der Website. Nur die letzte Anfrage zaehlt. Nach jedem Speichern (ec.beiStand) zeigt die Vorschau den neuen Status, und eine schon gestellte Frage wird mit
|
||||
* den neuen Daten wiederholt.
|
||||
*/
|
||||
function vorschauKarte(ec) {
|
||||
const jetzt = h('strong', { class: 'zt-vorschau-text', 'data-vorschau': 'jetzt' })
|
||||
const zeigeJetzt = () => {
|
||||
const v = ec.stand.vorschau
|
||||
const gueltig = v !== null && typeof v === 'object' && typeof v.text === 'string' && v.text !== ''
|
||||
jetzt.textContent = gueltig ? v.text : VORSCHAU_LEER
|
||||
jetzt.setAttribute('data-offen', gueltig ? String(v.offen === true || v.offen === false ? v.offen : null) : 'leer')
|
||||
}
|
||||
zeigeJetzt()
|
||||
|
||||
const heute = heuteBerlin()
|
||||
const tag = h('select', { id: 'zt-regler-tag', 'data-regler': 'tag' }, WOCHENTAGE_PLAN.map(([nr, name]) => h('option', { value: nr, selected: Number(nr) === wochentagVon(heute) }, name)))
|
||||
const zeit = h('input', { id: 'zt-regler-zeit', type: 'time', 'data-regler': 'zeit', value: berlinUhrzeit() })
|
||||
const ergebnis = h('p', { class: 'tm-hinweis zt-regler-ergebnis', 'data-vorschau': 'test', 'aria-live': 'polite' })
|
||||
const karte = h('section', { class: 'kb-panel zt-vorschau', 'aria-label': 'Vorschau deiner Öffnungszeiten' },
|
||||
h('p', { class: 'zt-vorschau-zeile' }, 'So steht es gerade auf deiner Seite: ', jetzt),
|
||||
h('fieldset', { class: 'zt-regler' },
|
||||
h('legend', null, 'Uhrzeit testen'),
|
||||
h('div', { class: 'zt-regler-felder' },
|
||||
h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-tag' }, 'Wochentag'), tag),
|
||||
h('div', { class: 'tm-feld' }, h('label', { for: 'zt-regler-zeit' }, 'Uhrzeit'), zeit)),
|
||||
ergebnis))
|
||||
|
||||
let lauf = 0
|
||||
let benutzt = false
|
||||
async function frage() {
|
||||
const wann = zeit.value
|
||||
const nr = ++lauf
|
||||
if (!/^\d{2}:\d{2}$/.test(wann)) {
|
||||
ergebnis.textContent = ''
|
||||
ergebnis.removeAttribute('role')
|
||||
return
|
||||
}
|
||||
benutzt = true
|
||||
const datum = naechstesVorkommen(heuteBerlin(), Number(tag.value))
|
||||
const ort = () => nr === lauf && karte.isConnected && ec.aktuell() // eine veraltete Antwort oder ein anderer Editor zaehlt nicht
|
||||
try {
|
||||
const antwort = await request(`/api/zeiten/${encodeURIComponent(ec.projectId)}/vorschau?zeit=${encodeURIComponent(`${datum}T${wann}`)}`)
|
||||
if (!ort()) return
|
||||
ergebnis.removeAttribute('role')
|
||||
ergebnis.textContent = typeof antwort?.text === 'string' && antwort.text !== '' ? `${datumLang(datum)}, ${wann} Uhr: ${antwort.text}` : VORSCHAU_KEINE_ANZEIGE
|
||||
} catch (err) {
|
||||
if (!ort()) return
|
||||
if (err.code === 'keine_zeiten') {
|
||||
ergebnis.removeAttribute('role') // vor dem ersten Speichern gibt es nichts zu testen: ein Hinweis, kein Fehler
|
||||
ergebnis.textContent = VORSCHAU_ERST_SPEICHERN
|
||||
return
|
||||
}
|
||||
ergebnis.setAttribute('role', 'alert')
|
||||
ergebnis.textContent = err.message
|
||||
}
|
||||
}
|
||||
tag.addEventListener('change', frage)
|
||||
zeit.addEventListener('change', frage)
|
||||
ec.beiStand(() => {
|
||||
if (!karte.isConnected) return false
|
||||
zeigeJetzt()
|
||||
if (benutzt) frage()
|
||||
return true
|
||||
})
|
||||
return karte
|
||||
}
|
||||
|
||||
export async function renderEditor(root, ctx = {}) {
|
||||
const token = {}
|
||||
LAEUFE.set(root, token)
|
||||
const aktuell = () => root.isConnected && LAEUFE.get(root) === token
|
||||
const url = `/api/zeiten/${encodeURIComponent(ctx.projectId)}`
|
||||
const ladeStand = async () => standAus(await request(url))
|
||||
|
||||
root.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
let stand
|
||||
try {
|
||||
stand = await ladeStand()
|
||||
} catch (err) {
|
||||
if (aktuell()) root.replaceChildren(fehlerKarte(err.message, () => renderEditor(root, ctx)))
|
||||
return
|
||||
}
|
||||
if (!aktuell()) return
|
||||
|
||||
const nurLesen = ctx.nurLesen === true || root.dataset.state === 'disabled'
|
||||
const meldungEl = h('div', { class: 'tm-meldung', 'data-meldung': 'editor', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
||||
const entwurfsplan = leererPlan({ name: 'Öffnungszeiten', kurz: 'oeffnungszeiten', haupt: true })
|
||||
const beobachter = new Set() // Funktionen, die nach jedem Speichern laufen (beiStand)
|
||||
const ec = {
|
||||
projectId: ctx.projectId,
|
||||
kundenansicht: ctx.kundenansicht === true,
|
||||
nurLesen,
|
||||
stand,
|
||||
entwurfsplan, // der leere Hauptplan vor dem ersten Speichern
|
||||
offen: new Set([entwurfsplan.id, ...(stand.daten ? stand.daten.plaene.filter((p) => p?.haupt === true).map((p) => p.id) : [])]), // aufgeklappte Plaene
|
||||
hinweisFrage: null, // { vorschlag } nach einem Urlaub ab 2 Tagen
|
||||
aktuell,
|
||||
beiStand: (fn) => { beobachter.add(fn) },
|
||||
leer: () => ec.stand.daten === null,
|
||||
/** Daten fuer das erste Speichern: der leere Hauptplan; die Kontaktdaten setzt der Server auf seine Vorgabe. */
|
||||
leereDaten: () => ({ land: 'RP', plaene: [ec.entwurfsplan], feiertage: {}, ausnahmen: [], hinweise: [] }),
|
||||
/** Eine Meldung, die erst nach dem Wechsel der Website eintrifft, gehoert nicht mehr auf diese Seite. */
|
||||
meldung(text, art = 'ok') {
|
||||
if (!aktuell()) return
|
||||
meldungEl.textContent = text || ''
|
||||
meldungEl.className = `tm-meldung tm-meldung-${art}`
|
||||
meldungEl.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
|
||||
meldungEl.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
|
||||
if (!text) return
|
||||
if (art === 'fehler') meldungEl.focus({ preventScroll: true })
|
||||
meldungEl.scrollIntoView({ block: 'nearest' })
|
||||
},
|
||||
async neuLaden() {
|
||||
const neu = await ladeStand()
|
||||
if (aktuell()) ec.stand = neu
|
||||
return neu
|
||||
},
|
||||
/** Antwort auf ein erfolgreiches Speichern. Das erste Speichern aendert, was die Huelle zeigt (zeitenLeer): sie zeichnet ihren Kopf neu. */
|
||||
uebernimm(antwort, { erstes = false } = {}) {
|
||||
ec.stand = standAus(antwort)
|
||||
for (const fn of [...beobachter]) {
|
||||
if (fn() === false) beobachter.delete(fn)
|
||||
}
|
||||
if (erstes && typeof ctx.neuZeichnen === 'function') {
|
||||
try {
|
||||
Promise.resolve(ctx.neuZeichnen()).catch(() => {}) // scheitert das, zeigt der Kopf den alten Stand bis zum naechsten Besuch
|
||||
} catch { /* wie oben */ }
|
||||
}
|
||||
},
|
||||
}
|
||||
const sp = neuerZeitenSpeicher(ec, root, zeichne)
|
||||
const t = { ctx: ec, sp, zeichne, wurzel: root }
|
||||
|
||||
let leerGezeichnet = null
|
||||
const abschnittEl = new Map() // key => das gezeichnete Element des Abschnitts
|
||||
|
||||
function baueAbschnitt(abschnitt) {
|
||||
const el = abschnitt.bauen(t)
|
||||
el.dataset.abschnitt = abschnitt.key
|
||||
abschnittEl.set(abschnitt.key, el)
|
||||
return el
|
||||
}
|
||||
|
||||
/** Alles neu (welche fehlt) oder nur die Abschnitte in welche (Liste von Schluesseln). Aenderte sich leer/nicht leer, muss alles neu. */
|
||||
function zeichne(welche) {
|
||||
const leer = ec.leer()
|
||||
if (Array.isArray(welche) && welche.length && leer === leerGezeichnet) {
|
||||
for (const key of welche) {
|
||||
const abschnitt = ABSCHNITTE.find((a) => a.key === key)
|
||||
const alt = abschnittEl.get(key)
|
||||
if (!abschnitt || !alt?.parentNode) continue
|
||||
const neu = baueAbschnitt(abschnitt)
|
||||
abschnitt.uebernehmen?.(alt, neu)
|
||||
alt.parentNode.insertBefore(neu, alt)
|
||||
alt.remove()
|
||||
}
|
||||
return
|
||||
}
|
||||
leerGezeichnet = leer
|
||||
abschnittEl.clear()
|
||||
const teile = [meldungEl, sp.konflikt]
|
||||
if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT)))
|
||||
if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN)))
|
||||
else if (leer) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'leer' }, h('span', null, LEER)))
|
||||
teile.push(h('div', { class: 'zt-kopfbereich' }, nurLesen ? null : vorschauKarte(ec))) // Live-Vorschau; abgeschaltet zeigt die Website nichts mehr
|
||||
for (const abschnitt of ABSCHNITTE) {
|
||||
if (abschnitt.mitDaten && leer) continue
|
||||
teile.push(baueAbschnitt(abschnitt))
|
||||
}
|
||||
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
|
||||
root.replaceChildren(...teile)
|
||||
}
|
||||
zeichne()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 6 „Google“ (Spec 3.4). google(t) ist ein Abschnitt des Editors (t = { ctx, sp, zeichne, wurzel }
|
||||
aus zeiten-editor.js). Der Text fuer das Google-Profil kommt vom Server (ctx.stand.googleText aus GET/PUT /api/zeiten: Montag bis Sonntag, danach die
|
||||
Sonderzeiten der naechsten 60 Tage) und wird hier nie selbst gebaut. „Für Google kopieren“ legt ihn in die Zwischenablage; fehlt die Schnittstelle oder
|
||||
lehnt der Browser ab, bleibt der Text im (schreibgeschuetzten) Textfeld markiert und eine Meldung sagt, wie der Kunde ihn selbst kopiert. Nach jedem Speichern
|
||||
folgt die Anzeige dem neuen Text (ctx.beiStand). Das Google-Profil selbst oeffnet der Link in einem neuen Fenster (noopener); wir schreiben dort nichts. */
|
||||
|
||||
import { abschnitt, h } from './termine-dom.js'
|
||||
|
||||
const GOOGLE_PROFIL = 'https://business.google.com/'
|
||||
const KOPIERT = 'Kopiert. Füge den Text jetzt in dein Google-Profil ein.'
|
||||
const KEIN_TEXT = 'Es gibt noch keinen Text zum Kopieren. Speichere zuerst deine Öffnungszeiten.'
|
||||
const NICHT_KOPIERT = 'Das Kopieren hat nicht geklappt. Der Text ist unten markiert: Kopiere ihn selbst (Strg+C, am Handy: lange drücken und „Kopieren“ wählen).'
|
||||
|
||||
export function google(t) {
|
||||
const { ctx } = t
|
||||
const text = () => (typeof ctx.stand.googleText === 'string' ? ctx.stand.googleText : '')
|
||||
const feld = h('textarea', { class: 'zt-google-text', readonly: true, 'aria-label': 'Öffnungszeiten für dein Google-Profil', 'data-google': 'text' })
|
||||
const meldung = h('p', { class: 'tm-hinweis zt-google-meldung', role: 'status', 'aria-live': 'polite', 'data-meldung': 'google' })
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'google-kopieren' }, 'Für Google kopieren')
|
||||
|
||||
const zeige = () => {
|
||||
feld.value = text()
|
||||
feld.setAttribute('rows', String(Math.min(Math.max(text().split('\n').length + 1, 4), 16)))
|
||||
}
|
||||
zeige()
|
||||
|
||||
knopf.addEventListener('click', async () => {
|
||||
const inhalt = text() // der Stand zum Zeitpunkt des Klicks, nicht der beim Zeichnen
|
||||
if (inhalt === '') {
|
||||
meldung.textContent = KEIN_TEXT
|
||||
return
|
||||
}
|
||||
let kopiert = false
|
||||
try {
|
||||
const zwischenablage = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
|
||||
if (zwischenablage && typeof zwischenablage.writeText === 'function') {
|
||||
await zwischenablage.writeText(inhalt)
|
||||
kopiert = true
|
||||
}
|
||||
} catch {
|
||||
kopiert = false // nicht erlaubt: weiter mit dem markierten Text
|
||||
}
|
||||
if (kopiert) {
|
||||
meldung.textContent = KOPIERT
|
||||
return
|
||||
}
|
||||
feld.focus()
|
||||
feld.select()
|
||||
if (typeof feld.setSelectionRange === 'function') feld.setSelectionRange(0, feld.value.length) // iOS markiert sonst nichts
|
||||
meldung.textContent = NICHT_KOPIERT
|
||||
})
|
||||
|
||||
const el = abschnitt('Google',
|
||||
'Die Google-Daten im Seitencode laufen automatisch – dafür musst du nichts tun. Dein Google-Unternehmensprofil pflegst du selbst: Kopiere hier den Text mit deinen Öffnungszeiten (auch die Sonderzeiten der nächsten 60 Tage) und füge ihn dort ein.',
|
||||
feld,
|
||||
h('div', { class: 'zt-google-aktionen' },
|
||||
knopf,
|
||||
h('a', { class: 'kb-btn-outline', href: GOOGLE_PROFIL, target: '_blank', rel: 'noopener noreferrer', 'data-google': 'profil' }, 'Google-Profil öffnen')),
|
||||
meldung)
|
||||
ctx.beiStand(() => {
|
||||
if (!el.isConnected) return false // der Abschnitt wurde neu gezeichnet: diese Anzeige gibt es nicht mehr
|
||||
zeige()
|
||||
return true
|
||||
})
|
||||
return el
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 4 „Hinweis-Leiste“ und 5 „Kontakt-Leiste (Handy)“ (Spec 3.4). Jeder Bereich ist eine Funktion
|
||||
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne Abschnitte neu).
|
||||
Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js); nach einem Speichern wird nur der eigene Abschnitt neu gezeichnet, ungespeicherte Eingaben
|
||||
in anderen Formularen bleiben stehen. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. Stil: zeiten-ausnahmen.js.
|
||||
Hinweis-Leiste: Liste der laufenden und geplanten Hinweise (Vergangenes eingeklappt), „Hinweis-Leiste anlegen“ (dieser Text ist fest: die Relaunch-Station
|
||||
tippt den Knopf an) und je Hinweis Text (<= 140), Art (Info, Urlaub, Aktion, Wichtig mit festen Farben wie auf der Website), optionaler Knopf (Link
|
||||
https oder Anrufen) und Zeitraum von–bis (Kalendertage, beide optional; „Aktion“ verlangt ein Enddatum). Der Browser prueft frueh (Aktion ohne Ende, Ende vor
|
||||
Beginn, leerer Text) und zeigt bei Link und Rufnummer nur einen Hinweis (linkOk): entschieden wird auf dem Server, sein Fehler steht am Feld.
|
||||
Ein anderer Abschnitt kann einen Hinweis anlegen (Urlaub-Frage der Ausnahmen): hinweisListeErneuern(t) zeichnet dann nur die Liste neu und laesst offene Formulare dieses
|
||||
Abschnitts samt ungespeicherter Eingaben stehen.
|
||||
Kontakt-Leiste: Ein/Aus, Telefon, Adresse, WhatsApp (Nummer, Starttext, Haken „Datenschutzerklaerung“). Der Server speichert Rufnummern als Ziffern mit
|
||||
Laendervorwahl; der Editor zeigt die gespeicherte Nummer aus der Antwort (+49 …) im Feld und in der Meldung, damit der Kunde sieht, was gilt.
|
||||
R12: den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) setzt nur der Kunde selbst. In der Kundenansicht ist der Haken gesperrt und der
|
||||
gespeicherte Wert geht unveraendert zurueck (ohne gespeicherten kein Speichern mit WhatsApp); beim Kunden geht ein frischer Zeitpunkt nur hinaus, wenn der Haken
|
||||
neu gesetzt wurde (der Server ersetzt ihn durch seine Zeit), sonst der gespeicherte Wert. */
|
||||
|
||||
import { datumKurz, heuteBerlin, istTag } from './termine-view.js'
|
||||
import { abschnitt, allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { HINWEIS_ARTEN, ZEITEN_LIMITS, ZEITEN_MAX, linkOk, neueZeitenId, preisHinweisNoetig } from './zeiten-view.js'
|
||||
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/* ----------------------------------------------------------- Hinweis-Leiste --- */
|
||||
const ART_NAME = Object.freeze({ info: 'Info', urlaub: 'Urlaub', aktion: 'Aktion', wichtig: 'Wichtig' })
|
||||
const KNOPF_WAHL = Object.freeze([['', 'Kein Knopf'], ['link', 'Link'], ['anruf', 'Anrufen']])
|
||||
const PREIS_HINWEIS = 'Bei Preisangaben gelten Endpreise. Bei Waren nennst du dazu den niedrigsten Preis der letzten 30 Tage.'
|
||||
const LINK_HINWEIS = 'Bitte gib eine vollständige Adresse mit https:// an.'
|
||||
const ANRUF_HINWEIS = 'Bitte nur Ziffern, Leerzeichen, + / und - (höchstens 30 Zeichen).'
|
||||
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
|
||||
|
||||
/** Rufnummer eines Anruf-Knopfs wie auf dem Server: nur Ziffern, Leerzeichen, / und -, ein + nur vorn, mindestens eine Ziffer, hoechstens 30 Zeichen. */
|
||||
const anrufOk = (ziel) => {
|
||||
const s = typeof ziel === 'string' ? ziel.trim() : ''
|
||||
return s !== '' && [...s].length <= ZEITEN_MAX.telefon && /^\+?[0-9 /-]+$/.test(s) && /\d/.test(s)
|
||||
}
|
||||
|
||||
/** 'JJJJ-MM-TT' oder '' (fehlt, null oder kein Kalendertag). */
|
||||
const tagOder = (wert) => (typeof wert === 'string' && istTag(wert) ? wert : '')
|
||||
|
||||
/** „12.10.2026 – 26.10.2026“, „ab …“, „bis …“ oder „immer sichtbar“. */
|
||||
function zeitraumText(x) {
|
||||
const von = tagOder(x.von)
|
||||
const bis = tagOder(x.bis)
|
||||
if (von && bis) return von === bis ? datumKurz(von) : `${datumKurz(von)} – ${datumKurz(bis)}`
|
||||
if (von) return `ab ${datumKurz(von)}`
|
||||
if (bis) return `bis ${datumKurz(bis)}`
|
||||
return 'immer sichtbar'
|
||||
}
|
||||
|
||||
const nachBeginn = (a, b) => (tagOder(a.von) < tagOder(b.von) ? -1 : tagOder(a.von) > tagOder(b.von) ? 1 : 0)
|
||||
|
||||
/** Eine Zeile der Hinweis-Liste (laufend, geplant oder vergangen) mit ihrem Bearbeiten-Formular unter dem Knopf. */
|
||||
function hinweisReihe(t, x, heute) {
|
||||
const { ctx } = t
|
||||
const vorbei = tagOder(x.bis) !== '' && x.bis < heute
|
||||
const art = HINWEIS_ARTEN.includes(x.art) ? x.art : 'info'
|
||||
const knopf = istObjekt(x.knopf) && KNOPF_ARTEN.includes(x.knopf.art) ? x.knopf : null
|
||||
const von = tagOder(x.von)
|
||||
const zustand = vorbei ? ['vorbei', 'off'] : von && von > heute ? ['geplant', 'wait'] : ['läuft', 'ok']
|
||||
const text = typeof x.text === 'string' ? x.text : ''
|
||||
return editorZeile({
|
||||
attrs: { 'data-id': x.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: `Hinweis ${text}`,
|
||||
haupt: [
|
||||
h('strong', { class: 'tm-name' }, text),
|
||||
h('span', { class: 'tm-meta' },
|
||||
h('span', { class: `zt-art zt-art-${art}` }, ART_NAME[art]),
|
||||
` · ${zeitraumText(x)}${knopf ? ` · Knopf „${typeof knopf.text === 'string' ? knopf.text : ''}“ (${knopf.art === 'anruf' ? 'Anrufen' : 'Link'})` : ''}`),
|
||||
],
|
||||
pille: h('span', { class: `tm-pille tm-pille-${zustand[1]}` }, zustand[0]),
|
||||
baue: (schliessen) => formular(t, { a: x, schliessen }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Liste der Hinweise: [Liste der laufenden und geplanten (oder „Keine Hinweise eingetragen.“), eingeklappte Liste der vergangenen]. vorhanden (Map ID => <li>):
|
||||
* Zeilen, die schon in der Seite stehen, werden wiederverwendet (samt ihrem offenen Formular) statt neu gebaut; nur für den Fall, dass sich an diesen Hinweisen nichts geändert hat.
|
||||
*/
|
||||
function hinweisListe(t, vorhanden = new Map()) {
|
||||
const alle = (Array.isArray(t.ctx.stand.daten.hinweise) ? t.ctx.stand.daten.hinweise : []).filter((x) => istObjekt(x) && typeof x.id === 'string')
|
||||
const heute = heuteBerlin()
|
||||
const vorbei = (x) => tagOder(x.bis) !== '' && x.bis < heute
|
||||
const reihe = (x) => vorhanden.get(x.id) ?? hinweisReihe(t, x, heute)
|
||||
const kommend = alle.filter((x) => !vorbei(x)).sort(nachBeginn)
|
||||
const alt = alle.filter(vorbei).sort(nachBeginn).reverse()
|
||||
return {
|
||||
voll: alle.length >= ZEITEN_LIMITS.hinweise,
|
||||
teile: [
|
||||
kommend.length
|
||||
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Laufende und geplante Hinweise' }, kommend.map(reihe))
|
||||
: h('p', { class: 'tm-leer' }, 'Keine Hinweise eingetragen.'),
|
||||
alt.length
|
||||
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Hinweise (${alt.length})`), h('ul', { class: 'tm-eintraege' }, alt.map(reihe)))
|
||||
: null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const GRENZ_TEXT = `Mehr als ${ZEITEN_LIMITS.hinweise} Hinweise sind nicht möglich.`
|
||||
|
||||
export function hinweise(t) {
|
||||
const { ctx } = t
|
||||
const { voll, teile } = hinweisListe(t)
|
||||
const neu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: 'Hinweis-Leiste anlegen', werkzeug: 'hinweis-neu', primaer: true, voll, grenzText: GRENZ_TEXT,
|
||||
baue: (schliessen) => formular(t, { a: null, schliessen }),
|
||||
})
|
||||
return abschnitt('Hinweis-Leiste',
|
||||
'Eine Leiste ganz oben auf deiner Website – zum Beispiel für Betriebsurlaub, eine Aktion oder etwas Wichtiges. Die Leiste erscheint nur in dem Zeitraum, den du einstellst. Gelten mehrere gleichzeitig, zeigt die Website nur einen: zuerst „Wichtig“, dann „Urlaub“, „Aktion“ und „Info“.',
|
||||
h('div', { class: 'zt-werkzeuge' }, neu.leiste),
|
||||
neu.box,
|
||||
h('div', { class: 'zt-hinweis-liste' }, teile))
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die Liste der Hinweise neu zeichnen, ohne den Abschnitt anzufassen: für den Fall, dass ein anderer Abschnitt einen Hinweis angelegt hat (die Frage „Hinweis-Leiste dazu
|
||||
* anlegen?“ der Ausnahmen). Offene, ungespeicherte Formulare bleiben stehen: das Formular „Neuer Hinweis“ liegt nicht in der Liste, und die Zeilen der bisherigen Hinweise werden
|
||||
* mit ihrem offenen Bearbeiten-Formular wiederverwendet. Der Knopf „Hinweis-Leiste anlegen“ folgt der Obergrenze. => false, wenn es den Abschnitt (noch) nicht gibt.
|
||||
*/
|
||||
export function hinweisListeErneuern(t) {
|
||||
const el = t.wurzel.querySelector('[data-abschnitt="hinweise"]')
|
||||
const platz = el?.querySelector('.zt-hinweis-liste')
|
||||
if (!platz) return false
|
||||
const vorhanden = new Map([...platz.querySelectorAll('li[data-id]')].map((li) => [li.getAttribute('data-id'), li]))
|
||||
const { voll, teile } = hinweisListe(t, vorhanden)
|
||||
platz.replaceChildren(...teile)
|
||||
const knopf = el.querySelector('[data-werkzeug="hinweis-neu"]')
|
||||
if (knopf) {
|
||||
knopf.disabled = voll
|
||||
const zeile = knopf.parentNode
|
||||
const grenze = zeile.querySelector('p')
|
||||
if (voll && !grenze) zeile.append(h('p', { class: 'tm-hinweis' }, GRENZ_TEXT))
|
||||
else if (!voll && grenze) grenze.remove()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular eines neuen (a null) oder bestehenden Hinweises. Die Felder heissen nach dem Pfad der Fehler des Servers (hinweise.<ID>.text, .art, .knopf.art,
|
||||
* .knopf.text, .knopf.ziel, .von, .bis); Absenden sucht den Hinweis per ID.
|
||||
*/
|
||||
function formular(t, { a, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const neu = a === null
|
||||
const alt = a || { id: neueZeitenId('h'), art: 'info', text: '', knopf: null, von: null, bis: null }
|
||||
const p = `hinweise.${alt.id}`
|
||||
const knopfAlt = istObjekt(alt.knopf) && KNOPF_ARTEN.includes(alt.knopf.art) ? alt.knopf : null
|
||||
|
||||
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.hinweisText, autocomplete: 'off', placeholder: 'z. B. Heute geschlossen – morgen wieder für dich da', value: typeof alt.text === 'string' ? alt.text : '' })
|
||||
const art = h('select', null, HINWEIS_ARTEN.map((k) => h('option', { value: k, selected: k === (HINWEIS_ARTEN.includes(alt.art) ? alt.art : 'info') }, ART_NAME[k])))
|
||||
const knopfArt = h('select', null, KNOPF_WAHL.map(([wert, name]) => h('option', { value: wert, selected: wert === (knopfAlt?.art ?? '') }, name)))
|
||||
const knopfText = h('input', { type: 'text', maxlength: ZEITEN_MAX.knopfText, autocomplete: 'off', placeholder: 'z. B. Mehr erfahren', value: typeof knopfAlt?.text === 'string' ? knopfAlt.text : '' })
|
||||
const knopfZiel = h('input', { type: 'url', maxlength: ZEITEN_MAX.link, autocomplete: 'off', value: typeof knopfAlt?.ziel === 'string' ? knopfAlt.ziel : '' })
|
||||
const von = h('input', { type: 'date', value: tagOder(alt.von) })
|
||||
const bis = h('input', { type: 'date', value: tagOder(alt.bis) })
|
||||
|
||||
const preisHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'preis', hidden: true }, PREIS_HINWEIS)
|
||||
const zielHinweis = h('p', { class: 'tm-hinweis', 'data-hinweis': 'knopf-ziel', hidden: true }, LINK_HINWEIS)
|
||||
const zielFeld = feld(`${p}.knopf.ziel`, 'Link', knopfZiel)
|
||||
zielFeld.insertBefore(zielHinweis, zielFeld.querySelector('[data-fehler]'))
|
||||
const textFeld = feld(`${p}.text`, 'Text', text, `Höchstens ${ZEITEN_MAX.hinweisText} Zeichen.`)
|
||||
textFeld.insertBefore(preisHinweis, textFeld.querySelector('[data-fehler]'))
|
||||
const knopfFelder = h('div', { class: 'tm-raster zt-knopf-felder', 'data-knopf-felder': '' },
|
||||
feld(`${p}.knopf.text`, 'Beschriftung des Knopfs', knopfText, `Höchstens ${ZEITEN_MAX.knopfText} Zeichen.`),
|
||||
zielFeld)
|
||||
|
||||
// Muster: so sieht die Leiste in der Farbe der Art aus (feste Farben wie auf der Website)
|
||||
const musterText = h('span', { class: 'zt-muster-text' })
|
||||
const musterKnopf = h('span', { class: 'zt-muster-knopf' })
|
||||
const muster = h('div', { class: 'zt-muster', 'aria-hidden': 'true', 'data-muster': '' }, musterText, musterKnopf)
|
||||
|
||||
const aktualisiere = () => {
|
||||
preisHinweis.hidden = !preisHinweisNoetig(text.value)
|
||||
const wahl = knopfArt.value
|
||||
knopfFelder.hidden = wahl === ''
|
||||
knopfZiel.type = wahl === 'anruf' ? 'tel' : 'url'
|
||||
knopfZiel.placeholder = wahl === 'anruf' ? 'z. B. 06301 123456' : 'https://'
|
||||
zielFeld.querySelector('label').textContent = wahl === 'anruf' ? 'Telefonnummer' : 'Link'
|
||||
const ziel = knopfZiel.value.trim()
|
||||
zielHinweis.textContent = wahl === 'anruf' ? ANRUF_HINWEIS : LINK_HINWEIS
|
||||
zielHinweis.hidden = wahl === '' || ziel === '' || (wahl === 'anruf' ? anrufOk(ziel) : linkOk(ziel))
|
||||
muster.className = `zt-muster zt-art-${art.value}`
|
||||
musterText.textContent = text.value.trim() || 'Dein Hinweistext'
|
||||
musterKnopf.textContent = knopfText.value.trim()
|
||||
musterKnopf.hidden = wahl === '' || knopfText.value.trim() === ''
|
||||
}
|
||||
for (const steuer of [text, art, knopfArt, knopfText, knopfZiel]) {
|
||||
steuer.addEventListener('input', aktualisiere)
|
||||
steuer.addEventListener('change', aktualisiere)
|
||||
}
|
||||
aktualisiere()
|
||||
|
||||
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
|
||||
titel: 'Hinweis entfernen?',
|
||||
text: 'Die Leiste verschwindet von deiner Website.',
|
||||
aendere: (entwurf) => { entwurf.hinweise = (Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []).filter((x) => x?.id !== alt.id) },
|
||||
nachher: () => nachSpeichern(t, 'Hinweis entfernt.', '[data-werkzeug="hinweis-neu"]', () => t.zeichne(['hinweise'])),
|
||||
})
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-speichern' }, neu ? 'Hinweis speichern' : 'Speichern')
|
||||
const form = h('form', { class: 'kb-panel tm-formular zt-hinweis-form', novalidate: true, 'aria-label': neu ? 'Neuer Hinweis' : 'Hinweis bearbeiten' },
|
||||
h('h3', null, neu ? 'Neuer Hinweis' : 'Hinweis'),
|
||||
gesperrt(ctx.nurLesen, [
|
||||
textFeld,
|
||||
feld(`${p}.art`, 'Art', art, 'Jede Art hat ihre feste Farbe.'),
|
||||
muster,
|
||||
feld(`${p}.knopf.art`, 'Knopf (optional)', knopfArt, 'Ein Knopf in der Leiste: ein Link auf eine Seite oder ein Anruf.'),
|
||||
knopfFelder,
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld(`${p}.von`, 'Sichtbar ab (optional)', von),
|
||||
feld(`${p}.bis`, 'Sichtbar bis (bei einer Aktion nötig)', bis)),
|
||||
h('p', { class: 'tm-hinweis' }, 'Ohne Datum gilt der Hinweis immer. Bei einer Aktion brauchst du ein Enddatum.'),
|
||||
]),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
ctx.nurLesen ? null : speichern,
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
|
||||
entfernen?.knopf))
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const fehler = {}
|
||||
const vonWert = von.value.trim()
|
||||
const bisWert = bis.value.trim()
|
||||
const hinweisText = text.value.trim()
|
||||
if (hinweisText === '') fehler[`${p}.text`] = 'Bitte gib einen Text ein.'
|
||||
if (vonWert !== '' && !istTag(vonWert)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
|
||||
if (bisWert !== '' && !istTag(bisWert)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
|
||||
if (!fehler[`${p}.von`] && !fehler[`${p}.bis`]) {
|
||||
if (vonWert !== '' && bisWert !== '' && bisWert < vonWert) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
|
||||
else if (art.value === 'aktion' && bisWert === '') fehler[`${p}.bis`] = 'Eine Aktion braucht ein Enddatum.'
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
// ein Knopf ohne Beschriftung und Ziel ist kein Knopf (wie auf dem Server)
|
||||
const ziel = knopfZiel.value.trim()
|
||||
const beschriftung = knopfText.value.trim()
|
||||
const knopf = knopfArt.value !== '' && (ziel !== '' || beschriftung !== '') ? { art: knopfArt.value, ziel, text: beschriftung } : null
|
||||
const eintrag = { id: alt.id, art: art.value, text: hinweisText, knopf, von: vonWert || null, bis: bisWert || null }
|
||||
if (neu) {
|
||||
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), eintrag]
|
||||
return true
|
||||
}
|
||||
const stelle = stelleVon(entwurf.hinweise, alt.id, form, 'Dieser Hinweis')
|
||||
if (stelle < 0) return false
|
||||
entwurf.hinweise[stelle] = eintrag
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
nachSpeichern(t, 'Hinweis gespeichert.', neu ? '[data-werkzeug="hinweis-neu"]' : `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`, () => t.zeichne(['hinweise']))
|
||||
})
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Kontakt-Leiste --- */
|
||||
const WHATSAPP_HAKEN = 'Ich habe den WhatsApp-Hinweis in meine Datenschutzerklärung aufgenommen'
|
||||
const HAKEN_FEHLT = 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.'
|
||||
const KUNDE_BESTAETIGT = 'Der Kunde muss den WhatsApp-Hinweis selbst bestätigen – in der Kundenansicht geht das nicht.'
|
||||
|
||||
/** Gespeicherte Rufnummer (Ziffern mit Laendervorwahl, z. B. 491711234567) als „+49 1711234567“; alles andere bleibt, wie es gespeichert ist. */
|
||||
export function nummerAnzeige(wert) {
|
||||
const s = typeof wert === 'string' ? wert.trim() : ''
|
||||
if (!/^\d{6,15}$/.test(s)) return s
|
||||
return s.startsWith('49') ? `+49 ${s.slice(2)}` : `+${s}`
|
||||
}
|
||||
|
||||
export function kontakt(t) {
|
||||
const { ctx, sp } = t
|
||||
const nurLesen = ctx.nurLesen
|
||||
const kundenansicht = ctx.kundenansicht === true || ctx.stand.kundenansicht === true
|
||||
const k = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
|
||||
const wa = istObjekt(k.whatsapp) ? k.whatsapp : null
|
||||
const gespeichertAt = typeof wa?.bestaetigtAt === 'string' ? wa.bestaetigtAt : ''
|
||||
const telefonAlt = typeof k.telefon === 'string' ? k.telefon : ''
|
||||
const nummerAlt = typeof wa?.nummer === 'string' ? wa.nummer : ''
|
||||
const telefonAnzeige = nummerAnzeige(telefonAlt)
|
||||
const nummerAnz = nummerAnzeige(nummerAlt)
|
||||
|
||||
const an = h('input', { type: 'checkbox', checked: k.an === true })
|
||||
const telefon = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 06301 123456', value: telefonAnzeige })
|
||||
const adresse = h('input', { type: 'text', maxlength: ZEITEN_MAX.adresse, autocomplete: 'off', placeholder: 'z. B. Hauptstraße 1, 67722 Winnweiler', value: typeof k.adresse === 'string' ? k.adresse : '' })
|
||||
const waAn = h('input', { type: 'checkbox', checked: wa !== null })
|
||||
const nummer = h('input', { type: 'tel', maxlength: ZEITEN_MAX.telefon, autocomplete: 'off', placeholder: 'z. B. 0171 1234567', value: nummerAnz })
|
||||
const starttext = h('textarea', { rows: 3, maxlength: ZEITEN_MAX.starttext, placeholder: 'z. B. Hallo, ich habe eine Frage.' })
|
||||
starttext.value = typeof wa?.starttext === 'string' ? wa.starttext : ''
|
||||
const bestaetigt = h('input', { type: 'checkbox', checked: gespeichertAt !== '', disabled: kundenansicht })
|
||||
|
||||
const waBlock = h('div', { class: 'zt-whatsapp', 'data-whatsapp': '', hidden: wa === null },
|
||||
feld('kontakt.whatsapp.nummer', 'WhatsApp-Nummer', nummer, 'Mit Vorwahl. Wir speichern sie mit Ländervorwahl, zum Beispiel +49 …'),
|
||||
feld('kontakt.whatsapp.starttext', 'Vorgeschriebener Text (optional)', starttext, `Dieser Text steht schon im Nachrichtenfeld, wenn jemand auf den WhatsApp-Knopf tippt. Höchstens ${ZEITEN_MAX.starttext} Zeichen.`),
|
||||
hakenFeld('kontakt.whatsapp.bestaetigtAt', bestaetigt, WHATSAPP_HAKEN, 'Beim Tippen öffnet sich WhatsApp (ein Dienst von Meta). Das gehört in die Datenschutzerklärung deiner Website – frag uns im Chat, wenn du den passenden Absatz brauchst.'),
|
||||
kundenansicht ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'whatsapp-kundenansicht' }, 'Kundenansicht: Den Haken zur Datenschutzerklärung kann nur der Kunde selbst setzen.') : null)
|
||||
waAn.addEventListener('change', () => { waBlock.hidden = !waAn.checked })
|
||||
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'kontakt-speichern' }, 'Kontakt-Leiste speichern')
|
||||
const form = h('form', { novalidate: true, 'aria-label': 'Kontakt-Leiste' },
|
||||
gesperrt(nurLesen, [
|
||||
hakenFeld('kontakt.an', an, 'Kontakt-Leiste auf dem Handy anzeigen', 'Eine Leiste unten auf deiner Website, nur auf dem Handy: Anrufen, Route und WhatsApp – je nachdem, was du einträgst.'),
|
||||
feld('kontakt.telefon', 'Telefon', telefon, 'Für „Anrufen“. Wir speichern die Nummer mit Ländervorwahl (+49 für Deutschland).'),
|
||||
feld('kontakt.adresse', 'Adresse für „Route“', adresse, `Höchstens ${ZEITEN_MAX.adresse} Zeichen.`),
|
||||
hakenFeld('kontakt.whatsapp', waAn, 'WhatsApp-Knopf anbieten'),
|
||||
waBlock,
|
||||
]),
|
||||
allgemeinerFehler(),
|
||||
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const fehler = {}
|
||||
let whatsapp = null
|
||||
if (waAn.checked) {
|
||||
const nummerWert = nummer.value === nummerAnz ? nummerAlt : nummer.value.trim()
|
||||
if (nummerWert === '') fehler['kontakt.whatsapp.nummer'] = 'Bitte gib die Nummer an.'
|
||||
let bestaetigtAt
|
||||
if (kundenansicht) {
|
||||
// R12: im Namen des Kunden gibt es nie einen neuen Zeitpunkt; ein gespeicherter geht unveraendert zurueck
|
||||
if (gespeichertAt !== '') bestaetigtAt = gespeichertAt
|
||||
else fehler['kontakt.whatsapp.bestaetigtAt'] = KUNDE_BESTAETIGT
|
||||
} else if (bestaetigt.checked) {
|
||||
bestaetigtAt = gespeichertAt !== '' ? gespeichertAt : new Date().toISOString() // der Server ersetzt einen neuen Zeitpunkt durch seine Zeit
|
||||
} else {
|
||||
fehler['kontakt.whatsapp.bestaetigtAt'] = HAKEN_FEHLT
|
||||
}
|
||||
whatsapp = { nummer: nummerWert, starttext: starttext.value.trim(), bestaetigtAt }
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
entwurf.kontakt = {
|
||||
...(istObjekt(entwurf.kontakt) ? entwurf.kontakt : {}),
|
||||
an: an.checked,
|
||||
telefon: telefon.value === telefonAnzeige ? telefonAlt : telefon.value.trim(),
|
||||
adresse: adresse.value.trim(),
|
||||
whatsapp,
|
||||
}
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
// was der Server gespeichert hat, steht danach im Feld (nur dieser Abschnitt wird neu gezeichnet) und in der Meldung
|
||||
const neu = istObjekt(ctx.stand.daten.kontakt) ? ctx.stand.daten.kontakt : {}
|
||||
const nummern = [neu.telefon ? `Telefon ${nummerAnzeige(neu.telefon)}` : '', neu.whatsapp?.nummer ? `WhatsApp ${nummerAnzeige(neu.whatsapp.nummer)}` : ''].filter(Boolean)
|
||||
nachSpeichern(t, `Kontakt-Leiste gespeichert.${nummern.length ? ` Gespeichert als ${nummern.join(', ')}.` : ''}`, 'form[aria-label="Kontakt-Leiste"] button[type="submit"]', () => t.zeichne(['kontakt']))
|
||||
})
|
||||
|
||||
return abschnitt('Kontakt-Leiste (Handy)',
|
||||
'Auf dem Handy erscheint unten auf deiner Website eine Leiste mit Knöpfen zum Anrufen, für die Route und für WhatsApp. Auf größeren Bildschirmen erscheint sie nicht.',
|
||||
form)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 1 „Zeitplaene“ (Spec 3.4) und die Eingabe eines Tages, die auch Feiertage und
|
||||
Sondertage nutzen (tagFelder). plaene(t) ist ein Abschnitt des Editors; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js
|
||||
(sp = Speicher des Editors, zeichne(['ausnahmen', …]) zeichnet einzelne Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js).
|
||||
Nach einem Speichern wird nur neu gezeichnet, was sich geaendert hat: die Karte des gespeicherten Plans (ersetzePlanKarte), die Liste der Plaene bei
|
||||
Hinzufuegen, Loeschen und Hauptplan (zeichnePlanListe, mit den ungespeicherten Eingaben der anderen Karten), die Ausnahmen bei neuem oder geloeschtem Plan
|
||||
und bei einem neuen Namen. Alles andere in der Seite bleibt mit seinen ungespeicherten Eingaben stehen.
|
||||
Ein Plan: Name, Kurzname (nur Anzeige: der Server vergibt ihn beim ersten Speichern endgueltig und nennt ihn in der Antwort, R33), je
|
||||
Wochentag der Haken „geöffnet“ mit zwei Zeitpaaren (<input type="time" step="300">) und der Wahl „zu festen Zeiten“, „rund um die Uhr“,
|
||||
„nach Vereinbarung“; Knoepfe Speichern, Zum Hauptplan machen, Plan löschen (nicht der einzige, nicht der Hauptplan).
|
||||
Vor dem ersten Speichern (ctx.stand.daten null) gibt es nur den leeren Hauptplan „Öffnungszeiten“ (ctx.entwurfsplan); er gehoert zu
|
||||
ctx.leereDaten(), mit denen das erste Speichern geht. Pruefungen im Browser (wochenFehler aus zeiten-view.js) zeigen Fehler nur frueher an,
|
||||
entscheiden tut der Server. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
|
||||
|
||||
import { WOCHENTAGE_PLAN } from './termine-view.js'
|
||||
import { abschnitt, allgemeinerFehler, feld, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hauptplanWechseln, kurzname, leererPlan, planLoeschenGrund, wochenFehler } from './zeiten-view.js'
|
||||
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
|
||||
|
||||
/** Betriebsart eines Tages, Ausnahme oder Feiertags: Wert und Beschriftung. */
|
||||
export const MODUS_WAHL = Object.freeze([
|
||||
['zu', 'geschlossen'], ['zeiten', 'geöffnet zu festen Zeiten'], ['rund', 'rund um die Uhr'], ['vereinbarung', 'nach Vereinbarung'],
|
||||
])
|
||||
const ART_WAHL = MODUS_WAHL.filter(([wert]) => wert !== 'zu') // Wochentag: „geöffnet“ ist der Haken, die Art steht daneben
|
||||
|
||||
/** Kurzfassung eines Tages: „geschlossen“, „09:00–12:30, 13:30–18:00“, „rund um die Uhr“, „nach Vereinbarung“. */
|
||||
export function tagText(tag) {
|
||||
if (tag?.modus === 'rund') return 'rund um die Uhr'
|
||||
if (tag?.modus === 'vereinbarung') return 'nach Vereinbarung'
|
||||
if (tag?.modus === 'zeiten' && Array.isArray(tag.fenster) && tag.fenster.length) return tag.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ')
|
||||
return 'geschlossen'
|
||||
}
|
||||
|
||||
/** <input type="time"> kennt kein 24:00: ein gespeichertes Ende um 24:00 steht als 00:00 da (dasselbe Ende: „bis Mitternacht“). */
|
||||
const zeitWert = (wert) => (wert === '24:00' ? '00:00' : typeof wert === 'string' ? wert : '')
|
||||
|
||||
let idZaehler = 0
|
||||
|
||||
/** Gibt es in der Auswahl einen Wert wert? (Eine Eingabe aus einer anderen Liste, z. B. nach einem Wechsel des Bundeslands, passt eventuell nicht mehr.) */
|
||||
const hatOption = (auswahl, wert) => [...auswahl.querySelectorAll('option')].some((o) => o.value === wert)
|
||||
|
||||
/**
|
||||
* Eingabe eines Tages (Wochentag, Feiertag, Sondertag): Betriebsart und bis zu zwei Zeitpaare.
|
||||
* haken: Wochentag – Haken „geöffnet“ (ohne Haken: geschlossen) und daneben die Wahl der Art; sonst eine Auswahl der Betriebsart (wahl: Liste
|
||||
* [Wert, Text]; ein Wert '' steht fuer „Vorgabe“ und liest sich als modus '').
|
||||
* pfad: Name des Feldes (data-feld) und des Fehlers (data-fehler) – so findet ihn die Antwort des Servers; fensterPfad: Pfad des Fehlers bei den
|
||||
* Zeitfenstern, wenn der Server fuer Betriebsart und Fenster getrennte Pfade nennt (Sondertag: …modus und …fenster).
|
||||
* sichtbar: die Beschriftung der Auswahl steht in der Seite (sonst nur fuer Screenreader).
|
||||
* => { el, lies, merke, stelleWieder }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der
|
||||
* Fenster pruefen der Aufrufer (tagFehler/wochenFehler) und der Server. merke() haelt die Eingaben so fest, wie sie im Formular stehen (auch Halbfertiges),
|
||||
* stelleWieder(merke()) setzt sie in einem neu gebauten Feld wieder ein: so gehen ungespeicherte Eingaben nicht verloren, wenn die Karte neu gezeichnet wird.
|
||||
*/
|
||||
export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenster = [], haken = false, wahl = MODUS_WAHL, sichtbar = false }) {
|
||||
const start = typeof modus === 'string' && (MODI.includes(modus) || modus === '') ? modus : 'zu'
|
||||
const zeitFeld = (i, teil, wert) => h('input', { type: 'time', step: '300', value: zeitWert(wert), 'aria-label': `${label}, Zeitfenster ${i + 1}, ${teil}` })
|
||||
const paare = [0, 1].map((i) => ({ von: zeitFeld(i, 'von', fenster?.[i]?.[0]), bis: zeitFeld(i, 'bis', fenster?.[i]?.[1]) }))
|
||||
if (fensterPfad !== pfad) paare[0].von.dataset.feld = fensterPfad
|
||||
const zeiten = h('div', { class: 'zt-zeiten' }, paare.map((paar, i) => h('div', { class: 'zt-fenster' },
|
||||
h('span', { class: 'zt-fenster-name' }, i === 0 ? 'Zeitfenster' : '2. Zeitfenster (optional)'),
|
||||
h('label', { class: 'zt-zeit' }, h('span', null, 'von'), paar.von),
|
||||
h('label', { class: 'zt-zeit' }, h('span', null, 'bis'), paar.bis))))
|
||||
|
||||
let kopf
|
||||
let felder
|
||||
let modusWert
|
||||
let zeige
|
||||
let setzeModus
|
||||
if (haken) {
|
||||
const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' })
|
||||
geoeffnet.dataset.feld = pfad
|
||||
const art = h('select', { 'aria-label': `${label}: Art der Öffnung` }, ART_WAHL.map(([wert, text]) => h('option', { value: wert, selected: wert === (start === 'zu' || start === '' ? 'zeiten' : start) }, text)))
|
||||
kopf = h('div', { class: 'tm-feld tm-feld-haken' }, h('label', { class: 'tm-haken' }, geoeffnet, h('span', null, h('strong', null, label), ' geöffnet')))
|
||||
felder = h('div', { class: 'zt-tag-felder' }, art, zeiten)
|
||||
modusWert = () => (geoeffnet.checked ? art.value : 'zu')
|
||||
setzeModus = (wert) => {
|
||||
geoeffnet.checked = wert.an === true
|
||||
if (hatOption(art, wert.art)) art.value = wert.art
|
||||
}
|
||||
zeige = () => {
|
||||
felder.hidden = !geoeffnet.checked
|
||||
zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten')
|
||||
}
|
||||
geoeffnet.addEventListener('change', zeige)
|
||||
art.addEventListener('change', zeige)
|
||||
} else {
|
||||
const id = `zt-t${++idZaehler}`
|
||||
const auswahl = h('select', { id }, wahl.map(([wert, text]) => h('option', { value: wert, selected: wert === start }, text)))
|
||||
auswahl.dataset.feld = pfad
|
||||
kopf = h('div', { class: 'tm-feld' }, h('label', { for: id, class: sichtbar ? 'tm-label' : 'tm-sr' }, sichtbar ? 'Öffnung' : `${label}: Öffnung`), auswahl)
|
||||
felder = h('div', { class: 'zt-tag-felder' }, zeiten)
|
||||
modusWert = () => auswahl.value
|
||||
setzeModus = (wert) => { if (hatOption(auswahl, wert.wahl)) auswahl.value = wert.wahl }
|
||||
zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' }
|
||||
auswahl.addEventListener('change', zeige)
|
||||
}
|
||||
zeige()
|
||||
|
||||
const el = h('div', { class: 'zt-tag' }, kopf, felder,
|
||||
h('p', { class: 'tm-fehler', 'data-fehler': pfad, role: 'alert' }),
|
||||
fensterPfad !== pfad ? h('p', { class: 'tm-fehler', 'data-fehler': fensterPfad, role: 'alert' }) : null)
|
||||
|
||||
function lies() {
|
||||
const art = modusWert()
|
||||
if (art !== 'zeiten') return { modus: art, fenster: [] }
|
||||
const gelesen = []
|
||||
for (const paar of paare) {
|
||||
const von = paar.von.value
|
||||
const bis = paar.bis.value
|
||||
if (!von && !bis) continue
|
||||
if (!von || !bis) return { fehler: { [fensterPfad]: 'Bitte gib Beginn und Ende des Zeitfensters an.' } }
|
||||
gelesen.push([von, bis])
|
||||
}
|
||||
return { modus: art, fenster: gelesen }
|
||||
}
|
||||
const merke = () => ({
|
||||
an: haken ? modusWert() !== 'zu' : undefined,
|
||||
art: haken ? (el.querySelector('select')?.value ?? '') : undefined,
|
||||
wahl: haken ? undefined : modusWert(),
|
||||
zeiten: paare.flatMap((paar) => [paar.von.value, paar.bis.value]),
|
||||
})
|
||||
const stelleWieder = (gemerkt) => {
|
||||
if (!gemerkt) return
|
||||
setzeModus(gemerkt)
|
||||
paare.forEach((paar, i) => {
|
||||
paar.von.value = gemerkt.zeiten?.[2 * i] ?? ''
|
||||
paar.bis.value = gemerkt.zeiten?.[2 * i + 1] ?? ''
|
||||
})
|
||||
zeige()
|
||||
}
|
||||
return { el, lies, merke, stelleWieder }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Zeitplaene --- */
|
||||
const LOESCHEN_WARNUNG = 'Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'
|
||||
|
||||
/** Der Plan mit dieser ID im aktuellen Stand (nach dem Speichern: mit dem Kurznamen des Servers). */
|
||||
const planMitId = (ctx, id) => (ctx.stand.daten?.plaene || []).find((p) => p?.id === id)
|
||||
const planSelektor = (id, rest) => `details[data-plan="${CSS.escape(id)}"] ${rest}`
|
||||
const grenzText = `Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich.`
|
||||
|
||||
// Pro Karte, wie man ihre Eingaben festhaelt und wieder einsetzt (merke/stelleWieder): die Liste der Plaene wird bei „Plan hinzufügen“, „Löschen“
|
||||
// und „Zum Hauptplan machen“ neu gezeichnet; was in den anderen Karten ungespeichert steht, geht dabei nicht verloren.
|
||||
const KARTEN = new WeakMap()
|
||||
const kartenIn = (t) => [...(t.wurzel.querySelector('.zt-plaene')?.children ?? [])]
|
||||
|
||||
export function plaene(t) {
|
||||
const { ctx } = t
|
||||
const daten = ctx.stand.daten
|
||||
const liste = daten ? daten.plaene : [ctx.entwurfsplan]
|
||||
const neu = daten ? neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Plan hinzufügen', werkzeug: 'plan-neu',
|
||||
baue: (schliessen) => neuFormular(t, { schliessen }),
|
||||
}) : null
|
||||
const voll = liste.length >= ZEITEN_LIMITS.plaene
|
||||
const knopf = neu?.leiste?.querySelector('button')
|
||||
if (knopf) knopf.disabled = voll
|
||||
return abschnitt('Zeitpläne',
|
||||
'Wann hast du geöffnet? Der Hauptplan steuert den Live-Status und die Leisten auf deiner Website. Weitere Pläne (zum Beispiel für Küche oder Lieferdienst) bekommen einen eigenen Kurznamen, unter dem das Team sie auf deiner Website einbauen kann.',
|
||||
neu?.leiste,
|
||||
neu ? h('p', { class: 'tm-hinweis zt-grenze', 'data-grenze': '' }, voll ? grenzText : '') : null,
|
||||
neu?.box,
|
||||
h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan))))
|
||||
}
|
||||
|
||||
/** Nach dem Speichern eines Plans: nur seine Karte neu zeichnen (aus dem Stand des Servers); alle anderen Formulare bleiben, wie sie sind. */
|
||||
function ersetzePlanKarte(t, id) {
|
||||
const plan = planMitId(t.ctx, id)
|
||||
const alt = kartenIn(t).find((karte) => karte.getAttribute('data-plan') === id)
|
||||
if (!plan || !alt) return
|
||||
alt.parentNode.insertBefore(planKarte(t, plan), alt)
|
||||
alt.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach „Plan hinzufügen“, „Plan löschen“ und „Zum Hauptplan machen“: die Karten neu zeichnen (Hauptplan-Marke, Knoepfe, neue und entfernte Plaene)
|
||||
* und den Knopf „+ Plan hinzufügen“ auf den Stand bringen. Die Eingaben der Karten, die es noch gibt, werden wieder eingesetzt (ungespeichert, wie sie waren);
|
||||
* das Formular „Plan hinzufügen“ (neben der Liste) bleibt unberuehrt.
|
||||
*/
|
||||
function zeichnePlanListe(t) {
|
||||
const { ctx } = t
|
||||
const behaelter = t.wurzel.querySelector('.zt-plaene')
|
||||
if (!behaelter || !ctx.stand.daten) return t.zeichne(['plaene'])
|
||||
const gemerkt = new Map(kartenIn(t).map((karte) => [karte.getAttribute('data-plan'), KARTEN.get(karte)?.merke()]))
|
||||
const karten = ctx.stand.daten.plaene.map((plan) => {
|
||||
const karte = planKarte(t, plan)
|
||||
if (gemerkt.get(plan.id)) KARTEN.get(karte)?.stelleWieder(gemerkt.get(plan.id))
|
||||
return karte
|
||||
})
|
||||
behaelter.replaceChildren(...karten)
|
||||
const voll = ctx.stand.daten.plaene.length >= ZEITEN_LIMITS.plaene
|
||||
const knopf = t.wurzel.querySelector('[data-werkzeug="plan-neu"]')
|
||||
if (knopf) knopf.disabled = voll
|
||||
const grenze = t.wurzel.querySelector('[data-grenze]')
|
||||
if (grenze) grenze.textContent = voll ? grenzText : ''
|
||||
}
|
||||
|
||||
/** Formular „Plan hinzufügen“: Name und die Vorschau des Kurznamens (wie ihn der Server bildet; endgueltig ist der Wert aus der Antwort). */
|
||||
function neuFormular(t, { schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', placeholder: 'z. B. Küche oder Lieferdienst' })
|
||||
const vorschau = h('code', { 'data-kurz-vorschau': '' }, '')
|
||||
name.addEventListener('input', () => {
|
||||
vorschau.textContent = name.value.trim() ? kurzname(name.value, ctx.stand.daten.plaene.map((p) => p.kurz)) : ''
|
||||
})
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Plan hinzufügen' },
|
||||
h('h3', null, 'Neuer Zeitplan'),
|
||||
feld('name', 'Name des Zeitplans', name, 'Die Zeiten trägst du danach im neuen Plan ein.'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Kurzname: ', vorschau, ' – wird beim Speichern festgelegt und bleibt dann immer gleich.'),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'plan-anlegen' }, 'Plan anlegen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, 'Abbrechen')))
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const gewaehlt = name.value.trim()
|
||||
let neuerPlan = null
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
if (!gewaehlt) {
|
||||
zeigeFehler(form, { fehler: { name: 'Bitte gib dem Zeitplan einen Namen.' } })
|
||||
return false
|
||||
}
|
||||
neuerPlan = leererPlan({ name: gewaehlt, kurz: kurzname(gewaehlt, entwurf.plaene.map((p) => p.kurz)) })
|
||||
entwurf.plaene = [...entwurf.plaene, neuerPlan]
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
ctx.offen.add(neuerPlan.id)
|
||||
const gespeichert = planMitId(ctx, neuerPlan.id) // Name und Kurzname gelten so, wie der Server sie gespeichert hat
|
||||
nachSpeichern(t, `Der Zeitplan „${gespeichert?.name ?? gewaehlt}“ ist angelegt, sein Kurzname ist „${gespeichert?.kurz ?? neuerPlan.kurz}“. Trage jetzt seine Zeiten ein.`, planSelektor(neuerPlan.id, 'summary'), () => {
|
||||
zeichnePlanListe(t)
|
||||
schliessen() // das Formular hat seine Arbeit getan
|
||||
t.zeichne(['ausnahmen']) // „Gilt für“ kennt den neuen Plan
|
||||
})
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
/** Ein Plan als aufklappbare Karte: Kopf (Name, Hauptplan, Kurzname) und das Formular mit Namen und Wochentagen. */
|
||||
function planKarte(t, plan) {
|
||||
const { ctx, sp } = t
|
||||
const daten = ctx.stand.daten
|
||||
const p = `plaene.${plan.id}`
|
||||
const nurLesen = ctx.nurLesen
|
||||
const details = h('details', { class: 'zt-plan', 'data-plan': plan.id, open: ctx.offen.has(plan.id) })
|
||||
details.addEventListener('toggle', () => {
|
||||
if (details.hasAttribute('open')) ctx.offen.add(plan.id)
|
||||
else ctx.offen.delete(plan.id)
|
||||
})
|
||||
|
||||
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', value: plan.name })
|
||||
const kurzEl = h('code', { 'data-kurz': '' }, plan.kurz)
|
||||
// Vor dem ersten Speichern bildet der Server den Kurznamen aus dem Namen von jetzt; danach bleibt er (der Name kann sich aendern, der Kurzname nicht)
|
||||
if (!daten) name.addEventListener('input', () => { kurzEl.textContent = kurzname(name.value.trim() || plan.name) })
|
||||
const tage = WOCHENTAGE_PLAN.map(([nr, tagName]) => ({
|
||||
nr,
|
||||
tag: tagFelder({ pfad: `${p}.tage.${nr}`, label: tagName, haken: true, modus: plan.tage?.[nr]?.modus, fenster: plan.tage?.[nr]?.fenster }),
|
||||
}))
|
||||
for (const { nr, tag } of tage) tag.el.dataset.tag = nr
|
||||
|
||||
/** Name und Tage aus dem Formular: { name, tage } oder { fehler } (Pfad => Text); wochenFehler zeigt, was der Server auch abweisen wuerde. */
|
||||
function lies() {
|
||||
const fehler = {}
|
||||
const gewaehlt = name.value.trim()
|
||||
if (!gewaehlt) fehler[`${p}.name`] = 'Bitte gib dem Zeitplan einen Namen.'
|
||||
const gelesen = {}
|
||||
for (const { nr, tag } of tage) {
|
||||
const r = tag.lies()
|
||||
if (r.fehler) Object.assign(fehler, r.fehler)
|
||||
else gelesen[nr] = { modus: r.modus, fenster: r.fenster }
|
||||
}
|
||||
for (const [nr, text] of Object.entries(wochenFehler({ tage: gelesen }))) if (!Object.hasOwn(fehler, `${p}.tage.${nr}`)) fehler[`${p}.tage.${nr}`] = text
|
||||
return Object.keys(fehler).length ? { fehler } : { name: gewaehlt, tage: gelesen }
|
||||
}
|
||||
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
|
||||
const grund = daten && !nurLesen ? planLoeschenGrund(daten, plan.id) : null
|
||||
// Wie viele Ausnahmen nur fuer diesen Plan gelten, steht erst beim Oeffnen der Rueckfrage fest: Ausnahmen zeichnen die Karte nicht neu
|
||||
const mitAusnahmen = () => (Array.isArray(ctx.stand.daten?.ausnahmen) ? ctx.stand.daten.ausnahmen : []).filter((a) => a?.plan === plan.id).length
|
||||
const loeschenText = () => {
|
||||
const n = mitAusnahmen()
|
||||
return [
|
||||
LOESCHEN_WARNUNG,
|
||||
n === 0 ? '' : n === 1
|
||||
? 'Die eine Ausnahme, die nur für diesen Plan gilt, wird mit gelöscht.'
|
||||
: `Die ${n} Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht.`,
|
||||
]
|
||||
}
|
||||
const entfernen = daten && !nurLesen && grund === null ? entfernenKnopf(sp, {
|
||||
knopfText: 'Plan löschen',
|
||||
ja: 'Ja, löschen',
|
||||
titel: `Zeitplan „${plan.name}“ löschen?`,
|
||||
text: loeschenText,
|
||||
fehlerPfad: p,
|
||||
// Ausnahmen, die nur fuer diesen Plan galten, haetten danach keinen Plan mehr (R10): sie gehen mit
|
||||
aendere: (entwurf) => {
|
||||
entwurf.plaene = entwurf.plaene.filter((x) => x.id !== plan.id)
|
||||
entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((a) => a?.plan !== plan.id)
|
||||
},
|
||||
nachher: () => {
|
||||
ctx.offen.delete(plan.id)
|
||||
nachSpeichern(t, `Der Zeitplan „${plan.name}“ ist gelöscht.`, '[data-werkzeug="plan-neu"]', () => {
|
||||
zeichnePlanListe(t)
|
||||
t.zeichne(['ausnahmen']) // die Ausnahmen dieses Plans sind mit weg, „Gilt für“ kennt den Plan nicht mehr
|
||||
})
|
||||
},
|
||||
}) : null
|
||||
|
||||
const hauptKnopf = daten && !nurLesen && !plan.haupt ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'haupt' }, 'Zum Hauptplan machen') : null
|
||||
|
||||
const form = h('form', { class: 'zt-plan-form', novalidate: true, 'aria-label': `Zeitplan ${plan.name}` },
|
||||
gesperrt(nurLesen, [
|
||||
feld(`${p}.name`, 'Name des Zeitplans', name, daten
|
||||
? 'Der Kurzname bleibt gleich, auch wenn du den Namen änderst.'
|
||||
: 'Der Kurzname wird beim ersten Speichern aus dem Namen gebildet und bleibt dann immer gleich.'),
|
||||
h('div', { class: 'zt-tage' }, tage.map(({ tag }) => tag.el)),
|
||||
]),
|
||||
allgemeinerFehler(),
|
||||
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern, hauptKnopf, entfernen?.knopf),
|
||||
grund ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'loeschen' }, grund) : null)
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const stelle = stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan')
|
||||
if (stelle < 0) return false
|
||||
const gelesen = lies()
|
||||
if (gelesen.fehler) {
|
||||
zeigeFehler(form, { fehler: gelesen.fehler })
|
||||
return false
|
||||
}
|
||||
const kurz = daten ? entwurf.plaene[stelle].kurz : kurzname(gelesen.name)
|
||||
entwurf.plaene[stelle] = { ...entwurf.plaene[stelle], kurz, name: gelesen.name, tage: gelesen.tage }
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
const gespeichert = planMitId(ctx, plan.id)
|
||||
nachSpeichern(t, `Die Zeiten von „${gespeichert?.name ?? plan.name}“ sind gespeichert.`, planSelektor(plan.id, '[data-aktion="speichern"]'), () => {
|
||||
if (!daten) { // das erste Speichern: jetzt gibt es auch Feiertage und Ausnahmen, der ganze Editor ist neu (es gibt sonst noch kein Formular)
|
||||
t.zeichne()
|
||||
return
|
||||
}
|
||||
ersetzePlanKarte(t, plan.id)
|
||||
if (gespeichert && gespeichert.name !== plan.name) t.zeichne(['ausnahmen']) // die Liste der Ausnahmen nennt den Plan beim Namen
|
||||
})
|
||||
})
|
||||
|
||||
hauptKnopf?.addEventListener('click', async () => {
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
if (stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan') < 0) return false
|
||||
entwurf.plaene = hauptplanWechseln(entwurf, plan.id).plaene
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
nachSpeichern(t, `Der Zeitplan „${planMitId(ctx, plan.id)?.name ?? plan.name}“ ist jetzt der Hauptplan. Er steuert den Live-Status und die Leisten auf deiner Website.`, planSelektor(plan.id, 'summary'), () => zeichnePlanListe(t))
|
||||
})
|
||||
|
||||
KARTEN.set(details, {
|
||||
merke: () => ({ name: name.value, tage: tage.map(({ tag }) => tag.merke()) }),
|
||||
stelleWieder: (gemerkt) => {
|
||||
name.value = gemerkt.name
|
||||
tage.forEach(({ tag }, i) => tag.stelleWieder(gemerkt.tage[i]))
|
||||
},
|
||||
})
|
||||
details.append(
|
||||
h('summary', { class: 'zt-plan-kopf' },
|
||||
h('strong', { class: 'tm-name' }, plan.name),
|
||||
plan.haupt ? h('span', { class: 'tm-pille tm-pille-ok' }, 'Hauptplan') : null,
|
||||
h('span', { class: 'tm-meta' }, 'Kurzname: ', kurzEl)),
|
||||
h('div', { class: 'tm-editor' }, form, entfernen?.box))
|
||||
return details
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Speichern des Editors – gemeinsam fuer alle Abschnitte (Zeitplaene, Feiertage, Ausnahmen und die
|
||||
Abschnitte der Aufgabe 30). Jede Aenderung ist ein Speichern: PUT /api/zeiten/<projectId> schickt ALLE Daten mit der Version, auf der sie
|
||||
beruhen (server/routes/zeiten.js). Der Abschnitt gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des
|
||||
aktuellen Stands (ctx.stand.daten, vor dem ersten Speichern ctx.leereDaten()), laesst aendere(entwurf) sie aendern und schickt sie ab.
|
||||
Muster: termine-speichern.js (neuerSpeicher), dort nichts geaendert.
|
||||
- Erfolg: ctx.uebernimm(antwort, { erstes }) setzt den neuen Stand (Antwort des Servers: daten, version, feiertage, vorschau …); Rueckgabe true.
|
||||
- 400: Fehler stehen am Feld (data-fehler = Pfad mit der ID des Eintrags, z. B. 'plaene.p_ab12cd34.tage.3', 'ausnahmen.x_ab12cd34.von',
|
||||
'feiertage.2026-12-25'); Pfade ohne Platz im container erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
|
||||
- 409 (ZEITEN_VERALTET, jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Editor. Rueckgabe false.
|
||||
- alles andere (429 zu viele Speichervorgaenge, 403, 500, kein Netz): Meldung ueber dem Editor (ctx.meldung). Rueckgabe false.
|
||||
- Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange der PUT laeuft, ist der Editor aria-busy, alle seine Knoepfe UND Eingabefelder
|
||||
sind gesperrt (waehrenddessen Getipptes ginge sonst verloren), und ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen
|
||||
(Rueckgabe false), statt mit einer veralteten Version zu gehen. Der Fokus geht danach zum ersten Feld mit Fehler, sonst dorthin zurueck, wo er war.
|
||||
- Nach einem Erfolg zeichnet der Abschnitt nur neu, was das Speichern geaendert hat (nachSpeichern mit neuZeichnen): ungespeicherte Eingaben in den
|
||||
anderen Formularen bleiben stehen. Nur „Neu laden“ nach einem 409 zeichnet den ganzen Editor neu.
|
||||
Der Abschnitt ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Texte des Servers kommen nur als Text in die Seite (h, textContent).
|
||||
ctx (baut zeiten-editor.js): { projectId, stand, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }) }. */
|
||||
|
||||
import { allgemeinerFehler, frage, h, knopfZeile, request, setzeFehler, zeigeFehler } from './termine-dom.js'
|
||||
import { datumKurz } from './termine-view.js'
|
||||
|
||||
export const VERALTET_TEXT = 'Inzwischen geändert – bitte neu laden.'
|
||||
|
||||
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
const istTagText = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
const finde = (liste, id) => (Array.isArray(liste) ? liste.find((eintrag) => eintrag?.id === id) : undefined)
|
||||
|
||||
/**
|
||||
* „Zeitplan „Küche“, Dienstag: Text“ aus Pfad und Text eines Fehlers. Eintraege finden sich per ID in den Daten (quellen: bisheriger Stand und
|
||||
* Entwurf); eine ID ist nie ein Schluessel eines Objekts (IDs wie „__proto__“ sind gewoehnliche Texte).
|
||||
*/
|
||||
export function beschrifteFehler(pfad, text, quellen) {
|
||||
const teile = pfad.split('.')
|
||||
const suche = (name, id) => {
|
||||
for (const quelle of quellen) {
|
||||
const eintrag = /^\d+$/.test(id) ? quelle?.[name]?.[Number(id)] : finde(quelle?.[name], id)
|
||||
if (eintrag) return eintrag
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const mit = (wer) => `${wer}: ${text}`
|
||||
switch (teile[0]) {
|
||||
case 'plaene': {
|
||||
const plan = teile[1] ? suche('plaene', teile[1]) : undefined
|
||||
if (!plan?.name) return text
|
||||
const tag = teile[2] === 'tage' && /^[1-7]$/.test(teile[3] || '') ? `, ${WOCHENTAGE[Number(teile[3]) - 1]}` : ''
|
||||
return mit(`Zeitplan „${plan.name}“${tag}`)
|
||||
}
|
||||
case 'ausnahmen': {
|
||||
const ausnahme = teile[1] ? suche('ausnahmen', teile[1]) : undefined
|
||||
if (!ausnahme) return text
|
||||
const wann = istTagText(ausnahme.von) ? ` am ${datumKurz(ausnahme.von)}` : ''
|
||||
return mit(`${ausnahme.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'}${wann}${ausnahme.text ? ` („${ausnahme.text}“)` : ''}`)
|
||||
}
|
||||
case 'hinweise': {
|
||||
const hinweis = teile[1] ? suche('hinweise', teile[1]) : undefined
|
||||
return hinweis?.text ? mit(`Hinweis „${hinweis.text}“`) : text
|
||||
}
|
||||
case 'feiertage':
|
||||
return istTagText(teile[1]) ? mit(`Feiertag ${datumKurz(teile[1])}`) : text
|
||||
case 'kontakt':
|
||||
return mit('Kontakt-Leiste')
|
||||
case 'land':
|
||||
return mit('Bundesland')
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). Den Fokus setzt speichere nach dem Entsperren. */
|
||||
function zeigeFelder(container, fehler, quellen) {
|
||||
const amFeld = {}
|
||||
const rest = []
|
||||
for (const [pfad, text] of Object.entries(fehler)) {
|
||||
if (pfad !== '_' && container.querySelector(`[data-fehler="${CSS.escape(pfad)}"]`)) amFeld[pfad] = text
|
||||
else rest.push(beschrifteFehler(pfad, text, quellen))
|
||||
}
|
||||
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
|
||||
}
|
||||
|
||||
/**
|
||||
* Speicher des Editors. wurzel = das Element des Editors (verschwindet beim Wechsel der Website: danach fasst nichts mehr die Seite an),
|
||||
* neuZeichnen() zeichnet den Editor aus ctx.stand neu (nach „Neu laden“).
|
||||
* => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Editor setzt es an den Anfang), nochHier() sagt, ob der
|
||||
* Editor noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
|
||||
*/
|
||||
export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
|
||||
const nochHier = () => wurzel.isConnected && ctx.aktuell()
|
||||
const text = h('span', null, VERALTET_TEXT)
|
||||
const neuLadenKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden' }, 'Neu laden')
|
||||
const konflikt = h('div', { class: 'tm-box tm-box-warn', role: 'alert', hidden: true }, text, neuLadenKnopf)
|
||||
neuLadenKnopf.addEventListener('click', async () => {
|
||||
neuLadenKnopf.disabled = true
|
||||
try {
|
||||
await ctx.neuLaden()
|
||||
if (!nochHier()) return
|
||||
text.textContent = VERALTET_TEXT
|
||||
konflikt.hidden = true
|
||||
neuZeichnen()
|
||||
} catch (err) {
|
||||
if (nochHier()) text.textContent = err.message
|
||||
} finally {
|
||||
neuLadenKnopf.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Editor und alle Knoepfe und Eingabefelder darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
|
||||
let laeuft = false
|
||||
function sperre(an, gesperrt) {
|
||||
if (an) {
|
||||
wurzel.setAttribute('aria-busy', 'true')
|
||||
const steuerungen = [...wurzel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')]
|
||||
const fokus = document.activeElement
|
||||
for (const steuerung of steuerungen) steuerung.disabled = true
|
||||
return { steuerungen, fokus }
|
||||
}
|
||||
wurzel.removeAttribute('aria-busy')
|
||||
for (const steuerung of gesperrt.steuerungen) steuerung.disabled = false
|
||||
// Der Fokus war auf einer gesperrten Steuerung und ist weg: zurueck dorthin; hat der Abschnitt ihn inzwischen anders gesetzt (z. B. auf die Meldung), bleibt das so
|
||||
const jetzt = document.activeElement
|
||||
if (gesperrt.steuerungen.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus()
|
||||
return null
|
||||
}
|
||||
|
||||
async function speichere(aendere, { container }) {
|
||||
if (laeuft) return false // ein Speichern zur Zeit
|
||||
laeuft = true
|
||||
let gesperrt = null
|
||||
let amFeld = false
|
||||
try {
|
||||
setzeFehler(container, {})
|
||||
konflikt.hidden = true
|
||||
const bisher = ctx.stand.daten
|
||||
const entwurf = structuredClone(bisher ?? ctx.leereDaten())
|
||||
if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt
|
||||
gesperrt = sperre(true)
|
||||
try {
|
||||
const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } })
|
||||
if (!ctx.aktuell()) return false
|
||||
ctx.uebernimm(antwort, { erstes: bisher === null })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!nochHier()) return false
|
||||
if (err.code === 'ZEITEN_VERALTET') {
|
||||
text.textContent = VERALTET_TEXT
|
||||
konflikt.hidden = false
|
||||
konflikt.scrollIntoView({ block: 'nearest' })
|
||||
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
|
||||
zeigeFelder(container, err.fehler, [bisher, entwurf])
|
||||
amFeld = true
|
||||
} else {
|
||||
ctx.meldung(err.message, 'fehler')
|
||||
}
|
||||
return false
|
||||
}
|
||||
} finally {
|
||||
laeuft = false
|
||||
if (gesperrt) sperre(false, gesperrt)
|
||||
if (amFeld && nochHier()) container.querySelector('[aria-invalid="true"]')?.focus() // gesperrte Felder nehmen den Fokus nicht: erst nach dem Entsperren
|
||||
}
|
||||
}
|
||||
|
||||
return { konflikt, speichere, nochHier }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelle des Eintrags mit dieser ID in der Liste des Entwurfs. Formulare merken sich keine Stelle, sondern die ID und suchen sie beim
|
||||
* Absenden. Fehlt der Eintrag, steht am Formular eine Meldung und die Rueckgabe ist -1.
|
||||
*/
|
||||
export function stelleVon(liste, id, form, was) {
|
||||
const stelle = (Array.isArray(liste) ? liste : []).findIndex((eintrag) => eintrag?.id === id)
|
||||
if (stelle < 0) zeigeFehler(form, { fehler: { _: `${was} gibt es nicht mehr. Bitte lade die Seite neu.` } })
|
||||
return stelle
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem erfolgreichen Speichern: neu zeichnen, was das Speichern geaendert hat (neuZeichnen(); ohne Angabe der ganze Editor – die Abschnitte
|
||||
* rufen es immer mit der kleinsten noetigen Aenderung auf, damit ungespeicherte Eingaben in anderen Formularen stehen bleiben), Meldung zeigen,
|
||||
* Fokus auf das Element mit selektor (Regel: nach dem Speichern auf den Knopf der gespeicherten Zeile, nach dem Entfernen auf den „+“-Knopf der Liste).
|
||||
*/
|
||||
export function nachSpeichern(t, meldung, selektor, neuZeichnen = () => t.zeichne()) {
|
||||
neuZeichnen()
|
||||
t.ctx.meldung(meldung)
|
||||
t.wurzel.querySelector(selektor)?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung (text: ein Text, eine Liste von Absaetzen oder eine Funktion, die beides beim Oeffnen liefert), Platz fuer den Fehler
|
||||
* des Servers, Knoepfe. aendere(entwurf) entfernt den Eintrag, nachher() laeuft nach dem erfolgreichen Speichern (der Abschnitt zeichnet neu).
|
||||
* Abbrechen schliesst die Rueckfrage und der Fokus geht zum Knopf zurueck. => { knopf, box }: der Aufrufer setzt beide NEBEN sein Formular, nicht
|
||||
* hinein (ein submit der Rueckfrage stiege sonst ins aeussere Formular auf).
|
||||
*/
|
||||
export function entfernenKnopf(sp, { knopfText = 'Entfernen', ja = 'Ja, entfernen', titel, text, fehlerPfad, aendere, nachher }) {
|
||||
const box = h('div', { class: 'tm-frage' })
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'entfernen' }, knopfText)
|
||||
knopf.addEventListener('click', () => {
|
||||
const { zeile } = knopfZeile(ja, () => { box.replaceChildren(); knopf.focus() })
|
||||
// text darf eine Funktion sein: sie liest den Stand erst jetzt, beim Oeffnen (die Karte kann aelter sein als eine Ausnahme, die inzwischen dazukam)
|
||||
const gesagt = typeof text === 'function' ? text() : text
|
||||
const absaetze = (Array.isArray(gesagt) ? gesagt : [gesagt]).filter(Boolean).map((t) => h('p', null, t))
|
||||
const rueckfrage = frage(titel,
|
||||
...absaetze,
|
||||
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
rueckfrage.addEventListener('submit', async () => {
|
||||
if (await sp.speichere(aendere, { container: rueckfrage }) && sp.nochHier()) nachher()
|
||||
})
|
||||
box.replaceChildren(rueckfrage)
|
||||
rueckfrage.focus()
|
||||
})
|
||||
return { knopf, box }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: reine Helfer fuer den Editor ohne DOM (ES-Modul; die Oberflaeche in den spaeteren
|
||||
zeiten*.js-Dateien nutzt sie, test/zeitenView.test.js prueft sie). Alles hier ist das Gegenstueck zu den Regeln des Servers
|
||||
(server/services/zeiten/pruefen.js): gleiche Grenzen, gleiche Kurznamen, gleiche Fensterregeln, gleiche Texte. Der Server
|
||||
bleibt die Instanz, die entscheidet; der Editor zeigt Fehler nur frueher an. test/zeitenView.test.js haelt jede Regel gegen den
|
||||
Server (zufaellige Eingaben, gleiche Ergebnisse).
|
||||
Tage sind Berliner Kalendertage 'JJJJ-MM-TT', Uhrzeiten 'HH:MM'. IDs von Plaenen, Ausnahmen und Hinweisen kommen vom Server und
|
||||
koennen jede Zeichenfolge aus [A-Za-z0-9_-] sein ('__proto__', 'constructor'): hier wird nie ein Objekt nach einer ID befragt,
|
||||
nur eine Liste durchsucht. */
|
||||
|
||||
import { datumKurz, istTag, tagPlus } from './termine-view.js'
|
||||
|
||||
/* ------------------------------------------------------------- Grenzen --- */
|
||||
/** Grenzen der Spezifikation (Server: ZEITEN_LIMITS, test/zeitenView.test.js vergleicht beide). */
|
||||
export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, ausnahmen: 100, hinweise: 20, urlaubTage: 366 })
|
||||
/** Laengen der Felder (maxlength der Eingaben). */
|
||||
export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24 })
|
||||
/** Zeitfenster je Tag (Server: ZEITEN_LIMITS.fensterJeTag). */
|
||||
export const FENSTER_JE_TAG = 2
|
||||
/** Betriebsart eines Tages, einer Ausnahme oder eines Feiertags. */
|
||||
export const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
|
||||
export const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig'])
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const fehlt = (wert) => wert === undefined || wert === null
|
||||
|
||||
/* ------------------------------------------------------------------ IDs --- */
|
||||
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const ID_PRAEFIXE = ['p', 'x', 'h']
|
||||
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
|
||||
|
||||
/**
|
||||
* Neue ID eines Plans ('p'), einer Ausnahme ('x') oder eines Hinweises ('h'): Praefix_ + 8 Zeichen a-z0-9, dieselbe Form wie auf dem
|
||||
* Server (der behaelt gueltige IDs; so koennen neue Ausnahmen schon vor dem Speichern auf einen neuen Plan verweisen).
|
||||
* bytes(n) liefert n Zufallsbytes (Vorgabe crypto.getRandomValues; nur fuer Tests austauschbar). Bytes ab 252 werden verworfen
|
||||
* (252 = 7 * 36): so ist jedes Zeichen gleich wahrscheinlich.
|
||||
*/
|
||||
export function neueZeitenId(praefix, bytes = zufallsBytes) {
|
||||
if (!ID_PRAEFIXE.includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${String(praefix)}`)
|
||||
let zeichen = ''
|
||||
while (zeichen.length < 8) {
|
||||
for (const byte of bytes(16)) {
|
||||
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length]
|
||||
}
|
||||
}
|
||||
return `${praefix}_${zeichen}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Kurznamen --- */
|
||||
/**
|
||||
* Kurzname [a-z0-9-]{1,24} aus dem Namen, genau wie auf dem Server: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich;
|
||||
* leer = 'plan'; ist er in vorhandene (Liste oder Set) belegt, kommt -2, -3, ... davor (immer <= 24 Zeichen).
|
||||
* Der Server vergibt den Kurznamen beim ersten Speichern endgueltig und beruecksichtigt dabei auch Namen frueher geloeschter
|
||||
* Plaene; zeigt der Editor einen Kurznamen vorab, gilt nach dem Speichern der Wert aus der Antwort.
|
||||
*/
|
||||
export function kurzname(name, vorhandene = []) {
|
||||
const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene)
|
||||
let basis = (typeof name === 'string' ? name : '')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '')
|
||||
if (basis === '') basis = 'plan'
|
||||
if (!belegt.has(basis)) return basis
|
||||
for (let n = 2; ; n += 1) {
|
||||
const endung = `-${n}`
|
||||
const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}`
|
||||
if (!belegt.has(kandidat)) return kandidat
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Fenster --- */
|
||||
const FENSTER_FEHLER = {
|
||||
form: 'Ungültige Zeitfenster.',
|
||||
zuviele: `Pro Tag sind höchstens ${FENSTER_JE_TAG} Zeitfenster möglich.`,
|
||||
uhrzeit: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.',
|
||||
gleich: 'Beginn und Ende dürfen nicht gleich sein.',
|
||||
nacht: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.',
|
||||
ueberschnitt: 'Die Zeitfenster überschneiden sich.',
|
||||
folgetag: 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.',
|
||||
leer: 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.',
|
||||
modus: 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.',
|
||||
eintrag: 'Ungültiger Eintrag.',
|
||||
}
|
||||
|
||||
/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), sonst null. */
|
||||
function minuten(wert, istEnde) {
|
||||
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
/**
|
||||
* Fensterregeln eines Tages ohne Blick auf den Folgetag: { fehler } oder { ersteVon (Minuten|null), nachtBis (Minuten|null) }.
|
||||
* nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht. Reihenfolge der Pruefungen wie auf dem Server (so erscheint
|
||||
* bei mehreren Fehlern derselbe Text).
|
||||
*/
|
||||
function fensterRegeln(fenster) {
|
||||
if (!Array.isArray(fenster)) return { fehler: FENSTER_FEHLER.form }
|
||||
if (fenster.length > FENSTER_JE_TAG) return { fehler: FENSTER_FEHLER.zuviele }
|
||||
const f = []
|
||||
for (const eintrag of fenster) {
|
||||
const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null
|
||||
const bis = von === null ? null : minuten(eintrag[1], true)
|
||||
if (von === null || bis === null) return { fehler: FENSTER_FEHLER.uhrzeit }
|
||||
if (bis === von) return { fehler: FENSTER_FEHLER.gleich }
|
||||
f.push({ von, bis })
|
||||
}
|
||||
f.sort((a, b) => a.von - b.von)
|
||||
for (let i = 0; i < f.length; i += 1) {
|
||||
if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: FENSTER_FEHLER.nacht }
|
||||
if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: FENSTER_FEHLER.ueberschnitt }
|
||||
}
|
||||
const letztes = f[f.length - 1]
|
||||
return { ersteVon: f.length ? f[0].von : null, nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null }
|
||||
}
|
||||
|
||||
/** Ragt ein Fenster bis nach Mitternacht (nachtBis) in das erste Fenster des Folgetags (ersteVon) hinein? Aneinandergrenzen ist erlaubt. */
|
||||
const ragtHinein = (nachtBis, ersteVonFolgetag) => nachtBis !== null && ersteVonFolgetag !== null && nachtBis > ersteVonFolgetag
|
||||
|
||||
/**
|
||||
* Pruefung der Zeitfenster eines Tages: Meldung (Du-Form, Text wie auf dem Server) oder null. fenster = [['09:00','12:30'], ...].
|
||||
* Regeln: hoechstens 2 Fenster, HH:MM im 5-Minuten-Raster (Ende bis 24:00), Ende ungleich Beginn, keine Ueberschneidung (Beruehren
|
||||
* ist erlaubt), Ende vor Beginn (ueber Mitternacht) nur beim letzten Fenster und nur, wenn es nicht in das erste Fenster des Folgetags
|
||||
* hineinragt. folgetagErstes = Beginn des ersten Fensters am Folgetag ('HH:MM') oder null (Folgetag zu, rund um die Uhr, nach
|
||||
* Vereinbarung oder selbst ungueltig). Eine leere Liste verletzt keine Fensterregel; dass ein „geoeffneter“ Tag eines braucht, prueft tagFehler.
|
||||
*/
|
||||
export function fensterPruefen(fenster, { folgetagErstes = null } = {}) {
|
||||
const r = fensterRegeln(fenster)
|
||||
if (r.fehler) return r.fehler
|
||||
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
|
||||
}
|
||||
|
||||
/** Regeln eines Tages mit Betriebsart: { fehler } oder { ersteVon, nachtBis } (ohne Fenster beide null). Fehlt der Tag, ist er geschlossen. */
|
||||
function tagRegeln(tag) {
|
||||
if (fehlt(tag)) return { ersteVon: null, nachtBis: null }
|
||||
if (!istObjekt(tag)) return { fehler: FENSTER_FEHLER.eintrag }
|
||||
if (!MODI.includes(tag.modus)) return { fehler: FENSTER_FEHLER.modus }
|
||||
if (tag.modus !== 'zeiten') return { ersteVon: null, nachtBis: null }
|
||||
const r = fensterRegeln(tag.fenster)
|
||||
if (r.fehler) return r
|
||||
if (r.ersteVon === null) return { fehler: FENSTER_FEHLER.leer }
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie fensterPruefen, aber fuer einen ganzen Tag { modus, fenster } (Wochentag, Sondertag, Feiertag): der Modus muss einer aus MODI
|
||||
* sein, „geoeffnet“ braucht mindestens ein Fenster, bei jedem anderen Modus zaehlen Fenster nicht. => Meldung oder null.
|
||||
*/
|
||||
export function tagFehler(tag, { folgetagErstes = null } = {}) {
|
||||
const r = tagRegeln(tag)
|
||||
if (r.fehler) return r.fehler
|
||||
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler eines Wochenplans je Wochentag: { '3': 'Text' } (leer = alles in Ordnung). Jeder Tag nach tagFehler; dazu prueft jeder
|
||||
* Tag sein Nachtfenster gegen das erste Fenster des Folgetags (Sonntag gegen Montag), sofern der Folgetag selbst in Ordnung ist
|
||||
* (sonst steht dort dessen eigener Fehler). plan = { tage: { 1: { modus, fenster }, ..., 7 } }; fehlende Tage sind geschlossen.
|
||||
*/
|
||||
export function wochenFehler(plan) {
|
||||
const tage = plan?.tage
|
||||
if (!istObjekt(tage)) return {}
|
||||
const regeln = {}
|
||||
for (let d = 1; d <= 7; d += 1) regeln[d] = tagRegeln(tage[d])
|
||||
const fehler = {}
|
||||
for (let d = 1; d <= 7; d += 1) {
|
||||
const r = regeln[d]
|
||||
const folgetag = regeln[(d % 7) + 1]
|
||||
if (r.fehler) fehler[d] = r.fehler
|
||||
else if (!folgetag.fehler && ragtHinein(r.nachtBis, folgetag.ersteVon)) fehler[d] = FENSTER_FEHLER.folgetag
|
||||
}
|
||||
return fehler
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Plaene --- */
|
||||
const NIE_LOESCHBAR = 'Der letzte Zeitplan kann nicht gelöscht werden.'
|
||||
const HAUPTPLAN_LOESCHEN = 'Erst einen anderen Plan zum Hauptplan machen.'
|
||||
const PLAN_UNBEKANNT = 'Diesen Zeitplan gibt es nicht.'
|
||||
|
||||
/** Neuer Plan mit neuer ID und sieben geschlossenen Tagen (je Tag ein eigenes Objekt). kurz kommt aus kurzname(name, vorhandene). */
|
||||
export function leererPlan({ name, kurz, haupt = false } = {}) {
|
||||
const tage = {}
|
||||
for (let d = 1; d <= 7; d += 1) tage[d] = { modus: 'zu', fenster: [] }
|
||||
return { id: neueZeitenId('p'), kurz, name, haupt: haupt === true, tage }
|
||||
}
|
||||
|
||||
/** Neue Daten, in denen genau der Plan mit dieser ID der Hauptplan ist; die Eingabe bleibt unveraendert. Unbekannte ID: Daten unveraendert. */
|
||||
export function hauptplanWechseln(daten, planId) {
|
||||
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
|
||||
if (!plaene.some((p) => p?.id === planId)) return daten
|
||||
return { ...daten, plaene: plaene.map((p) => ({ ...p, haupt: p.id === planId })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum sich der Plan nicht loeschen laesst (Text in Du-Form), sonst null. Der letzte Plan ist immer auch der Hauptplan, hat aber den
|
||||
* deutlicheren Grund (einen anderen Hauptplan gaebe es gar nicht); danach der Hauptplan: erst einen anderen dazu machen.
|
||||
*/
|
||||
export function planLoeschenGrund(daten, planId) {
|
||||
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
|
||||
const plan = plaene.find((p) => p?.id === planId)
|
||||
if (!plan) return PLAN_UNBEKANNT
|
||||
if (plaene.length <= 1) return NIE_LOESCHBAR
|
||||
if (plan.haupt === true) return HAUPTPLAN_LOESCHEN
|
||||
return null
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Wochentage --- */
|
||||
/** Wochentag eines Kalendertags 'JJJJ-MM-TT': 1 = Montag … 7 = Sonntag. */
|
||||
export function wochentagVon(tag) {
|
||||
const nr = new Date(`${tag}T00:00:00Z`).getUTCDay() // 0 = Sonntag
|
||||
return nr === 0 ? 7 : nr
|
||||
}
|
||||
|
||||
/** Der erste Tag ab heute (heute eingeschlossen), der auf den Wochentag (1 = Montag … 7 = Sonntag) fällt: Tag für den Regler „Uhrzeit testen“. */
|
||||
export function naechstesVorkommen(heute, wochentag) {
|
||||
return tagPlus(heute, (wochentag - wochentagVon(heute) + 7) % 7)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Hinweis-Leiste --- */
|
||||
const VORLAUF_TAGE = 7
|
||||
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
|
||||
const tagMonat = (tag) => datumKurz(tag).slice(0, 6) // '12.10.'
|
||||
|
||||
/**
|
||||
* Vorschlag fuer eine Hinweis-Leiste zu einem Betriebsurlaub (von, bis; bis fehlt = ein Tag): ab 2 Tagen
|
||||
* { art: 'urlaub', text: 'Betriebsurlaub vom 12.10. bis 26.10.', von: Urlaubsbeginn - 7 Tage, bis: Urlaubsende }, sonst null.
|
||||
* Dauert der Urlaub so lang, dass 7 Tage Vorlauf die 366 Tage fuer einen Urlaubshinweis sprengen wuerden (der Server lehnte ihn
|
||||
* ab), beginnt der Hinweis entsprechend spaeter.
|
||||
*/
|
||||
export function hinweisVorschlag(urlaub) {
|
||||
const von = urlaub?.von
|
||||
const bis = urlaub?.bis
|
||||
if (!istTag(von) || !istTag(bis) || bis <= von) return null
|
||||
const fruehestens = tagPlus(bis, -(ZEITEN_LIMITS.urlaubTage - 1))
|
||||
const mitVorlauf = tagPlus(von, -VORLAUF_TAGE)
|
||||
return { art: 'urlaub', text: `Betriebsurlaub vom ${tagMonat(von)} bis ${tagMonat(bis)}`, von: mitVorlauf < fruehestens ? fruehestens : mitVorlauf, bis }
|
||||
}
|
||||
|
||||
/** Braucht ein Hinweistext den Hinweis zu Preisangaben (Endpreise, niedrigster Preis der letzten 30 Tage)? Bei „%“ oder „statt“. */
|
||||
export function preisHinweisNoetig(text) {
|
||||
return typeof text === 'string' && /%|statt/i.test(text)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Links --- */
|
||||
// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern (wie der Server).
|
||||
const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/
|
||||
|
||||
/**
|
||||
* Ist das ein erlaubtes Ziel fuer einen Link-Knopf? https, hoechstens 300 Zeichen, ohne Leerraum und Zugangsdaten, ASCII-Hostname
|
||||
* (Umlaute-Domains als Punycode); ein Apostroph im Pfad zaehlt als %27 (so speichert ihn der Server). Aussen stehender Leerraum
|
||||
* wird wie beim Speichern abgeschnitten.
|
||||
*/
|
||||
export function linkOk(url) {
|
||||
const s = typeof url === 'string' ? url.trim() : ''
|
||||
if (s === '' || s.length > ZEITEN_MAX.link || /\s/.test(s)) return false
|
||||
let ziel
|
||||
try { ziel = new URL(s) } catch { return false }
|
||||
const href = ziel.href.replace(/'/g, '%27')
|
||||
return ziel.protocol === 'https:' && !ziel.username && !ziel.password && HOST_RE.test(ziel.hostname) && href.length <= ZEITEN_MAX.link
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js,
|
||||
zeiten-leisten.js, zeiten-google.js).
|
||||
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
|
||||
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
|
||||
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
|
||||
|
||||
#view-zeiten .kb-panel { max-width: none; }
|
||||
#view-zeiten .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
#view-zeiten button:focus-visible, #view-zeiten input:focus-visible, #view-zeiten select:focus-visible, #view-zeiten textarea:focus-visible,
|
||||
#view-zeiten summary:focus-visible, #view-zeiten a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
|
||||
#view-zeiten input, #view-zeiten select, #view-zeiten textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Zeitfelder dunkel */
|
||||
#view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 40px; }
|
||||
|
||||
/* Ansicht: oben die Karte oder die Bestellseite, darunter der Platz des Editors */
|
||||
.zt-kopf > .kb-panel { margin-bottom: 16px; }
|
||||
.zt-editor { margin-top: 4px; }
|
||||
.zt-editor[hidden] { display: none; }
|
||||
.zt-editor:empty { display: none; }
|
||||
.zt-hinweis h3 { margin-bottom: 8px; }
|
||||
.zt-hinweis .kb-btn-outline { margin-top: 4px; }
|
||||
.zt-bestellen .tm-bestellen-aktion .kb-btn-outline { justify-self: start; }
|
||||
.zt-status .tm-box { margin-bottom: 0; }
|
||||
.zt-status .tm-box .kb-btn-outline { flex: none; }
|
||||
|
||||
/* Editor: Zeitplaene, Feiertage, Ausnahmen (zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js) */
|
||||
.zt-editor > * + * { margin-top: 16px; }
|
||||
.zt-editor > .tm-meldung:empty, .zt-editor > .tm-box[hidden] { margin: 0; }
|
||||
/* Die Bestätigung (erstes Kind des Editors) klebt unter der Kopfleiste: nach dem Speichern eines unteren Abschnitts springt der Fokus auf den Knopf,
|
||||
und „gespeichert“ läge sonst ausser Sicht. Undurchsichtig, damit der Inhalt darunter nicht durchscheint; leer klebt nichts. */
|
||||
.zt-editor > .tm-meldung:not(:empty) { position: sticky; top: calc(var(--kb-appbar-h, 0px) + 8px); z-index: 5; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); }
|
||||
.zt-editor > .tm-meldung-ok:not(:empty) { background: linear-gradient(rgba(74, 222, 128, 0.1), rgba(74, 222, 128, 0.1)), #0c0c0e; }
|
||||
.zt-editor > .tm-meldung-fehler:not(:empty) { background: linear-gradient(rgba(248, 113, 113, 0.1), rgba(248, 113, 113, 0.1)), #0c0c0e; }
|
||||
.zt-kopfbereich:empty { display: none; }
|
||||
.zt-spaeter { margin: 0 4px; line-height: 1.5; }
|
||||
.zt-plaene { display: grid; gap: 12px; }
|
||||
.zt-plan { border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
|
||||
.zt-plan[open] { border-color: var(--kb-purple-line); }
|
||||
.zt-plan-kopf { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-height: 44px; padding: 12px 16px; list-style: none; cursor: pointer; }
|
||||
.zt-plan-kopf::-webkit-details-marker { display: none; }
|
||||
.zt-plan-kopf::before { content: '▸'; flex: none; color: var(--kb-muted); transition: transform 0.15s; }
|
||||
.zt-plan[open] > .zt-plan-kopf::before { transform: rotate(90deg); }
|
||||
.zt-plan-kopf .tm-name { display: inline; }
|
||||
.zt-plan-kopf .tm-meta { display: inline; margin: 0; }
|
||||
.zt-plan-kopf code, .tm-formular code { padding: 1px 6px; border-radius: 6px; background: rgba(255, 255, 255, 0.07); font-size: 12.5px; }
|
||||
.zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; }
|
||||
.zt-plan-form .tm-frage-knoepfe { margin-top: 16px; }
|
||||
.zt-plan-form > .tm-hinweis { margin-top: 10px; }
|
||||
.zt-grenze { margin: 0 0 12px; }
|
||||
.zt-grenze:empty { display: none; }
|
||||
.zt-werkzeuge { display: flex; flex-wrap: wrap; gap: 0 10px; }
|
||||
.zt-werkzeuge:empty { display: none; }
|
||||
.zt-editor .tm-abschnitt > .tm-frage-box { margin: 12px 0 16px; }
|
||||
.zt-tage { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.zt-tag { min-width: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
|
||||
.zt-tag > .tm-feld { margin-top: 0; }
|
||||
.zt-tag-felder { display: grid; gap: 10px; margin-top: 8px; }
|
||||
.zt-tag-felder[hidden], .zt-zeiten[hidden] { display: none; }
|
||||
.zt-tag select, .zt-feiertag select {
|
||||
width: 100%; max-width: 360px; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 16px;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.zt-tag select option { color: #111; }
|
||||
.zt-zeiten { display: grid; gap: 8px; }
|
||||
.zt-fenster { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
|
||||
.zt-fenster-name { min-width: 11rem; font-size: 13px; color: var(--kb-muted); }
|
||||
.zt-zeit { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; }
|
||||
.zt-zeit input {
|
||||
width: 8.5rem; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border-radius: 12px; font: inherit; font-size: 16px; font-variant-numeric: tabular-nums;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.zt-tag [aria-invalid='true'] { border-color: #f87171; }
|
||||
.zt-tag > .tm-fehler { margin-top: 8px; }
|
||||
.zt-feiertag .zt-tag { margin-top: 10px; padding: 0; border: 0; background: transparent; }
|
||||
|
||||
/* Editor II: Live-Vorschau mit Regler „Uhrzeit testen“, Hinweis-Leiste, Kontakt-Leiste, Google (zeiten-editor.js, zeiten-leisten.js, zeiten-google.js) */
|
||||
.zt-editor [hidden] { display: none; }
|
||||
.zt-vorschau { padding: 16px 18px; }
|
||||
.zt-vorschau-zeile { margin: 0; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.zt-vorschau-text { font-size: 17px; }
|
||||
.zt-vorschau-text::before { content: ''; display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; background: var(--kb-muted); }
|
||||
.zt-vorschau-text[data-offen='true']::before { background: #4ade80; }
|
||||
.zt-vorschau-text[data-offen='false']::before { background: #f87171; }
|
||||
.zt-vorschau-text[data-offen='null']::before { background: #fbbf24; }
|
||||
.zt-regler { min-width: 0; margin: 14px 0 0; padding: 0; border: 0; }
|
||||
.zt-regler legend { padding: 0; margin-bottom: 2px; font-size: 13px; font-weight: 600; }
|
||||
.zt-regler-felder { display: flex; flex-wrap: wrap; gap: 0 14px; }
|
||||
.zt-regler-felder .tm-feld { margin-top: 8px; }
|
||||
.zt-regler-ergebnis { margin-top: 10px; font-size: 14px; line-height: 1.5; color: inherit; overflow-wrap: anywhere; }
|
||||
.zt-regler-ergebnis:empty { display: none; }
|
||||
.zt-regler-ergebnis[role='alert'] { color: #fecaca; }
|
||||
.zt-regler .tm-feld select, .zt-regler .tm-feld input { min-height: 40px; }
|
||||
.zt-art { display: inline-block; padding: 1px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||
.zt-art-info { background: #e0f2fe; color: #0c4a6e; }
|
||||
.zt-art-aktion { background: #dcfce7; color: #14532d; }
|
||||
.zt-art-urlaub { background: #fef3c7; color: #78350f; }
|
||||
.zt-art-wichtig { background: #b91c1c; color: #ffffff; }
|
||||
.zt-muster { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px 12px; margin: 14px 0 0; padding: 10px 14px; border-radius: 10px; font-size: 14px; line-height: 1.4; text-align: center; }
|
||||
.zt-muster-text { overflow-wrap: anywhere; }
|
||||
.zt-muster-knopf { padding: 3px 12px; border-radius: 999px; border: 1.5px solid currentColor; font-size: 13px; font-weight: 700; }
|
||||
.zt-knopf-felder { margin-top: 0; }
|
||||
.zt-hinweis-form .zt-tag { margin-top: 0; }
|
||||
.zt-hinweis-liste { min-width: 0; }
|
||||
.zt-whatsapp { margin-top: 10px; padding: 0 14px 10px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
|
||||
.zt-google-text {
|
||||
display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 14px; line-height: 1.6; resize: vertical; white-space: pre; overflow: auto;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.zt-google-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.zt-google-aktionen a { display: inline-flex; align-items: center; text-decoration: none; }
|
||||
.zt-google-meldung { margin-top: 10px; font-size: 13.5px; line-height: 1.5; color: inherit; }
|
||||
.zt-google-meldung:empty { display: none; }
|
||||
|
||||
@media (max-width: 880px), (pointer: coarse) {
|
||||
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
|
||||
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
|
||||
#view-zeiten .tm-haken input { margin-top: 0; }
|
||||
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select, #view-zeiten .zt-regler .tm-feld select, #view-zeiten .zt-regler .tm-feld input { min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.zt-fenster-name { min-width: 100%; }
|
||||
.zt-zeit input { width: 7.75rem; }
|
||||
.zt-plan > .tm-editor { padding: 0 12px 14px; }
|
||||
.zt-bestellen .tm-bestellen-aktion { justify-items: stretch; }
|
||||
.zt-status .tm-box .kb-btn-outline { width: 100%; }
|
||||
.zt-status .tm-aktionen > * { flex: 1 1 auto; }
|
||||
#view-zeiten .tm-rechtstext { max-height: 260px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #view-zeiten * { transition: none !important; animation: none !important; } }
|
||||
@@ -0,0 +1,247 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: die Ansicht #zeiten (ES-Modul, app.js laedt es beim Oeffnen der Ansicht; Styles: zeiten.css, dazu die
|
||||
gemeinsamen tm-Klassen aus termine.css). Server: server/routes/funktionen.js (Liste, Einschalten, Bestellen, Behalten) und server/routes/zeiten.js
|
||||
(Editor, Aufgaben 29 und 30).
|
||||
initZeiten(rootEl, { projectId }): Projektauswahl (bei mehr als einer Website) und je nach Zustand der Funktion (features.zeiten.state aus
|
||||
GET /api/funktionen):
|
||||
off Einschalten (Wartungs-Abo laeuft), Bestellen (30 €) oder ein Hinweis (Abo noch nicht aktiv, nicht lesbar): renderZeitenBestellen
|
||||
ordered Statuskarte „Zahlung wird bestätigt …“ (Zahlung fortsetzen)
|
||||
setup Statuskarte (Einbau, Ticket, „Trag jetzt deine Zeiten ein“) und darunter der Platz des Editors
|
||||
active Statuskarte und der Platz des Editors
|
||||
disabled Statuskarte mit dem Grund; „Für 30 € wieder einschalten“, solange die Eingaben da sind; der Platz des Editors zum Lesen
|
||||
Das Abo-Ende (aboEndeAt) zeigt in setup und active den Hinweis „behalte die Funktion bis <Datum> für einmalig 30 €“ mit dem Knopf zur Behalten-Seite.
|
||||
Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen
|
||||
„Danke!“; #zeiten?projekt=<id> (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen
|
||||
legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten').
|
||||
Der Editor haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
|
||||
Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen.
|
||||
Aendert sich der Stand (zeitenLeer nach dem ersten Speichern, setup → active, die Abo-Lage), wird nur der Kopf (Statuskarte) neu gezeichnet: der Editorplatz
|
||||
bleibt mit seinem Editor und den ungespeicherten Eingaben stehen, solange Website und Schreibrecht (abgeschaltet = nur lesen) gleich sind (data-projekt,
|
||||
data-state); eine andere Website oder der Wechsel in „nur lesen“ bekommt einen frischen Editor. Die Rueckkehr von Stripe zeichnet immer ganz neu.
|
||||
Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
|
||||
|
||||
import { fehlerKarte, h, infoKarte, request } from './termine-dom.js'
|
||||
import { behaltenLage, renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js'
|
||||
import { renderEditor } from './zeiten-editor.js'
|
||||
|
||||
// Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten
|
||||
const auswahl = { projectId: '' }
|
||||
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
|
||||
|
||||
const zustandVon = (projekt) => projekt?.features?.zeiten?.state || 'off'
|
||||
const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
|
||||
const kundenansicht = () => Boolean(window.KbView?.id?.())
|
||||
|
||||
/** Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews/initTermine). */
|
||||
function leseRueckkehr() {
|
||||
const hash = window.location.hash || ''
|
||||
if (!hash.startsWith('#zeiten?')) return { bestellt: false, behalten: false, projekt: '' }
|
||||
const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#zeiten`)
|
||||
return { bestellt: query.get('bestellt') === '1', behalten: query.get('behalten') === '1', projekt: query.get('projekt') || '' }
|
||||
}
|
||||
|
||||
// Vorauswahl wie bei der Kachel: aktiv, dann bestellt/in Einrichtung, dann abgeschaltet, sonst die erste
|
||||
function pickProjekt(projects) {
|
||||
return projects.find((p) => zustandVon(p) === 'active')
|
||||
|| projects.find((p) => istBestellt(zustandVon(p)))
|
||||
|| projects.find((p) => zustandVon(p) === 'disabled')
|
||||
|| projects[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben,
|
||||
* und nur ohne Abo (behaltenLage 'bezahlen': inklusive false UND grund kein_abo); laeuft das Abo (nicht_aktiv, gekuendigt, inklusive), gibt es nichts zu bezahlen.
|
||||
*/
|
||||
function behaltenMoeglich(projekt, angebot) {
|
||||
const feature = projekt?.features?.zeiten
|
||||
if (!feature || behaltenLage(angebot) !== 'bezahlen') return false
|
||||
if (feature.state === 'setup' || feature.state === 'active') return Boolean(feature.aboEndeAt)
|
||||
return feature.state === 'disabled' && feature.disabledReason === 'abo_ende' && projekt.zeitenLeer === false
|
||||
}
|
||||
|
||||
export async function initZeiten(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
const rueckkehr = leseRueckkehr()
|
||||
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
||||
const wunschWeg = root.dataset.wunschWeg === 'behalten' ? 'behalten' : ''
|
||||
delete root.dataset.wunschProjekt
|
||||
delete root.dataset.wunschWeg
|
||||
if (wunsch) auswahl.projectId = wunsch
|
||||
const antwort = await request('/api/funktionen')
|
||||
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
||||
const s = {
|
||||
root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '',
|
||||
projekt: null, lauf: 0, buehne: null, danke: '', editorAlt: null,
|
||||
}
|
||||
// der Editorplatz der bisherigen Ansicht (mit seinem Editor) wird in den neuen Aufbau mitgenommen, ausser nach der Rueckkehr von Stripe
|
||||
const alterEditor = !rueckkehr.bestellt && !rueckkehr.behalten && S?.root === root ? root.querySelector('.zt-editor') : null
|
||||
if (!s.angebot?.verfuegbar) {
|
||||
S = s
|
||||
root.dataset.zeitenKey = ''
|
||||
root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
|
||||
return
|
||||
}
|
||||
if (!projects.length) {
|
||||
S = s
|
||||
root.dataset.zeitenKey = ''
|
||||
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
|
||||
return
|
||||
}
|
||||
s.projekt = projects.find((p) => p.projectId === auswahl.projectId) || pickProjekt(projects)
|
||||
auswahl.projectId = s.projekt.projectId
|
||||
|
||||
// Nicht neu aufbauen, solange Projekt und Stand gleich sind und der Editorplatz steht: ungespeicherte Eingaben bleiben (Muster initNews).
|
||||
// Ein Weg (Behalten aus der Kachel) oder die Rueckkehr von Stripe zeichnen immer neu; der Wechsel in die Behalten-Seite behaelt den Editor.
|
||||
const key = stand(s.projekt, s.angebot)
|
||||
const editorDa = root.dataset.zeitenKey === key && root.querySelector('.zt-editor') && S?.root === root
|
||||
if (editorDa && !rueckkehr.bestellt && !rueckkehr.behalten) {
|
||||
if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt, S.angebot)) zeigeBehalten(S)
|
||||
return
|
||||
}
|
||||
S = s
|
||||
s.editorAlt = alterEditor
|
||||
s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt'
|
||||
: rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : ''
|
||||
s.buehne = h('div', { id: 'zeiten-buehne' })
|
||||
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
|
||||
zeichneProjekt(s, { weg: wunschWeg })
|
||||
}
|
||||
|
||||
/** Alles, was die Statuskarte zeigt und der Editorplatz davon abhaengt (auch die Abo-Lage: Behalten gibt es nur ohne Abo); aendert es sich, wird neu aufgebaut. */
|
||||
function stand(projekt, angebot) {
|
||||
const f = projekt?.features?.zeiten || {}
|
||||
return [projekt?.projectId, f.state, f.aboEndeAt, f.einbau, f.ticketWoid, f.disabledReason, f.unlockedBy, projekt?.zeitenLeer, angebot?.inklusive, angebot?.grund].join(':')
|
||||
}
|
||||
|
||||
function projektwahl(s) {
|
||||
const select = h('select', { id: 'zeiten-project' }, s.projects.map((p) => {
|
||||
const option = h('option', { value: p.projectId }, p.projectName)
|
||||
option.selected = p.projectId === s.projekt.projectId
|
||||
return option
|
||||
}))
|
||||
select.addEventListener('change', () => {
|
||||
auswahl.projectId = select.value
|
||||
s.projekt = s.projects.find((p) => p.projectId === select.value)
|
||||
s.danke = '' // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
|
||||
zeichneProjekt(s)
|
||||
})
|
||||
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'zeiten-project' }, 'Website'), select)
|
||||
}
|
||||
|
||||
/** Liste der Funktionen neu holen (Projekte, Angebot, Register-Ansicht). Wirft bei einem Fehler und aendert dann nichts. */
|
||||
async function ladeListe(s) {
|
||||
const antwort = await request('/api/funktionen')
|
||||
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
||||
s.projects = projects
|
||||
s.angebot = antwort.zeiten || null
|
||||
s.features = antwort.features || {}
|
||||
s.ustHinweis = typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : ''
|
||||
s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem Konflikt (409) oder dem Einschalten: Liste frisch holen und die Ansicht nach dem neuen Stand zeichnen (optionen: meldung des Servers,
|
||||
* danke). Scheitert das Laden, wirft die Funktion und die Seite bleibt mit ihrer Meldung stehen. Eine Antwort, die erst nach einem Wechsel der
|
||||
* Website eintrifft, wird verworfen.
|
||||
*/
|
||||
async function neuZeichnen(s, optionen = {}) {
|
||||
const lauf = s.lauf
|
||||
await ladeListe(s)
|
||||
if (S !== s || s.lauf !== lauf) return
|
||||
if (!s.angebot?.verfuegbar) {
|
||||
s.root.dataset.zeitenKey = ''
|
||||
s.root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
|
||||
return
|
||||
}
|
||||
if (optionen.danke) s.danke = optionen.danke
|
||||
zeichneProjekt(s, { meldung: optionen.meldung })
|
||||
}
|
||||
|
||||
/** Kontext der Seiten in der Huelle (zeiten-bestellen.js): Projekt, Register-Ansicht, Kundenansicht und die Wege, die Ansicht nach dem Server neu zu zeichnen. */
|
||||
function baueCtx(s, projekt) {
|
||||
return {
|
||||
projectId: projekt.projectId,
|
||||
features: s.features,
|
||||
ustHinweis: s.ustHinweis,
|
||||
kundenansicht: kundenansicht(),
|
||||
neuZeichnen: (optionen) => neuZeichnen(s, optionen),
|
||||
fertig: ({ danke } = {}) => neuZeichnen(s, { danke }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Ansicht zum Zustand des gewaehlten Projekts zeichnen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch. */
|
||||
function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } = {}) {
|
||||
const projekt = s.projekt
|
||||
++s.lauf
|
||||
const ctx = baueCtx(s, projekt)
|
||||
const zustand = zustandVon(projekt)
|
||||
const feature = projekt.features?.zeiten || {}
|
||||
const danke = s.danke
|
||||
s.danke = '' // nur beim ersten Zeichnen nach der Rueckkehr
|
||||
s.root.dataset.zeitenKey = ''
|
||||
const kopf = h('div', { class: 'zt-kopf' })
|
||||
const teile = []
|
||||
const meldungEl = meldung ? h('p', { class: 'tm-meldung tm-meldung-fehler', role: 'alert', tabindex: '-1', 'data-meldung': 'ansicht' }, meldung) : null
|
||||
if (meldungEl) teile.push(meldungEl)
|
||||
teile.push(kopf)
|
||||
|
||||
// Der Editorplatz steht in setup, active und (mit Daten) disabled. Ein Platz derselben Website mit gleichem Schreibrecht bleibt samt Editor stehen
|
||||
// (ungespeicherte Eingaben gehen nicht verloren, der Editor laedt nicht noch einmal); sonst gibt es einen frischen.
|
||||
let editorPlatz = null
|
||||
let frisch = false
|
||||
if (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) {
|
||||
const vorhanden = s.editorAlt || s.buehne.querySelector('.zt-editor')
|
||||
if (vorhanden && vorhanden.dataset.projekt === projekt.projectId && (vorhanden.dataset.state === 'disabled') === (zustand === 'disabled')) {
|
||||
editorPlatz = vorhanden
|
||||
editorPlatz.dataset.state = zustand
|
||||
editorPlatz.hidden = false // die Behalten-Seite versteckt ihn; sie wird bei Bedarf unten wieder geoeffnet
|
||||
} else {
|
||||
editorPlatz = h('div', { class: 'zt-editor', 'data-state': zustand, 'data-projekt': projekt.projectId })
|
||||
frisch = true
|
||||
}
|
||||
teile.push(editorPlatz)
|
||||
}
|
||||
s.editorAlt = null
|
||||
s.buehne.replaceChildren(...teile)
|
||||
if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
|
||||
if (frisch) {
|
||||
// neuZeichnen gehoert zur jeweils aktuellen Sitzung (S): der Platz kann einen Neuaufbau der Ansicht ueberleben
|
||||
const editorCtx = { projectId: projekt.projectId, kundenansicht: ctx.kundenansicht, neuZeichnen: (optionen) => (S ? neuZeichnen(S, optionen) : undefined) }
|
||||
Promise.resolve(renderEditor(editorPlatz, editorCtx)).catch((err) => editorPlatz.replaceChildren(fehlerKarte(err?.message || 'Der Editor konnte nicht gezeichnet werden.')))
|
||||
}
|
||||
// die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere
|
||||
if (meldungEl) {
|
||||
meldungEl.focus({ preventScroll: true })
|
||||
meldungEl.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
const statusKarte = (zusatz = {}) => zeitenStatusKarte(
|
||||
{ projekt, feature, angebot: s.angebot, kundenansicht: ctx.kundenansicht, danke, ...zusatz },
|
||||
{
|
||||
behalten: () => zeigeBehalten(s),
|
||||
weiter: (fall) => zeichneProjekt(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung || '' }),
|
||||
})
|
||||
|
||||
if (zustand === 'off' || (zustand === 'ordered' && bestellformular)) {
|
||||
renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt })
|
||||
return
|
||||
}
|
||||
kopf.replaceChildren(statusKarte())
|
||||
if (weg === 'behalten' && behaltenMoeglich(projekt, s.angebot)) zeigeBehalten(s)
|
||||
}
|
||||
|
||||
/** Behalten-Seite (30 €) statt der Statuskarte; der Editor darunter bleibt, nur versteckt, damit nichts verloren geht. „Zurück“ zeigt wieder die Karte. */
|
||||
function zeigeBehalten(s) {
|
||||
const kopf = s.buehne.querySelector('.zt-kopf')
|
||||
if (!kopf) return
|
||||
const editor = s.buehne.querySelector('.zt-editor')
|
||||
const ctx = baueCtx(s, s.projekt)
|
||||
ctx.zurueck = () => {
|
||||
if (editor) editor.hidden = false
|
||||
kopf.replaceChildren(zeitenStatusKarte(
|
||||
{ projekt: s.projekt, feature: s.projekt.features?.zeiten, angebot: s.angebot, kundenansicht: ctx.kundenansicht },
|
||||
{ behalten: () => zeigeBehalten(s) }))
|
||||
}
|
||||
if (editor) editor.hidden = true
|
||||
renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt: s.projekt, modus: 'behalten' })
|
||||
}
|
||||
@@ -20,6 +20,11 @@ export function scriptTag(analytics, baseUrl) {
|
||||
return `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
|
||||
}
|
||||
|
||||
// Öffnungszeiten live: siteKey und Basis-URL werden vor dem Aufruf per Regex geprüft (injectZeiten), daher ohne Maskierung sicher.
|
||||
export function zeitenScriptTag(zeiten, baseUrl) {
|
||||
return `<script data-wk-zeiten src="${baseUrl}zeiten.js" data-site="${zeiten.siteKey}" defer></script>`;
|
||||
}
|
||||
|
||||
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
|
||||
function findSpans(s) {
|
||||
const spans = [];
|
||||
@@ -45,11 +50,16 @@ function findSpans(s) {
|
||||
return spans;
|
||||
}
|
||||
|
||||
const RE_MARKER = /^data-wk-[a-z]+$/;
|
||||
|
||||
// Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben.
|
||||
export function injectIntoHtml(buf, tag) {
|
||||
// Die Marke (Attribut des Tags, z. B. data-wk-zeiten) erkennt einen früher eingesetzten Tag; sie wird geprüft, weil sie in einen regulären Ausdruck eingeht.
|
||||
export function injectIntoHtml(buf, tag, marker = 'data-wk-analytics') {
|
||||
if (typeof marker !== 'string' || !RE_MARKER.test(marker)) throw new Error('Ungültiger Marker');
|
||||
const reOld = new RegExp(`\\b${marker}\\b`, 'i');
|
||||
let s = buf.toString('latin1');
|
||||
const spans = findSpans(s);
|
||||
const olds = spans.filter((x) => x.kind === 'script' && /\bdata-wk-analytics\b/i.test(s.slice(x.start, s.indexOf('>', x.start) + 1)));
|
||||
const olds = spans.filter((x) => x.kind === 'script' && reOld.test(s.slice(x.start, s.indexOf('>', x.start) + 1)));
|
||||
if (olds.length) {
|
||||
for (let i = olds.length - 1; i >= 0; i--) s = s.slice(0, olds[i].start) + (i === 0 ? tag : '') + s.slice(olds[i].end);
|
||||
return Buffer.from(s, 'latin1');
|
||||
@@ -74,6 +84,18 @@ async function* walk(fs, dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Setzt den Tag in alle HTML-Dateien unterhalb von dir ein (Dateien über 5 MB bleiben unberührt) und liefert die Anzahl.
|
||||
async function injectTagIntoDir(fs, dir, tag, marker, label, onLine) {
|
||||
let n = 0;
|
||||
for await (const file of walk(fs, dir)) {
|
||||
const rel = file.slice(dir.length + 1);
|
||||
if ((await fs.stat(file)).size > MAX_HTML_BYTES) { onLine?.(`${label}: ${rel} übersprungen (größer als 5 MB)`); continue; }
|
||||
await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag, marker));
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = nodeFs } = {}) {
|
||||
if (!analytics) return;
|
||||
if (!RE_SITE_KEY.test(analytics.siteKey || '')) throw new Error('Analyse: ungültiger siteKey');
|
||||
@@ -86,12 +108,15 @@ export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = no
|
||||
const page = withAddr.replace(/\{\{(company|address|email)\}\}/g, (_, k) => esc(p[k] ?? ''));
|
||||
await fs.writeFile(join(dir, PAGE), page, 'utf8');
|
||||
await fs.chmod(join(dir, PAGE), 0o644);
|
||||
let n = 0;
|
||||
for await (const file of walk(fs, dir)) {
|
||||
const rel = file.slice(dir.length + 1);
|
||||
if ((await fs.stat(file)).size > MAX_HTML_BYTES) { onLine?.(`Analyse: ${rel} übersprungen (größer als 5 MB)`); continue; }
|
||||
await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag));
|
||||
n++;
|
||||
}
|
||||
const n = await injectTagIntoDir(fs, dir, tag, 'data-wk-analytics', 'Analyse', onLine);
|
||||
onLine?.(`Analyse-Skript in ${n} HTML-Datei(en) eingesetzt`);
|
||||
}
|
||||
|
||||
// Öffnungszeiten live: nur der Skript-Tag, keine Datenschutzseite.
|
||||
export async function injectZeiten(dir, zeiten, { baseUrl, onLine, fs = nodeFs } = {}) {
|
||||
if (!zeiten) return;
|
||||
if (typeof zeiten.siteKey !== 'string' || !RE_SITE_KEY.test(zeiten.siteKey)) throw new Error('Zeiten: ungültiger siteKey');
|
||||
if (!isValidBase(baseUrl)) throw new Error('Zeiten: ungültige öffentliche Basis-URL');
|
||||
const n = await injectTagIntoDir(fs, dir, zeitenScriptTag(zeiten, baseUrl), 'data-wk-zeiten', 'Zeiten', onLine);
|
||||
onLine?.(`Zeiten-Skript in ${n} HTML-Datei(en) eingesetzt`);
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
export function loadConfig(env = process.env) {
|
||||
const analyticsPublicBase = env.ANALYTICS_PUBLIC_BASE || 'https://project.webklar.com/a/';
|
||||
if (!/^https:\/\/[^\s"'<>]+\/$/.test(analyticsPublicBase)) throw new Error('ANALYTICS_PUBLIC_BASE muss mit https:// beginnen und mit / enden');
|
||||
const zeitenPublicBase = env.ZEITEN_PUBLIC_BASE || 'https://project.webklar.com/embed/';
|
||||
if (!/^https:\/\/[^\s"'<>]+\/$/.test(zeitenPublicBase)) throw new Error('ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden');
|
||||
return {
|
||||
analyticsPublicBase,
|
||||
zeitenPublicBase,
|
||||
token: env.PREVIEW_RUNNER_TOKEN || '',
|
||||
giteaUrl: env.GITEA_URL || '',
|
||||
giteaToken: env.GITEA_TOKEN || '',
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ import { join, dirname, sep } from 'node:path';
|
||||
import { buildRunArgs } from './docker.js';
|
||||
import { detectFromDir } from './detect.js';
|
||||
import { readMemAvailable } from './server.js';
|
||||
import { injectAnalytics } from './analytics-inject.js';
|
||||
import { injectAnalytics, injectZeiten } from './analytics-inject.js';
|
||||
|
||||
const LOG_LINES = 300;
|
||||
const MAX_LINE = 2000;
|
||||
@@ -257,6 +257,8 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem
|
||||
await ensureIndexHtml(fs, fresh, job.displayName || job.sub, onLine);
|
||||
// Analyse (optional): auf dem .new-Ordner vor dem Tausch; Fehler brechen die Veröffentlichung ab, die alte Preview bleibt (V4).
|
||||
if (job.analytics) await injectAnalytics(fresh, job.analytics, { baseUrl: config.analyticsPublicBase, onLine, fs });
|
||||
// Öffnungszeiten live: gleiche Runde, gleiche Fehlerfolge (Job failed, alte Preview bleibt). Nie im Dev-Pfad.
|
||||
if (job.zeiten) await injectZeiten(fresh, job.zeiten, { baseUrl: config.zeitenPublicBase, onLine, fs });
|
||||
try { await fs.rename(target, old); } catch (e) { if (e.code !== 'ENOENT') throw e; hadOld = false; }
|
||||
try { await fs.rename(fresh, target); } catch (e) {
|
||||
if (hadOld) await fs.rename(old, target).catch((e2) => onLine?.(`Rollback fehlgeschlagen: ${e2.message}`));
|
||||
|
||||
@@ -64,10 +64,19 @@ export function validateJob(body) {
|
||||
}
|
||||
analytics = { siteKey: a.siteKey, color: a.color, privacy };
|
||||
}
|
||||
// Öffnungszeiten live: nur der siteKey, Zusatzfelder werden verworfen.
|
||||
let zeiten;
|
||||
if (body.zeiten !== undefined && body.zeiten !== null) {
|
||||
const z = body.zeiten;
|
||||
if (!z || typeof z !== 'object' || Array.isArray(z)) return { ok: false, error: 'Ungültige Zeiten-Angaben' };
|
||||
if (typeof z.siteKey !== 'string' || !RE_SITE_KEY.test(z.siteKey)) return { ok: false, error: 'Ungültiger Zeiten-Schlüssel' };
|
||||
zeiten = { siteKey: z.siteKey };
|
||||
}
|
||||
// Whitelist: keine ungeprüften Client-Felder im Job
|
||||
const job = { sub, repo, sha, branch, mode, env };
|
||||
if (index !== undefined) job.index = index;
|
||||
if (displayName !== undefined) job.displayName = displayName;
|
||||
if (analytics !== undefined) job.analytics = analytics;
|
||||
if (zeiten !== undefined) job.zeiten = zeiten;
|
||||
return { ok: true, job };
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { injectZeiten, zeitenScriptTag, injectIntoHtml, injectAnalytics } from '../src/analytics-inject.js';
|
||||
import { validateJob } from '../src/validate.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { createJobs } from '../src/jobs.js';
|
||||
|
||||
const BASE = 'https://project.webklar.com/embed/';
|
||||
const ABASE = 'https://project.webklar.com/a/';
|
||||
const KEY = 'abcdefghij234567';
|
||||
const Z = (o = {}) => ({ siteKey: KEY, ...o });
|
||||
const TAG = `<script data-wk-zeiten src="${BASE}zeiten.js" data-site="${KEY}" defer></script>`;
|
||||
const A = () => ({ siteKey: 'abcdef0123456789abcd', color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } });
|
||||
const dir = () => mkdtempSync(join(tmpdir(), 'zinj-'));
|
||||
const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); };
|
||||
const get = (d, f) => readFileSync(join(d, f), 'utf8');
|
||||
const count = (s, sub) => s.split(sub).length - 1;
|
||||
|
||||
test('zeitenScriptTag: genau die vereinbarte Form', () => {
|
||||
assert.equal(zeitenScriptTag(Z(), BASE), TAG);
|
||||
});
|
||||
|
||||
test('Tag steht vor </head>, node_modules/Punktordner übersprungen, kein Datenschutzseite', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
|
||||
put(d, 'sub/seite.htm', '<head></head><p>b</p>');
|
||||
put(d, 'node_modules/p/x.html', '<head></head>');
|
||||
put(d, '.hidden/x.html', '<head></head>');
|
||||
const lines = [];
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
|
||||
assert.equal(get(d, 'index.html'), `<html><head><title>x</title>${TAG}</head><body>a</body></html>`);
|
||||
assert.equal(get(d, 'sub/seite.htm'), `<head>${TAG}</head><p>b</p>`);
|
||||
assert.equal(get(d, 'node_modules/p/x.html'), '<head></head>');
|
||||
assert.equal(get(d, '.hidden/x.html'), '<head></head>');
|
||||
assert.deepEqual(lines, ['Zeiten-Skript in 2 HTML-Datei(en) eingesetzt']);
|
||||
});
|
||||
|
||||
test('zweimal einsetzen ergibt genau einen Tag', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.equal(count(get(d, 'index.html'), 'data-wk-zeiten'), 1);
|
||||
assert.equal(get(d, 'index.html'), `<head>${TAG}</head>`);
|
||||
});
|
||||
|
||||
test('alter Zeiten-Tag wird ersetzt', async () => {
|
||||
const d = dir();
|
||||
put(d, 'b.html', '<head><script data-wk-zeiten src="https://alt/zeiten.js" data-site="x" defer></script></head><body></body>');
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'b.html'), `<head>${TAG}</head><body></body>`);
|
||||
});
|
||||
|
||||
test('alter Tag im Kommentar wird nicht ersetzt', async () => {
|
||||
const d = dir();
|
||||
const old = '<script data-wk-zeiten src="https://alt/zeiten.js"></script>';
|
||||
put(d, 'a.html', `<head><!-- ${old} --></head>`);
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'a.html'), `<head><!-- ${old} -->${TAG}</head>`);
|
||||
});
|
||||
|
||||
test('ohne head: nach <body …>; ohne body: am Dateianfang', async () => {
|
||||
const d = dir();
|
||||
put(d, 'a.html', '<body class="x">hi</body>');
|
||||
put(d, 'b.html', '<p>nur text</p>');
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'a.html'), `<body class="x">${TAG}hi</body>`);
|
||||
assert.equal(get(d, 'b.html'), `${TAG}<p>nur text</p>`);
|
||||
});
|
||||
|
||||
test('Nicht-UTF8-Bytes bleiben erhalten', async () => {
|
||||
const d = dir();
|
||||
writeFileSync(join(d, 'l.html'), Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('</head>')]));
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.deepEqual(readFileSync(join(d, 'l.html')), Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('</head>')]));
|
||||
});
|
||||
|
||||
test('Datei > 5 MB wird übersprungen (Logzeile), genau 5 MB wird bearbeitet', async () => {
|
||||
const d = dir();
|
||||
const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('<head></head>', 0);
|
||||
const edge = Buffer.alloc(5 * 1024 * 1024, 0x20); edge.write('<head></head>', 0);
|
||||
writeFileSync(join(d, 'gross.html'), big);
|
||||
writeFileSync(join(d, 'grenze.html'), edge);
|
||||
const lines = [];
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
|
||||
assert.deepEqual(readFileSync(join(d, 'gross.html')), big);
|
||||
assert.ok(get(d, 'grenze.html').includes('data-wk-zeiten'));
|
||||
assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l)));
|
||||
});
|
||||
|
||||
test('Analyse und Zeiten nacheinander: je ein Tag, in beiden Reihenfolgen und mit Wiederholung', async () => {
|
||||
for (const order of ['az', 'za']) {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
|
||||
put(d, 'k.html', '<body>kein head</body>');
|
||||
for (const step of [...order, ...order]) {
|
||||
if (step === 'a') await injectAnalytics(d, A(), { baseUrl: ABASE });
|
||||
else await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
}
|
||||
for (const f of ['index.html', 'k.html']) {
|
||||
const s = get(d, f);
|
||||
assert.equal(count(s, 'data-wk-analytics'), 1, `${order} ${f} analytics`);
|
||||
assert.equal(count(s, 'data-wk-zeiten'), 1, `${order} ${f} zeiten`);
|
||||
}
|
||||
// Die Datenschutzseite schreibt jeder Analyse-Lauf neu; im echten Ablauf (Analyse, dann Zeiten) trägt auch sie beide Tags.
|
||||
const p = get(d, 'datenschutz-analyse.html');
|
||||
assert.equal(count(p, 'data-wk-analytics'), 1, `${order} Datenschutzseite analytics`);
|
||||
if (order === 'az') assert.equal(count(p, 'data-wk-zeiten'), 1, 'Datenschutzseite zeiten');
|
||||
assert.ok(get(d, 'index.html').includes(TAG));
|
||||
}
|
||||
});
|
||||
|
||||
test('nur Zeiten: keine datenschutz-analyse.html', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectZeiten(d, Z(), { baseUrl: BASE });
|
||||
assert.deepEqual(readdirSync(d), ['index.html']);
|
||||
});
|
||||
|
||||
test('ohne zeiten: nichts geändert', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectZeiten(d, undefined, { baseUrl: BASE });
|
||||
await injectZeiten(d, null, { baseUrl: BASE });
|
||||
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||
});
|
||||
|
||||
test('ungültige Eingaben werfen mit festem Text (siteKey, Basis)', async () => {
|
||||
const d = dir(); put(d, 'index.html', '<head></head>');
|
||||
for (const siteKey of ['ABC', 'x'.repeat(41), 'ABCDEFGHIJ234567', 'abcdefghij23456"', undefined, 5]) {
|
||||
await assert.rejects(injectZeiten(d, { siteKey }, { baseUrl: BASE }), { message: 'Zeiten: ungültiger siteKey' });
|
||||
}
|
||||
for (const baseUrl of ['http://x/embed/', 'https://x/embed', 'https://x/"><script>/', 'https://x/ a/', undefined, '']) {
|
||||
await assert.rejects(injectZeiten(d, Z(), { baseUrl }), { message: 'Zeiten: ungültige öffentliche Basis-URL' });
|
||||
}
|
||||
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||
});
|
||||
|
||||
test('injectIntoHtml lehnt fremde Marker ab, nimmt gültige an', () => {
|
||||
const buf = Buffer.from('<head></head>');
|
||||
for (const m of ['data-wk-', 'data-wk-A', 'data-x-zeiten', 'data-wk-zeiten"', 'data-wk-a b', 'x data-wk-zeiten', 'data-wk-zeiten\n', '', 'data-wk-a.b', 5, null]) {
|
||||
assert.throws(() => injectIntoHtml(buf, '<script></script>', m), /Marker/, String(m));
|
||||
}
|
||||
assert.equal(injectIntoHtml(buf, '<script data-wk-foo></script>', 'data-wk-foo').toString(), '<head><script data-wk-foo></script></head>');
|
||||
// Vorgabe bleibt die Analyse-Marke
|
||||
const old = Buffer.from('<head><script data-wk-analytics src="x"></script></head>');
|
||||
assert.equal(injectIntoHtml(old, '<b></b>').toString(), '<head><b></b></head>');
|
||||
});
|
||||
|
||||
test('validateJob: gültig, null, ungültiger Schlüssel, Zusatzfelder verworfen', () => {
|
||||
const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
|
||||
const ok = validateJob({ ...base, zeiten: { siteKey: KEY, extra: 1, url: 'https://x' } });
|
||||
assert.equal(ok.ok, true);
|
||||
assert.deepEqual(ok.job.zeiten, { siteKey: KEY });
|
||||
assert.equal(validateJob(base).ok, true);
|
||||
assert.equal('zeiten' in validateJob(base).job, false);
|
||||
const nul = validateJob({ ...base, zeiten: null });
|
||||
assert.equal(nul.ok, true); assert.equal('zeiten' in nul.job, false);
|
||||
const both = validateJob({ ...base, analytics: A(), zeiten: Z() });
|
||||
assert.equal(both.ok, true); assert.deepEqual(both.job.zeiten, { siteKey: KEY }); assert.equal(both.job.analytics.color, '#2b6cb0');
|
||||
for (const z of ['x', [], 5, true]) {
|
||||
const r = validateJob({ ...base, zeiten: z }); assert.equal(r.ok, false, JSON.stringify(z)); assert.equal(r.error, 'Ungültige Zeiten-Angaben');
|
||||
}
|
||||
for (const z of [{}, { siteKey: 'kurz' }, { siteKey: 'A'.repeat(20) }, { siteKey: 5 }, { siteKey: 'a'.repeat(41) }, { siteKey: `${KEY}"` }, { siteKey: `${KEY}\n` }]) {
|
||||
const r = validateJob({ ...base, zeiten: z }); assert.equal(r.ok, false, JSON.stringify(z)); assert.equal(r.error, 'Ungültiger Zeiten-Schlüssel');
|
||||
}
|
||||
});
|
||||
|
||||
test('loadConfig: zeitenPublicBase Vorgabe, Override, ungültig wirft', () => {
|
||||
assert.equal(loadConfig({}).zeitenPublicBase, BASE);
|
||||
assert.equal(loadConfig({ ZEITEN_PUBLIC_BASE: 'https://x.de/q/' }).zeitenPublicBase, 'https://x.de/q/');
|
||||
assert.throws(() => loadConfig({ ZEITEN_PUBLIC_BASE: 'http://x.de/q/' }), { message: 'ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden' });
|
||||
assert.throws(() => loadConfig({ ZEITEN_PUBLIC_BASE: 'https://x.de/q' }), /ZEITEN_PUBLIC_BASE/);
|
||||
assert.equal(loadConfig({}).analyticsPublicBase, ABASE);
|
||||
});
|
||||
|
||||
// ---- Integration über publish ----
|
||||
function setup(cfg = {}, fsOver = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'zinjjobs-'));
|
||||
const config = { giteaUrl: 'https://git.x', giteaToken: 't', previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, analyticsPublicBase: ABASE, zeitenPublicBase: BASE, ...cfg };
|
||||
mkdirSync(config.previewsRoot, { recursive: true });
|
||||
const git = async ({ dir: d }) => { put(d, 'index.html', '<html><head></head><body>NEU</body></html>'); put(d, 'x/y.html', '<p>y</p>'); };
|
||||
const jobs = createJobs({ config, docker: { run: async () => ({ code: 0 }) }, git, mem: () => 1e10, fs: { chownR: async () => {}, ...fsOver } });
|
||||
return { config, jobs };
|
||||
}
|
||||
const job = (o = {}) => ({ sub: 'demo', repo: 'own/rep', sha: 'abcdef1', branch: 'main', mode: 'auto', env: {}, ...o });
|
||||
const wait = async (jobs, id) => { for (let i = 0; i < 400; i++) { const r = jobs.get(id); if (r.state === 'done' || r.state === 'failed') return r; await new Promise((r2) => setTimeout(r2, 5)); } throw new Error('timeout'); };
|
||||
|
||||
test('publish: Job mit zeiten setzt das Tag im veröffentlichten Ordner, ohne Datenschutzseite', async () => {
|
||||
const s = setup();
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||
assert.equal(r.state, 'done');
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
assert.ok(get(t, 'index.html').includes(TAG));
|
||||
assert.ok(get(t, 'x/y.html').startsWith(TAG));
|
||||
assert.ok(!get(t, 'index.html').includes('data-wk-analytics'));
|
||||
assert.ok(!existsSync(join(t, 'datenschutz-analyse.html')));
|
||||
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||
});
|
||||
|
||||
test('publish: analytics und zeiten in einem Job: je ein Tag', async () => {
|
||||
const s = setup();
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A(), zeiten: Z() })));
|
||||
assert.equal(r.state, 'done');
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
for (const f of ['index.html', 'x/y.html']) {
|
||||
const c = get(t, f);
|
||||
assert.equal(count(c, 'data-wk-analytics'), 1, f);
|
||||
assert.equal(count(c, 'data-wk-zeiten'), 1, f);
|
||||
assert.ok(c.includes(TAG), f);
|
||||
}
|
||||
assert.ok(existsSync(join(t, 'datenschutz-analyse.html')));
|
||||
});
|
||||
|
||||
test('publish: Job ohne zeiten bleibt unverändert', async () => {
|
||||
const s = setup();
|
||||
const r = await wait(s.jobs, s.jobs.submit(job()));
|
||||
assert.equal(r.state, 'done');
|
||||
assert.equal(get(join(s.config.previewsRoot, 'demo'), 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||
});
|
||||
|
||||
test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt', async () => {
|
||||
const s = setup({}, { readFile: async () => { throw new Error('kaputt'); } });
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
mkdirSync(t); writeFileSync(join(t, 'index.html'), 'ALT');
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||
assert.equal(r.state, 'failed');
|
||||
assert.equal(get(t, 'index.html'), 'ALT');
|
||||
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||
});
|
||||
|
||||
test('publish: ungültige Basis-URL in der Konfiguration ⇒ failed, alter Ordner bleibt', async () => {
|
||||
const s = setup({ zeitenPublicBase: 'http://kaputt/' });
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
mkdirSync(t); writeFileSync(join(t, 'index.html'), 'ALT');
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() })));
|
||||
assert.equal(r.state, 'failed');
|
||||
assert.equal(get(t, 'index.html'), 'ALT');
|
||||
});
|
||||
|
||||
test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => {
|
||||
const s = setup();
|
||||
const { workDir } = await s.jobs.prepareWorkspace(job({ zeiten: Z() }), { workDir: join(s.config.workRoot, 'dev', 'x') });
|
||||
assert.equal(get(workDir, 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner- und Webshop-Einrichtung als Einmalpreise): legt fehlende
|
||||
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-, Terminplaner-, Webshop- und Oeffnungszeiten-Einrichtung als Einmalpreise): legt fehlende
|
||||
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
|
||||
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
||||
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
|
||||
@@ -17,7 +17,7 @@ import { config } from '../server/config.js'
|
||||
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
|
||||
import { PRODUCT_KEYS } from '../server/services/products.js'
|
||||
|
||||
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup']
|
||||
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup']
|
||||
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
|
||||
|
||||
export function parseArgs(argv) {
|
||||
@@ -32,6 +32,7 @@ export function catalogEntry(key) {
|
||||
if (key === 'newsSetup') return config.newsSetup
|
||||
if (key === 'termineSetup') return config.termineSetup
|
||||
if (key === 'shopSetup') return config.shopSetup
|
||||
if (key === 'zeitenSetup') return config.zeitenSetup
|
||||
return config.products[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Oeffnungszeiten live: legt die Collections oeffnungszeiten und zeitenErinnerungen mit Attributen und Indizes in
|
||||
* Appwrite an (nur Server-Key, keine Client-Rechte) und ergaenzt portalFeatures um die Felder unlockedBy,
|
||||
* aboEndeAt, einbau, behaltenSessionId und einschaltMailAt (Abo-Freischaltung, Behalten, Selbstheilung). Das Schema steht in
|
||||
* server/services/zeiten/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes
|
||||
* auch in schon vorhandenen Collections nachgezogen. portalFeatures selbst legt setup-abo-collections.mjs an
|
||||
* (fehlt sie, bricht das Skript vor jeder Aenderung ab). Vor dem Einschalten (ZEITEN_ENABLED) und vor dem
|
||||
* Code-Deploy ausfuehren: der Code schreibt die neuen Felder, ohne Schema antwortet Appwrite mit "Unknown attribute".
|
||||
* Usage:
|
||||
* node scripts/zeiten-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem)
|
||||
* node scripts/zeiten-setup.mjs (Fehlendes anlegen)
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { config } from '../server/config.js'
|
||||
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js'
|
||||
import { createAppwriteClient } from './analyse-setup.mjs'
|
||||
|
||||
export async function main({ check = false, client, log = console.log } = {}) {
|
||||
const api = client || createAppwriteClient()
|
||||
const missing = []
|
||||
const missingCollections = []
|
||||
const missingIndexes = []
|
||||
const plan = []
|
||||
|
||||
// Eigene Collections plus die vorhandene portalFeatures (nur zusaetzliche Attribute, keine Indizes)
|
||||
const targets = [
|
||||
...Object.entries(ZEITEN_COLLECTIONS).map(([name, { attributes, indexes }]) => ({ id: config.collections[name], attributes, indexes, own: true })),
|
||||
{ id: config.collections.portalFeatures, attributes: ZEITEN_FEATURE_ATTRIBUTES, indexes: [], own: false },
|
||||
]
|
||||
|
||||
// Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert
|
||||
for (const { id, attributes, indexes, own } of targets) {
|
||||
const exists = await api.collectionExists(id)
|
||||
if (!exists && !own) throw new Error(`Collection ${id} (portalFeatures) fehlt – zuerst scripts/setup-abo-collections.mjs ausfuehren`)
|
||||
const haveAttributes = exists ? new Set((await api.listAttributes(id)).map((a) => a.key)) : new Set()
|
||||
const haveIndexes = exists ? new Set((await api.listIndexes(id)).map((i) => i.key)) : new Set()
|
||||
const absentAttributes = attributes.filter((a) => !haveAttributes.has(a.key))
|
||||
const absentIndexes = indexes.filter((i) => !haveIndexes.has(i.key))
|
||||
|
||||
if (!exists) {
|
||||
log(`${check ? '-' : '+'} Collection ${id}${check ? ' FEHLT' : ''}`)
|
||||
missingCollections.push(id)
|
||||
} else {
|
||||
log(`= ${id}: ${haveAttributes.size} Attribute, ${haveIndexes.size} Indizes vorhanden`)
|
||||
}
|
||||
if (check) {
|
||||
absentAttributes.forEach((a) => log(`- ${id}.${a.key} FEHLT`))
|
||||
absentIndexes.forEach((i) => log(`- Index ${id}.${i.key} FEHLT`))
|
||||
}
|
||||
absentAttributes.forEach((a) => missing.push({ ...a, collection: id }))
|
||||
absentIndexes.forEach((i) => missingIndexes.push({ ...i, collection: id }))
|
||||
plan.push({ id, exists, absentAttributes, absentIndexes })
|
||||
}
|
||||
|
||||
const result = { missing, missingCollections, missingIndexes }
|
||||
if (check) {
|
||||
const open = missing.length + missingCollections.length + missingIndexes.length
|
||||
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
|
||||
return result
|
||||
}
|
||||
|
||||
for (const { id, exists, absentAttributes } of plan) {
|
||||
if (!exists) await api.ensureCollection(id, id)
|
||||
for (const attr of absentAttributes) {
|
||||
await api.createAttribute(id, attr)
|
||||
log(`+ Attribut ${id}.${attr.key}`)
|
||||
}
|
||||
}
|
||||
// Indizes brauchen verfuegbare Attribute: erst warten, dann anlegen
|
||||
for (const { id, absentAttributes } of plan) {
|
||||
for (const attr of absentAttributes) await api.waitForAttribute(id, attr.key)
|
||||
}
|
||||
for (const { id, absentIndexes } of plan) {
|
||||
for (const index of absentIndexes) {
|
||||
await api.createIndex(id, index)
|
||||
log(`+ Index ${id}.${index.key}`)
|
||||
}
|
||||
}
|
||||
log('Fertig.')
|
||||
return result
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (invokedDirectly) {
|
||||
const check = process.argv.includes('--check')
|
||||
main({ check })
|
||||
.then(({ missing, missingCollections, missingIndexes }) =>
|
||||
process.exit(check && (missing.length || missingCollections.length || missingIndexes.length) ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,16 @@ function nonNegativeNumber(raw, fallback) {
|
||||
|
||||
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
|
||||
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
|
||||
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
|
||||
|
||||
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
|
||||
function commaList(raw, fallback) {
|
||||
const list = String(raw || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return list.length ? list : fallback
|
||||
}
|
||||
|
||||
// Abo-Produkte (Spec 2026-09-27 "Verträge, Hosting-Abo und E-Mail-Zusatzspeicher"). Preise in Cent,
|
||||
// Endpreise gemäß § 19 UStG. Texte, die Kunden sehen, stimmen mit den Vertragsbedingungen überein.
|
||||
@@ -127,6 +137,16 @@ const shopSetup = {
|
||||
priceNickname: 'Webshop-Einrichtung 299 €',
|
||||
}
|
||||
|
||||
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
|
||||
const zeitenSetup = {
|
||||
key: 'zeitenSetup',
|
||||
name: 'Öffnungszeiten live – Einrichtung',
|
||||
amountCents: 3000,
|
||||
lookupKey: 'webklar_zeiten_einrichtung_3000',
|
||||
oneTime: true,
|
||||
priceNickname: 'Öffnungszeiten-Einrichtung 30 €',
|
||||
}
|
||||
|
||||
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
|
||||
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
|
||||
|
||||
@@ -490,6 +510,74 @@ const features = {
|
||||
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
|
||||
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
|
||||
},
|
||||
// Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei)
|
||||
zeiten: {
|
||||
key: 'zeiten',
|
||||
product: 'zeitenSetup',
|
||||
label: { name: 'Öffnungszeiten live', kurz: 'Zeiten', keine: 'keine Öffnungszeiten live' },
|
||||
priceLabel: '30 €',
|
||||
setupEstimate: 'meist innerhalb weniger Tage',
|
||||
// Wortlaut der Zustimmung (bezahlter Weg); wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB,
|
||||
// Wertersatz § 357a Abs. 2 BGB; der Wertersatz-Satz steht wörtlich wie bei News und Terminplaner, Entwurf bis zur Rechtsrunde, ZQ3).
|
||||
// Fassung 2026-10-05: mit dem Wertersatz-Satz; bestehende Bestellungen behalten Text und Fassung aus ihrem Nachweis.
|
||||
consent: {
|
||||
version: '2026-10-05',
|
||||
knopf: 'Zahlungspflichtig bestellen · 30 €',
|
||||
items: [
|
||||
{
|
||||
id: 'leistung',
|
||||
haken: true,
|
||||
text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und bestelle die Einrichtung.',
|
||||
},
|
||||
{
|
||||
id: 'beginn',
|
||||
haken: true,
|
||||
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Zustimmung des Gratisweges (Wartungs-Abo laeuft): ein Haken, kein Widerrufsverlangen, kein Preis
|
||||
consentFrei: {
|
||||
version: '2026-10-04',
|
||||
knopf: 'Einschalten',
|
||||
items: [
|
||||
{
|
||||
id: 'leistung',
|
||||
haken: true,
|
||||
text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen und schalte die Funktion ein. Sie ist in meinem Wartungs-Abo ohne Aufpreis enthalten.',
|
||||
},
|
||||
],
|
||||
},
|
||||
leistungsbeschreibung: { version: '2026-10-04', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' },
|
||||
avv: null,
|
||||
widerruf: null,
|
||||
returnHash: 'zeiten',
|
||||
returnProjekt: true,
|
||||
ticket: {
|
||||
topic: (project) => `Öffnungszeiten live einbauen – ${ticketProjektName(project)}`.trim(),
|
||||
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
|
||||
details: async (ctx) => (await import('./services/zeiten/ticketDetails.js')).zeitenTicketDetails(ctx),
|
||||
},
|
||||
mails: {
|
||||
funktion: 'Öffnungszeiten live',
|
||||
formen: { dein: 'Deine Öffnungszeiten live', nom: 'Öffnungszeiten live', akk: 'Öffnungszeiten live', gen: 'von Öffnungszeiten live', pron: 'sie' },
|
||||
knopf: 'Zeiten bearbeiten',
|
||||
danach: 'Danach kannst Du Deine Zeiten im Portal eintragen.',
|
||||
bereit: 'Deine Öffnungszeiten stehen ab sofort live auf Deiner Seite.',
|
||||
preisHinweis: 'einmalig, ohne Umsatzsteuer nach § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo',
|
||||
anleitung: false,
|
||||
},
|
||||
requiresWartungsAbo: false,
|
||||
// Im Wartungs-Abo ohne Aufpreis enthalten (Gratisweg POST /api/funktionen/zeiten/einschalten)
|
||||
aboInklusive: true,
|
||||
ticketFreischaltung: true,
|
||||
// Die oeffentliche Schnittstelle liefert in diesen Zustaenden dasselbe (Ersatz-Tabelle bis zur Freischaltung)
|
||||
teilweiseLive: ['setup', 'active'],
|
||||
disabledMessage:
|
||||
'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.',
|
||||
orderedConflictMessage: 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.',
|
||||
unavailableMessage: 'Öffnungszeiten live ist noch nicht verfügbar.',
|
||||
},
|
||||
}
|
||||
|
||||
export const config = {
|
||||
@@ -543,6 +631,9 @@ export const config = {
|
||||
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
|
||||
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
|
||||
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
|
||||
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
|
||||
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
|
||||
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
@@ -560,6 +651,7 @@ export const config = {
|
||||
newsSetup,
|
||||
termineSetup,
|
||||
shopSetup,
|
||||
zeitenSetup,
|
||||
ustHinweis,
|
||||
// Register der kaufbaren Funktionen (siehe oben)
|
||||
features,
|
||||
@@ -823,6 +915,18 @@ export const config = {
|
||||
unbezahltLoeschTage: 30,
|
||||
},
|
||||
|
||||
// Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt
|
||||
// config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js
|
||||
zeiten: {
|
||||
// off (Vorgabe) | pilot (nur ZEITEN_PILOT_EMAILS) | on (alle Kunden)
|
||||
mode: ['pilot', 'on'].includes(zeitenEnabled) ? zeitenEnabled : 'off',
|
||||
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
|
||||
pilotEmails: commaList(process.env.ZEITEN_PILOT_EMAILS, []),
|
||||
// Subdomains, fuer die der Neubau nie angestossen wird (Einbau von Hand); ZEITEN_KEIN_NEUBAU ist eine
|
||||
// Komma-Liste, die Vorgabe ist emmas-schatzkiste
|
||||
keinNeubau: commaList(process.env.ZEITEN_KEIN_NEUBAU, ['emmas-schatzkiste']),
|
||||
},
|
||||
|
||||
invoiceNinja: {
|
||||
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
|
||||
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
|
||||
|
||||
@@ -20,6 +20,7 @@ import termineRoutes from './routes/termine.js'
|
||||
import shopRoutes from './routes/shop.js'
|
||||
import shopWebhookRoutes from './routes/shopWebhook.js'
|
||||
import { shopSeiteRouter } from './routes/shopSeite.js'
|
||||
import zeitenRoutes from './routes/zeiten.js'
|
||||
import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
||||
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
@@ -29,6 +30,7 @@ import { runActivation } from './services/siteFeatures.js'
|
||||
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
||||
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
|
||||
import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js'
|
||||
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
@@ -42,6 +44,7 @@ import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import publicTermineRoutes from './routes/publicTermine.js'
|
||||
import publicShopRoutes from './routes/publicShop.js'
|
||||
import publicZeitenRoutes from './routes/publicZeiten.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import { legalRouter } from './services/legalDocs.js'
|
||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||
@@ -79,6 +82,7 @@ app.use('/api/auth', authRoutes)
|
||||
app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api/public/news', publicNewsRoutes)
|
||||
app.use('/api/public/zeiten/v1', publicZeitenRoutes)
|
||||
app.use('/api/news', newsRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
@@ -89,6 +93,7 @@ app.use('/api/termine', termineRoutes)
|
||||
// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers
|
||||
app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
|
||||
app.use('/api/shop', shopRoutes)
|
||||
app.use('/api/zeiten', zeitenRoutes)
|
||||
app.use('/api/team', teamRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminPreviewRoutes)
|
||||
@@ -255,6 +260,29 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
setInterval(shopTages, 24 * 60 * 60 * 1000).unref()
|
||||
}, 200 * 1000).unref()
|
||||
|
||||
// Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED,
|
||||
// ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei
|
||||
// Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und
|
||||
// werfen nie; hier faengt der Rahmen trotzdem alles Unerwartete ab. Im Log stehen keine Kundendaten.
|
||||
const zeitenAbo = () =>
|
||||
zeitenAboJob(new Date())
|
||||
.then((r) => {
|
||||
if (r.aboEnde || r.abgeschaltet || r.wiederEin || r.geloescht || r.mails || r.fehler) {
|
||||
console.log(`[zeiten] Abo-Abgleich: ${r.aboEnde ?? 0} Abo-Ende erkannt, ${r.abgeschaltet ?? 0} abgeschaltet, ${r.wiederEin ?? 0} wieder eingeschaltet, ${r.geloescht ?? 0} gelöscht, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn('[zeiten] Abo-Abgleich fehlgeschlagen:', err.message))
|
||||
setTimeout(zeitenAbo, 240 * 1000).unref()
|
||||
setInterval(zeitenAbo, 24 * 60 * 60 * 1000).unref()
|
||||
const zeitenErinnerung = () =>
|
||||
zeitenErinnerungsJob(new Date())
|
||||
.then((r) => {
|
||||
if (r.merker || r.mails || r.fehler) console.log(`[zeiten] Feiertags-Erinnerung: ${r.merker ?? 0} Tag(e) vorgemerkt, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`)
|
||||
})
|
||||
.catch((err) => console.warn('[zeiten] Feiertags-Erinnerung fehlgeschlagen:', err.message))
|
||||
setTimeout(zeitenErinnerung, 300 * 1000).unref()
|
||||
setInterval(zeitenErinnerung, 60 * 60 * 1000).unref()
|
||||
|
||||
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
|
||||
const repoAssign = () =>
|
||||
runRepoAssignJob({ now: new Date() }).catch((err) =>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}, {{fassung}}, {{lb_url}}, {{avv_url}}, {{widerruf_url}}, {{zustimmungen_text}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, anlagen, anhang, zustimmungen
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu, anlagen, anhang, zustimmungen
|
||||
funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen).
|
||||
preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis).
|
||||
danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal.
|
||||
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
|
||||
behalten = der Kunde behält die Funktion nach dem Ende seines Wartungs-Abos für einmalig 30 € (Öffnungszeiten live): nichts wird neu eingerichtet. neu = der Normalfall, die Einrichtung läuft jetzt los; genau einer der beiden ist an (bestellMail setzt neu = nicht behalten).
|
||||
anlagen = Vertragsunterlagen der Funktion (nur Funktionen mit Rechtsdokumenten): fassung = Fassung der Texte, lb_url/avv_url/widerruf_url = versionierte Adressen von Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular. anhang (innerhalb von anlagen) = die drei PDFs hängen an der Mail (dauerhafter Datenträger); fehlt der Anhang, entfällt der Satz.
|
||||
zustimmungen = Wortlaut und Zeitpunkt der erteilten Zustimmungen (zustimmungen_text, mehrzeilig) aus dem Nachweis zur bezahlten Bestellung.
|
||||
-->
|
||||
@@ -40,9 +41,9 @@ a{color:#111111;}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">✓ Bestellung eingegangen</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: {{funktion}}</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}</div></td></tr></table></td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung.<!-- WENN neu --> Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.<!-- ENDE neu --><!-- WENN behalten --> Du behältst {{funktion_akk}} für Dein Projekt, auch ohne Wartungs-Abo.<!-- ENDE behalten --></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong><!-- WENN neu --> – Einrichtung<!-- ENDE neu --><!-- WENN behalten --> – behalten<!-- ENDE behalten --></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><!-- WENN neu --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr><!-- ENDE neu --></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;"><!-- WENN neu -->Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}<!-- ENDE neu --><!-- WENN behalten -->Deine Einstellungen und Daten bleiben unverändert, und es wird nichts neu eingebaut. Du musst nichts weiter tun.<!-- ENDE behalten --></div></td></tr></table></td></tr>
|
||||
<!-- WENN anlagen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Vertragsunterlagen</div><div class="mut" style="color:#4a4843;">Fassung {{fassung}}: <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung</a>, <a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag</a> und <a href="{{widerruf_url}}" style="color:#111111;">Widerrufsbelehrung mit Muster-Widerrufsformular</a>.<!-- WENN anhang --> Alle drei hängen als PDF an dieser E-Mail. Bitte bewahre sie auf.<!-- ENDE anhang --></div></td></tr></table></td></tr>
|
||||
<!-- ENDE anlagen -->
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K27 · Zeiten eingeschaltet
|
||||
Betreff: Öffnungszeiten live ist eingeschaltet
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{ticket}}, {{portal_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angestossen, manuell, wieder, weiter
|
||||
Geht an Kunden, die Öffnungszeiten live im Wartungs-Abo selbst einschalten (Gratisweg), und, mit dem Block wieder, wenn die Funktion nach einem Abo-Ende wieder eingeschaltet wird.
|
||||
Genau einer der Blöcke angestossen (der Neubau der Website läuft, die Leisten erscheinen nach dem nächsten Veröffentlichen) oder manuell (das Team baut ein) ist an; wieder kommt dazu, wenn das Wartungs-Abo zurückgekehrt ist.
|
||||
weiter umschließt den ganzen Kasten „Wie geht es weiter?“ und ist an, wenn angestossen oder manuell an ist. Kehrt das Abo zurück, bevor die Funktion abgeschaltet wurde, gab es keinen Neubau: dann sind angestossen und manuell aus, der Kasten entfällt und nur der Block wieder erklärt die Lage.
|
||||
ticket = Hinweis auf das Einbau-Ticket mit führendem Leerzeichen, zum Beispiel „ (Ticket #100123)“, oder leer.
|
||||
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Öffnungszeiten live ist eingeschaltet</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Öffnungszeiten live ist eingeschaltet. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Eingeschaltet</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">✓ Öffnungszeiten live</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Öffnungszeiten live ist eingeschaltet</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>Öffnungszeiten live ist für Dein Projekt {{projekt}} eingeschaltet. Die Funktion ist in Deinem Wartungs-Abo ohne Aufpreis enthalten.<!-- WENN wieder --> Dein Wartungs-Abo läuft wieder, deshalb haben wir sie automatisch und ohne Gebühr wieder eingeschaltet. Deine Zeiten und Einstellungen sind unverändert da.<!-- ENDE wieder --></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Öffnungszeiten live</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<!-- WENN weiter -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;"><!-- WENN angestossen -->Die Hinweis-Leiste und die Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen Deiner Website, meist in wenigen Minuten. Die Zeiten-Tabelle setzt unser Team zusätzlich per Ticket an die passende Stelle Deiner Seite{{ticket}}.<!-- ENDE angestossen --><!-- WENN manuell -->Deine Website können wir nicht automatisch aktualisieren. Deshalb baut unser Team die Hinweis-Leiste, die Kontakt-Leiste und die Zeiten-Tabelle von Hand in Deine Seite ein{{ticket}}.<!-- ENDE manuell --></div><div class="mut" style="color:#4a4843;padding-top:8px;">Im Kundenportal trägst Du Deine Öffnungszeiten ein und änderst sie jederzeit. Ohne gespeicherte Zeiten kann die Tabelle nicht eingebaut werden.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE weiter -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zeiten bearbeiten →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K28 · Zeiten Feiertage prüfen
|
||||
Betreff: Feiertage stehen an – stimmen Deine Zeiten?
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · ohne BCC
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{tage}}, {{portal_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): pruefen, mehrere
|
||||
Erinnerung 1 bis 7 Tage vor Feiertagen und „bitte prüfen“-Tagen (Heiligabend, Silvester, örtliche Feiertage), eine Mail für alle neuen Tage; Hinweis, die Zeiten auch bei Google einzutragen.
|
||||
tage = eine Zeile je Tag, zum Beispiel „Sa 03.10. – Tag der Deutschen Einheit“. pruefen = mindestens ein Tag ist ein „bitte prüfen“-Tag (Erklärung); mehrere = mehr als ein Tag.
|
||||
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Feiertage stehen an – stimmen Deine Zeiten?</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Stimmen Deine Zeiten an den nächsten Feiertagen? ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Erinnerung</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Öffnungszeiten live</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Stimmen Deine Zeiten an diesen Tagen?</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>In den nächsten Tagen weicht der Kalender vom Alltag ab. Für Dein Projekt {{projekt}} gelten an diesen Tagen die Zeiten, die Du im Kundenportal eingetragen hast.<!-- WENN mehrere --> Es sind mehrere Tage, bitte prüfe jeden einzelnen.<!-- ENDE mehrere --></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Betrifft</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{tage}}</td></tr></table></td></tr>
|
||||
<!-- WENN pruefen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bitte prüfen</div><div class="mut" style="color:#4a4843;">Bei Tagen mit dem Zusatz „bitte prüfen“ gilt der Feiertag nur in manchen Orten oder ist kein gesetzlicher Feiertag (zum Beispiel Heiligabend und Silvester). Wir haben dort Deine normalen Zeiten stehen lassen. Bitte trag ein, ob Du an diesen Tagen geöffnet hast.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE pruefen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Auch bei Google eintragen</div><div class="mut" style="color:#4a4843;">Dein Google-Unternehmensprofil ändert sich nicht von allein. Trag abweichende Zeiten dort bitte ebenfalls ein; im Kundenportal kannst Du Dir Deine Zeiten dafür zum Kopieren ausgeben lassen.</div></td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zeiten prüfen →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K29 · Zeiten Abo beendet
|
||||
Betreff: Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{frist}}, {{preis}}, {{portal_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): abo_ende, hosting, wechsel_widerrufen
|
||||
Geht an Kunden, die Öffnungszeiten live im Wartungs-Abo eingeschaltet hatten, wenn das Abo endet: Die Funktion läuft noch bis zur Frist, danach schalten wir sie ab, außer sie wird für einmalig 30 € behalten.
|
||||
Genau einer der Gründe abo_ende (Wartungs-Abo beendet), hosting (das Abo ist ein Hosting-Abo) oder wechsel_widerrufen (Wechsel ins Wartungs-Abo widerrufen) ist an.
|
||||
frist = Tag, an dem wir abschalten (30 Tage nach dem erkannten Abo-Ende), z. B. „04.11.2026“; preis = Preis zum Behalten bzw. Wiedereinschalten, z. B. „30 €“.
|
||||
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Öffnungszeiten live läuft noch bis {{frist}}. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Abo beendet</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Öffnungszeiten live</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Möchtest Du Öffnungszeiten live behalten?</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN abo_ende -->Dein Wartungs-Abo ist beendet. Öffnungszeiten live war darin ohne Aufpreis enthalten und endet deshalb mit dem Abo.<!-- ENDE abo_ende --><!-- WENN hosting -->Dein Abo ist ein Hosting-Abo. Öffnungszeiten live war im Wartungs-Abo ohne Aufpreis enthalten; ein Hosting-Abo reicht dafür nicht.<!-- ENDE hosting --><!-- WENN wechsel_widerrufen -->Du hast den Wechsel in das Wartungs-Abo widerrufen, Dein Abo bleibt ein Hosting-Abo. Öffnungszeiten live war im Wartungs-Abo ohne Aufpreis enthalten; ein Hosting-Abo reicht dafür nicht.<!-- ENDE wechsel_widerrufen --> Die Funktion läuft noch bis zum {{frist}} unverändert weiter. Wenn Du sie behalten möchtest, geht das für einmalig {{preis}}.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Öffnungszeiten live</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Läuft weiter bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{frist}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Behalten</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">einmalig {{preis}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Was jetzt gilt</div><div class="mut" style="color:#4a4843;">Bis zum {{frist}} bleibt die Funktion eingeschaltet, Deine Website zeigt Zeiten, Status und Leisten wie bisher.</div><div class="mut" style="color:#4a4843;padding-top:8px;"><strong>Behalten:</strong> Für einmalig {{preis}} behältst Du Öffnungszeiten live auch ohne Wartungs-Abo. Das geht im Kundenportal.</div><div class="mut" style="color:#4a4843;padding-top:8px;">Ohne Zahlung schalten wir die Funktion am {{frist}} ab. Dann stehen auf Deiner Website wieder feste Zeiten, und Deine Eingaben bleiben noch 30 Tage gespeichert.</div><div class="mut" style="color:#4a4843;padding-top:8px;"><strong>Rückkehr:</strong> Läuft wieder ein Wartungs-Abo, solange Deine Eingaben noch gespeichert sind, schalten wir die Funktion ohne Gebühr wieder ein.</div></td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Für {{preis}} behalten →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K30 · Zeiten abgeschaltet
|
||||
Betreff: Öffnungszeiten live ist abgeschaltet
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{loeschdatum}}, {{preis}}, {{portal_url}}
|
||||
Geht an Kunden, deren Wartungs-Abo geendet hat und die Öffnungszeiten live nicht behalten haben: Die Leisten sind weg, auf der Seite stehen wieder feste Zeiten; die Eingaben bleiben noch 30 Tage gespeichert, Wiedereinschalten für einmalig 30 €.
|
||||
loeschdatum = Tag, ab dem wir die Eingaben löschen (30 Tage nach dem Abschalten); preis = Preis zum Wiedereinschalten, z. B. „30 €“.
|
||||
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Öffnungszeiten live ist abgeschaltet</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Öffnungszeiten live ist abgeschaltet. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Abgeschaltet</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Öffnungszeiten live</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Öffnungszeiten live ist abgeschaltet</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>Öffnungszeiten live ist für Dein Projekt {{projekt}} abgeschaltet, weil das Wartungs-Abo beendet ist und die Frist zum Behalten abgelaufen ist. Die Hinweis-Leiste, die Kontakt-Leiste und der Live-Status sind von Deiner Website verschwunden; dort stehen ab jetzt wieder feste Zeiten.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Öffnungszeiten live</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingaben gespeichert bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{loeschdatum}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Was jetzt gilt</div><div class="mut" style="color:#4a4843;">Deine Eingaben (Zeiten, Hinweise und Einstellungen) bleiben bis zum {{loeschdatum}} gespeichert. Danach löschen wir sie.</div><div class="mut" style="color:#4a4843;padding-top:8px;"><strong>Wieder einschalten:</strong> Bis dahin kannst Du Öffnungszeiten live im Kundenportal für einmalig {{preis}} wieder einschalten. Läuft wieder ein Wartungs-Abo, schalten wir die Funktion automatisch und ohne Gebühr wieder ein.</div><div class="mut" style="color:#4a4843;padding-top:8px;">Nach der Löschung musst Du die Funktion neu einschalten und Deine Zeiten noch einmal eintragen.</div></td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,7 +8,7 @@
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, anhang_fehlt
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team, anhang_fehlt
|
||||
funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“.
|
||||
ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt.
|
||||
zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer.
|
||||
@@ -16,6 +16,8 @@
|
||||
bereits_aktiv = die Zahlung ist da, aber die Funktion war schon in dem Zustand {{zustand}}; es wurde kein Ticket angelegt und nichts eingerichtet (Einbau nachholen oder erstatten); sitzung nennt die Stripe-Sitzung.
|
||||
anleitung = Knopf „Im Ticketsystem öffnen“ zur Einbauanleitung des Projekts (anleitung_url); nur bei Funktionen mit Anleitung und angelegtem Ticket.
|
||||
gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs).
|
||||
abo = die Funktion wurde ohne Zahlung eingeschaltet, weil sie im Wartungs-Abo des Kunden enthalten ist (Gratisweg, der Kunde hat selbst eingeschaltet); es gibt keine Rechnung.
|
||||
team = das Team hat die Funktion ohne Zahlung freigeschaltet („Aktivieren ohne Zahlung“); es gibt keine Rechnung.
|
||||
anhang_fehlt = die Vertragsunterlagen (PDF) sind nicht beim Kunden angekommen (Anhang nicht lesbar, Mail nicht versandt oder Unterlagen einer anderen Fassung als bestellt); das Team sendet sie von Hand nach.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -55,6 +57,12 @@ a{color:#111111;}
|
||||
<!-- WENN bereits_aktiv -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bezahlt, aber schon vorhanden</div><div class="mut" style="color:#4a4843;">Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE bereits_aktiv -->
|
||||
<!-- WENN abo -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Im Wartungs-Abo enthalten</div><div class="mut" style="color:#4a4843;">Der Kunde hat die Funktion selbst eingeschaltet. Sie ist in seinem Wartungs-Abo ohne Aufpreis enthalten; es gab keine Zahlung und es gibt keine Rechnung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE abo -->
|
||||
<!-- WENN team -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Vom Team freigeschaltet</div><div class="mut" style="color:#4a4843;">Die Funktion wurde im Ticketsystem ohne Zahlung freigeschaltet. Es gab keine Zahlung und es gibt keine Rechnung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE team -->
|
||||
<!-- WENN gesundheit -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:16px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;">Gesundheitsbereich prüfen: {{gesundheit_text}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE gesundheit -->
|
||||
|
||||
@@ -34,6 +34,9 @@ export const KUNDENANSICHT_ALLOWED = [
|
||||
['PUT', /^\/api\/shop\/einstellungen\/?$/],
|
||||
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
|
||||
['POST', /^\/api\/shop\/bild\/?$/],
|
||||
// Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403).
|
||||
// Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt.
|
||||
['PUT', /^\/api\/zeiten\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
|
||||
['POST', /^\/api\/auth\/logout\/?$/],
|
||||
['POST', /^\/api\/auth\/login\/?$/],
|
||||
|
||||
@@ -727,15 +727,15 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']
|
||||
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop' }
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -810,6 +810,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
if (order === 'termine') return 'Terminplaner (Einrichtung)'
|
||||
if (order === 'shop') return 'Webshop (Einrichtung)'
|
||||
if (order === 'zeiten') return 'Öffnungszeiten live'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { config } from '../../config.js'
|
||||
import { aboMemo } from '../../services/aboStatus.js'
|
||||
import { validateTextform } from '../../services/analyse.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig } from '../../services/featureRegistry.js'
|
||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
|
||||
import {
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
textformFassung,
|
||||
ticketNachlegen,
|
||||
} from '../../services/siteFeatures.js'
|
||||
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
|
||||
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
|
||||
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
|
||||
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
|
||||
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
|
||||
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
|
||||
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
|
||||
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
|
||||
import { ladeStand } from '../../services/termine/kontext.js'
|
||||
@@ -60,6 +66,29 @@ async function readTicket(ticketId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusatzfelder der Zeiten fuer das WOMS-Panel (Spec 8): plaene [{ kurz, name, haupt }] (Hauptplan zuerst, sonst in gespeicherter
|
||||
* Reihenfolge; Kurznamen fuer data-plan und "Ersatz-HTML kopieren"), team und teamVersion (Team-Einstellungen mit ihrer Version fuer
|
||||
* PATCH .../zeiten/team) und hindernis: 'zeiten_fehlen', solange die Einrichtung (setup) auf Zeiten des Kunden wartet, sonst null.
|
||||
* Gelesen wird nur, wo es Daten geben kann (setup, active, disabled); sonst Vorgaben. hindernis fragt den Register-Haken freischaltbar
|
||||
* (dieselbe Regel wie der 5-Minuten-Job, fail-closed). Wirft bei Lesefehlern, wie der Rest der Ansicht.
|
||||
*/
|
||||
async function zeitenZusatz(doc) {
|
||||
const mitDaten = doc && (doc.state === 'setup' || doc.state === 'active' || doc.state === 'disabled')
|
||||
const gespeichert = mitDaten ? await ladeZeiten(doc.$id) : null
|
||||
const plaene = (Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : [])
|
||||
.filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '')
|
||||
.map((p) => ({ kurz: p.kurz, name: typeof p.name === 'string' ? p.name : '', haupt: p.haupt === true }))
|
||||
const { freischaltbar } = featureHaken('zeiten')
|
||||
const wartet = doc?.state === 'setup' && typeof freischaltbar === 'function' && (await freischaltbar(doc)) !== true
|
||||
return {
|
||||
plaene: [...plaene.filter((p) => p.haupt), ...plaene.filter((p) => !p.haupt)],
|
||||
team: gespeichert?.team ?? normalisiereTeam({}).team,
|
||||
teamVersion: gespeichert?.teamVersion ?? 0,
|
||||
hindernis: wartet ? 'zeiten_fehlen' : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */
|
||||
async function customerName(customerId) {
|
||||
try {
|
||||
@@ -138,6 +167,7 @@ async function adminView(customerId) {
|
||||
view.adminNote = doc?.adminNote || ''
|
||||
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
|
||||
view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null
|
||||
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
|
||||
}
|
||||
}
|
||||
return projects
|
||||
@@ -178,6 +208,35 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Ersatz-HTML (statische Wochentabelle) eines Plans von Oeffnungszeiten live fuer den Einbau (Spec 4.5): { html }.
|
||||
* Ohne Parameter plan gilt der Hauptplan; ist plan angegeben (auch leer), muss es diesen Kurznamen geben, sonst 404 und
|
||||
* kein Rueckfall auf den Hauptplan (Ruling R9). Noch keine gespeicherten Zeiten -> 409. Nur lesend, ohne Sperre.
|
||||
*/
|
||||
funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
|
||||
try {
|
||||
const { customerId } = req.params
|
||||
const { projectId, plan } = req.query
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` })
|
||||
const daten = (await ladeZeiten(doc.$id))?.daten
|
||||
const keineZeiten = () => res.status(409).json({ error: 'Der Kunde hat noch keine Zeiten eingetragen.' })
|
||||
if (!Array.isArray(daten?.plaene) || daten.plaene.length === 0) return keineZeiten()
|
||||
if (plan !== undefined) {
|
||||
const gewaehlt = planNachKurz(daten, plan)
|
||||
if (!gewaehlt) return res.status(404).json({ error: 'Plan nicht gefunden' })
|
||||
return res.json({ html: ersatzHtml(daten, gewaehlt) })
|
||||
}
|
||||
const haupt = hauptplan(daten)
|
||||
if (!haupt) return keineZeiten()
|
||||
res.json({ html: ersatzHtml(daten, haupt) })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur
|
||||
* ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein
|
||||
@@ -227,9 +286,42 @@ funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), disable, abnahme (Freischalten nach Abnahme, in
|
||||
// setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket nachlegen, in setup). abnahme und ticket gibt es nur fuer
|
||||
// Funktionen mit ticketFreischaltung im Register.
|
||||
/**
|
||||
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
|
||||
* und ohne Zustimmungsnachweis und K27: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
|
||||
* entsteht wie bei den anderen Funktionen (Zeit, Name, Aktion, Text).
|
||||
* Sperren: einschaltenZeiten nimmt fuer die Zustandsentscheidung selbst `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout)
|
||||
* und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` nicht
|
||||
* gehalten: sie wuerde dem Team (und dem Abo-Abgleich) fuer die Dauer von Neubau und Ticket jede Aktion an diesem Projekt sperren,
|
||||
* ohne etwas zu schuetzen, denn holeZeitenNach liest den Zustand frisch und laesst ein inzwischen abgeschaltetes Dokument unberuehrt.
|
||||
*/
|
||||
async function zeitenAktivieren({ customerId, projectId, by, note }) {
|
||||
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
const adminNote = composeAdminNote(bisher?.adminNote, { by, action: 'activate', note })
|
||||
await einschaltenZeiten({ customerId, projectId, by: 'team', staff: { name: by, adminNote } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live abschalten: disabled mit Grund team, ohne Loeschfrist (loeschungAm bleibt null, die Daten bleiben).
|
||||
* Zustand und Notiz unter `feature-admin:<projekt>`; das Ersatz-Ticket (nachAbschaltung, Netzwerk, wirft nie) danach ausserhalb der
|
||||
* Sperre. Es laeuft bei jedem Aufruf, auch bei einem Doppelklick: ein schon angelegtes Ticket merkt sich der Ersatz-Hash.
|
||||
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>}
|
||||
*/
|
||||
async function zeitenAbschalten({ customerId, projectId, by, note }) {
|
||||
const doc = await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!bisher) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}`), { status: 409 })
|
||||
}
|
||||
const adminNote = composeAdminNote(bisher.adminNote, { by, action: 'disable', note })
|
||||
return disable(bisher.$id, { by, adminNote, reason: 'team' })
|
||||
})
|
||||
return nachAbschaltung(doc)
|
||||
}
|
||||
|
||||
// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad),
|
||||
// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket
|
||||
// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register.
|
||||
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
|
||||
const TICKET_ACTIONS = ['abnahme', 'ticket']
|
||||
|
||||
@@ -251,25 +343,32 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
await ticketNachlegen({ customerId, projectId, key, by, adminNote })
|
||||
} else {
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten' && action === 'activate') {
|
||||
await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
} else if (key === 'zeiten' && action === 'disable') {
|
||||
ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
|
||||
} else {
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
await ticketNachlegen({ customerId, projectId, key, by, adminNote })
|
||||
} else {
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
|
||||
}
|
||||
await disable(doc.$id, { by, adminNote })
|
||||
}
|
||||
await disable(doc.$id, { by, adminNote })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
|
||||
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
|
||||
let warnung = false
|
||||
@@ -277,8 +376,54 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
warnung = true
|
||||
await notiereK16Fehlt(projectId, freigeschaltet.doc)
|
||||
}
|
||||
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), ...(warnung ? { warnung } : {}) })
|
||||
res.json({
|
||||
projects: (await adminView(customerId)).filter((p) => p.projectId === projectId),
|
||||
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
|
||||
...(ersatzTicket ? { ersatzTicket } : {}),
|
||||
...(warnung ? { warnung } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/** Antwort der Team-Einstellungen: Pruefergebnis (400 mit fehler je Feld) und Versionskonflikt (409 mit code) wie die Kundenroute. */
|
||||
function sendEinstellungenError(res, err) {
|
||||
if (err?.status === 400 && err.fehler && typeof err.fehler === 'object') {
|
||||
return res.status(400).json({ error: err.message, fehler: err.fehler })
|
||||
}
|
||||
if (err?.status === 409 && err.code === 'ZEITEN_VERALTET') {
|
||||
return res.status(409).json({ error: err.message, code: err.code })
|
||||
}
|
||||
return sendError(res, err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-Einstellungen von Oeffnungszeiten live (Spec 5 und 8): { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }
|
||||
* -> { team, teamVersion }. PATCH, weil das Ticketsystem nur GET/POST/PATCH/DELETE durchlaesst. Die Version ist die des Teams
|
||||
* (teamVersion aus der Ansicht); eine andere oder fehlende ergibt 409 ZEITEN_VERALTET, ungueltige Werte 400 mit fehler.
|
||||
* Die Sperre `zeiten:<projekt>` nimmt speichereTeam selbst; der Zustand wird nicht veraendert, darum keine feature-admin-Sperre.
|
||||
* Nicht fuer eine ausgeschaltete Funktion (off, z. B. nach der Loeschung der Daten): 409, damit kein verwaistes Dokument entsteht.
|
||||
*/
|
||||
funktionenAdminRouter.patch('/:customerId/zeiten/team', async (req, res) => {
|
||||
try {
|
||||
const { customerId } = req.params
|
||||
const { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus } = req.body || {}
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` })
|
||||
if (doc.state === 'off') {
|
||||
return res.status(409).json({ error: `${FEATURE_LABELS.zeiten.name} ist für dieses Projekt nicht eingeschaltet.` })
|
||||
}
|
||||
const gespeichert = await speichereTeam({
|
||||
feature: doc,
|
||||
eingabe: { navSelektor, kontaktleisteAus, hinweisleisteAus },
|
||||
teamVersion,
|
||||
by: `team:${req.staff?.appwriteUserId ?? 'unbekannt'}`,
|
||||
})
|
||||
res.json(gespeichert)
|
||||
} catch (err) {
|
||||
sendEinstellungenError(res, err)
|
||||
}
|
||||
})
|
||||
+174
-48
@@ -8,19 +8,24 @@ import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stri
|
||||
import {
|
||||
assertOwnProject,
|
||||
backfillInvoiceLinks,
|
||||
featureView,
|
||||
getFeatureDoc,
|
||||
hakenTexte,
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
markOrdered,
|
||||
runActivation,
|
||||
unterlagenAdressen,
|
||||
} from '../services/siteFeatures.js'
|
||||
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
|
||||
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { shopFuerKunde } from '../services/shop/schalter.js'
|
||||
import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
|
||||
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
|
||||
import { starteBehalten } from '../services/zeiten/behalten.js'
|
||||
import { aboMemo } from '../services/aboStatus.js'
|
||||
import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
|
||||
import {
|
||||
ELIGIBILITY_MESSAGES,
|
||||
@@ -46,12 +51,6 @@ function fail(res, err, fallback) {
|
||||
return res.status(500).json({ error: fallback })
|
||||
}
|
||||
|
||||
// Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde
|
||||
const actingPerson = (session) =>
|
||||
session.adminName || session.adminEmail
|
||||
? { name: session.adminName, email: session.adminEmail }
|
||||
: { name: session.name, email: session.email }
|
||||
|
||||
const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet'
|
||||
const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
@@ -102,13 +101,13 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv']
|
||||
* bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen
|
||||
* oder IDs enthalten).
|
||||
*/
|
||||
async function termineAngebot(email, customerId) {
|
||||
async function termineAngebot(email, customerId, abos = aboMemo()) {
|
||||
if (!termineFuerKunde(email)) return { verfuegbar: false }
|
||||
const register = featureConfig('termine')
|
||||
let bestellbar = null
|
||||
let grund = null
|
||||
try {
|
||||
const abo = await aboMemo().bestellbar(customerId)
|
||||
const abo = await abos.bestellbar(customerId)
|
||||
bestellbar = abo?.ok === true
|
||||
grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund
|
||||
} catch (err) {
|
||||
@@ -143,6 +142,61 @@ async function rechtlichesJeFunktion() {
|
||||
return legal
|
||||
}
|
||||
|
||||
/**
|
||||
* Felder der Zeiten je Projekt in GET /api/funktionen: zeitenStatus (Kurzstatus der Website, null wenn nichts live ist oder nichts
|
||||
* gespeichert wurde) und zeitenLeer (keine Plaene gespeichert). Nicht verfuegbar: null und true, ohne etwas zu lesen. Ein Lesefehler kostet die
|
||||
* Liste nichts (null und false: ohne Wissen wird kein Hinweis "Zeiten eintragen" erzwungen); im Log nur Art und Status.
|
||||
*/
|
||||
async function zeitenListenfelder(docs, projectId, verfuegbar, jetzt) {
|
||||
if (!verfuegbar) return { zeitenStatus: null, zeitenLeer: true }
|
||||
const doc = docs.find((d) => d.projectId === projectId && d.featureKey === 'zeiten')
|
||||
try {
|
||||
return await zeitenProjektAnsicht(doc, jetzt)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Zeiten für die Projektliste nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
return { zeitenStatus: null, zeitenLeer: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten-Teil der Antwort von GET /api/funktionen (Spec 3.1). Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
|
||||
* das Abo wird dann nicht gelesen. Verfuegbar: inklusive (true: laeuft und ist aktiv, die Funktion ist im Wartungs-Abo enthalten und
|
||||
* einschaltbar; false: sonst, dann sagt grund warum: kein_abo | gekuendigt | nicht_aktiv; null: Abo nicht lesbar), grund (null bei true und
|
||||
* null), preis (Register), einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
|
||||
* Leistungsbeschreibung) und belehrung: die Widerrufsbelehrung samt Muster-Formular, die die Bestellseite des bezahlten Weges (30 €, auch
|
||||
* "Behalten") vor dem Knopf zeigt (Haken belehrung der Funktion, derselbe Text wie im Zustimmungsnachweis, Ruling R32; null, wenn der Haken
|
||||
* sie nicht liefern kann: die Seite bestellt dann nicht). Die Zustimmung des bezahlten Weges steht in features.zeiten (Register-Ansicht).
|
||||
* Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text.
|
||||
*/
|
||||
async function zeitenAngebot(email, customerId, abos) {
|
||||
if (!zeitenFuerKunde(email)) return { verfuegbar: false }
|
||||
const register = featureConfig('zeiten')
|
||||
let inklusive = null
|
||||
let grund = null
|
||||
try {
|
||||
const abo = await abos.bestellbar(customerId)
|
||||
inklusive = abo?.ok === true
|
||||
grund = inklusive || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Abo für Öffnungszeiten live nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
let belehrung = null
|
||||
try {
|
||||
belehrung = (await featureHaken('zeiten').belehrung?.()) ?? null
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Widerrufsbelehrung (zeiten) nicht lesbar: ${err?.name ?? 'Fehler'}`)
|
||||
}
|
||||
const { version, url } = register.leistungsbeschreibung
|
||||
return {
|
||||
verfuegbar: true,
|
||||
inklusive,
|
||||
grund,
|
||||
preis: register.priceLabel,
|
||||
einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } },
|
||||
belehrung,
|
||||
}
|
||||
}
|
||||
|
||||
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
@@ -150,7 +204,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
|
||||
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
await backfillInvoiceLinks(customerId)
|
||||
const projects = await listCustomerFeatures(customerId)
|
||||
// Die Funktionsdokumente einmal lesen: die Ansicht je Projekt und der Zeiten-Status brauchen sie beide
|
||||
const docs = await listCustomerFeatureDocs(customerId)
|
||||
const projects = await listCustomerFeatures(customerId, { docs })
|
||||
const zeitenAn = zeitenFuerKunde(req.session.email)
|
||||
const jetzt = einschaltenDeps.jetzt()
|
||||
// Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
@@ -158,8 +216,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message)
|
||||
return { state: 'unknown' }
|
||||
})
|
||||
Object.assign(p, await zeitenListenfelder(docs, p.projectId, zeitenAn, jetzt))
|
||||
}),
|
||||
)
|
||||
// Der Abo-Stand wird fuer Terminplaner und Oeffnungszeiten nur einmal gelesen
|
||||
const abos = aboMemo()
|
||||
res.json({
|
||||
projects,
|
||||
// Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner
|
||||
@@ -173,10 +234,13 @@ router.get('/', requireSession, async (req, res) => {
|
||||
consentText: config.funktionen.consentText,
|
||||
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
|
||||
// mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers
|
||||
termine: await termineAngebot(req.session.email, customerId),
|
||||
termine: await termineAngebot(req.session.email, customerId, abos),
|
||||
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
|
||||
// kommen aus projects[].features.shop und GET /api/shop/status
|
||||
shop: { verfuegbar: shopFuerKunde(req.session.email) },
|
||||
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit
|
||||
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
|
||||
zeiten: await zeitenAngebot(req.session.email, customerId, abos),
|
||||
})
|
||||
} catch (err) {
|
||||
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
|
||||
@@ -207,39 +271,21 @@ async function openOrderSession(doc, feature) {
|
||||
|
||||
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
||||
|
||||
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
|
||||
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
|
||||
|
||||
/**
|
||||
* Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem
|
||||
* Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route
|
||||
* antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log.
|
||||
*/
|
||||
async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) {
|
||||
const belehrung = (await haken.belehrung?.()) ?? null
|
||||
const texte = nachweisTexte(feature, { belehrung })
|
||||
try {
|
||||
await speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: feature.key,
|
||||
stripeSessionId: sessionId,
|
||||
fassungen: zustimmungsfassungen(feature),
|
||||
texte,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
konto: kontoLabel(actingPerson(req.session)),
|
||||
at: klickAt,
|
||||
})
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
|
||||
/** Pruefstufe "Vertragsunterlagen abrufbar" (Haken unterlagenBereit der Funktion): sonst 503 unterlagen_fehlen, vor jeder Stripe-Sitzung und jedem Dokument. */
|
||||
async function pruefeUnterlagen(haken) {
|
||||
if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) {
|
||||
throw bestellFehler(
|
||||
503,
|
||||
'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
'unterlagen_fehlen'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
|
||||
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
|
||||
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
||||
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
|
||||
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo, 7b inklusive (Abo laeuft: einschalten statt bezahlen).
|
||||
router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
|
||||
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
|
||||
@@ -272,13 +318,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
||||
// 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung)
|
||||
const haken = featureHaken(key)
|
||||
if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) {
|
||||
throw bestellFehler(
|
||||
503,
|
||||
'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
'unterlagen_fehlen'
|
||||
)
|
||||
}
|
||||
await pruefeUnterlagen(haken)
|
||||
// 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7
|
||||
if (haken.bestellpruefung) {
|
||||
const offen = await haken.bestellpruefung({ customerId, projectId, feature })
|
||||
@@ -289,6 +329,8 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
}
|
||||
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
||||
await pruefeAbo(feature, customerId)
|
||||
// 7b Funktionen mit aboInklusive (Oeffnungszeiten live): im laufenden Wartungs-Abo gibt es nichts zu bezahlen (409 inklusive)
|
||||
await pruefeInklusive(feature, customerId)
|
||||
|
||||
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
|
||||
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||
@@ -300,7 +342,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
}
|
||||
if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
|
||||
|
||||
const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt })
|
||||
const nachweis = (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt })
|
||||
|
||||
// b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite
|
||||
const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null
|
||||
@@ -339,6 +381,90 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
const EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
// Gratisweg: Oeffnungszeiten live im Wartungs-Abo einschalten (Spec 3.2). Vorpruefungen wie im Checkout, jede Stufe ohne Seiteneffekt:
|
||||
// 1 Kundenansicht, 2 Zustimmung (Gratis-Fassung: ein Haken, Knopf "Einschalten"), 3 Projekt (eigen, nicht archiviert),
|
||||
// 4 Schalter/Pilot, 5 Unterlagen. Das Abo wird erst unter der Sperre frisch geprueft (einschaltenZeiten), dort entstehen auch Nachweis,
|
||||
// Zustand, Neubau, Ticket und K27; ein zweiter Klick holt Fehlendes nach.
|
||||
router.post('/zeiten/einschalten', requireSession, async (req, res) => {
|
||||
const now = new Date()
|
||||
try {
|
||||
// 1 Kundenansicht: Mitarbeiter schalten nicht fuer den Kunden ein
|
||||
if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' })
|
||||
const feature = featureConfig('zeiten')
|
||||
const body = req.body || {}
|
||||
// 2 Zustimmung: Haken und aktuelle Fassung der Gratis-Zustimmung (consentFrei)
|
||||
// nur { zustimmung }: die Altform { consent: true } (News, ohne Fassungspruefung) gilt bei dieser neuen Route nicht, sonst bestaetigte ein
|
||||
// alter Tab nach einer Textaenderung den neuen Wortlaut, ohne ihn gesehen zu haben
|
||||
pruefeZustimmung(freiweg(feature), { zustimmung: body.zustimmung })
|
||||
// 3 Projekt
|
||||
const { projectId } = body
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, projectId, { forNewOrder: true })
|
||||
// 4 Schalter (ZEITEN_ENABLED und Pilotliste)
|
||||
const sperre = featureSperre('zeiten', req.session.email)
|
||||
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
||||
// 5 Vertragsunterlagen abrufbar
|
||||
await pruefeUnterlagen(featureHaken('zeiten'))
|
||||
|
||||
const { doc } = await einschaltenZeiten({
|
||||
customerId,
|
||||
projectId,
|
||||
by: 'kunde',
|
||||
now,
|
||||
// Nachweis der Gratis-Zustimmung ohne Stripe-Sitzung und ohne Belehrung (es gibt kein Widerrufsverlangen)
|
||||
nachweis: () => speichereNachweisAusAnfrage(req, { feature: freiweg(feature), customerId, projectId, klickAt: now.toISOString() }),
|
||||
})
|
||||
res.json({ feature: await featureView(doc, 'zeiten'), einbau: doc.einbau || null })
|
||||
} catch (err) {
|
||||
fail(res, err, EINSCHALTEN_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
const BEHALTEN_ERROR = 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
// Behalten (Spec 3.3): nach dem Ende des Wartungs-Abos Öffnungszeiten live fuer einmalig 30 € behalten, ohne neuen Einbau. Vorpruefungen wie im
|
||||
// Checkout, jede Stufe ohne Seiteneffekt: 1 Kundenansicht, 2 Zustimmung (die der Bestellung, beide Haken), 3 Projekt (eigen; eine laufende Funktion
|
||||
// zu behalten ist keine Neubestellung, ein archiviertes Projekt bleibt erlaubt), 4 Schalter/Pilot, 5 Unterlagen. Ob behalten werden darf, entscheidet
|
||||
// das Dokument unter der Sperre (starteBehalten: aboEndeAt, sonst 409); den Zustand aendert die Route nicht, erst die bezahlte Sitzung (Webhook).
|
||||
// Die Route steht nicht auf der Erlaubnisliste der Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden); die Pruefung hier ist die zweite Sicherung.
|
||||
router.post('/zeiten/behalten', requireSession, async (req, res) => {
|
||||
// Zeit des Klicks: Eingang der Anfrage, Anker des Nachweises und der Metadaten (consentAt) und damit der Widerrufsfrist
|
||||
const now = new Date()
|
||||
try {
|
||||
// 1 Kundenansicht
|
||||
if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
||||
const feature = featureConfig('zeiten')
|
||||
const body = req.body || {}
|
||||
// 2 Zustimmung: alle Pflicht-Haken der Bestellung und die aktuelle Fassung
|
||||
pruefeZustimmung(feature, body)
|
||||
// 3 Projekt
|
||||
const { projectId } = body
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, projectId)
|
||||
// 4 Schalter (ZEITEN_ENABLED und Pilotliste)
|
||||
const sperre = featureSperre('zeiten', req.session.email)
|
||||
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
||||
// 5 Vertragsunterlagen abrufbar
|
||||
const haken = featureHaken('zeiten')
|
||||
await pruefeUnterlagen(haken)
|
||||
|
||||
const customer = await getDocument(config.collections.customers, customerId)
|
||||
const { url } = await starteBehalten({
|
||||
customer,
|
||||
projectId,
|
||||
now,
|
||||
nachweis: (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt: now.toISOString() }),
|
||||
})
|
||||
res.json({ url })
|
||||
} catch (err) {
|
||||
fail(res, err, BEHALTEN_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
const analyseGuard = [requireSession, requireAnalyseEnabled]
|
||||
|
||||
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { darfZeitenAnfrage } from '../services/zeiten/limits.js'
|
||||
import { oeffentlicheZeitenAntwort } from '../services/zeiten/oeffentlich.js'
|
||||
|
||||
/**
|
||||
* Oeffentliche Lese-API der Oeffnungszeiten fuer das Skript auf den Kundenwebsites: /api/public/zeiten/v1/:siteKey
|
||||
* (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4.1). Ohne Session, eigenes CORS (`*`, nur GET und HEAD),
|
||||
* Antworten enthalten nie Kunden-, Projekt-, Ticket- oder Stripe-IDs (das stellt services/zeiten/oeffentlich.js sicher).
|
||||
*
|
||||
* - Reihenfolge: CORS-Kopfzeilen (auch bei jedem Fehler, damit das Skript sie lesen kann) => OPTIONS 204 => 405 fuer alles ausser
|
||||
* GET/HEAD => Grenze 120 Abrufe je Minute und IP (services/zeiten/limits.js) => Antwort.
|
||||
* - Der Speicher (30 s, LRU 500), die Pruefung des siteKey-Formats und alle { aktiv: false }-Faelle (Schalter aus, unbekannter
|
||||
* Schluessel, geloeschter Kunde, Zustand nicht setup/active) liegen im Dienst; hier wird nur gesendet.
|
||||
* - Das weiche ETag hasht den Body OHNE `stand`: es bleibt gleich, solange sich die Daten nicht aendern, auch wenn die Antwort
|
||||
* neu gebaut wird. `Access-Control-Expose-Headers: Date` laesst das Skript die Serverzeit lesen (Uhrabweichung).
|
||||
* - Ein Fehler des Dienstes (Appwrite) wird zu 503 mit fester Meldung, nie mit dem Text des Fehlers.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
const TEXT_ZU_VIELE = 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.'
|
||||
const TEXT_NICHT_ERREICHBAR = 'Vorübergehend nicht erreichbar.'
|
||||
|
||||
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||
let uhr = () => new Date()
|
||||
export function setzeUhrFuerTests(neueUhr) {
|
||||
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||
}
|
||||
export function resetPublicZeitenForTests() {
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, If-None-Match',
|
||||
'Access-Control-Expose-Headers': 'Date',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
})
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'Methode nicht erlaubt' })
|
||||
// die App steht hinter Traefik, 'trust proxy' ist gesetzt -> req.ip ist die Client-IP
|
||||
if (!darfZeitenAnfrage(req.ip, uhr())) {
|
||||
res.set({ 'Retry-After': '60', 'Cache-Control': 'no-store' })
|
||||
return res.status(429).json({ error: TEXT_ZU_VIELE })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
/** Sendet den Body mit weichem ETag (sha1, base64url) ueber den Body ohne `stand`; 304 bei If-None-Match (auch `*`). */
|
||||
function sendeMitEtag(req, res, body) {
|
||||
const etag = `W/"${createHash('sha1').update(JSON.stringify({ ...body, stand: undefined })).digest('base64url')}"`
|
||||
res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60', 'X-Content-Type-Options': 'nosniff' })
|
||||
const gesendet = String(req.get('If-None-Match') || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^W\//, ''))
|
||||
if (gesendet.includes(etag.slice(2)) || gesendet.includes('*')) return res.status(304).end()
|
||||
return res.type('application/json').send(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function antworteNichtErreichbar(res, err) {
|
||||
console.error(`[publicZeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
if (res.headersSent) return res.end()
|
||||
res.set('Cache-Control', 'no-store')
|
||||
return res.status(503).json({ error: TEXT_NICHT_ERREICHBAR })
|
||||
}
|
||||
|
||||
router.get('/:siteKey', async (req, res) => {
|
||||
try {
|
||||
return sendeMitEtag(req, res, await oeffentlicheZeitenAntwort(req.params.siteKey, uhr()))
|
||||
} catch (err) {
|
||||
return antworteNichtErreichbar(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
router.use((req, res) => res.status(404).json({ error: 'Nicht gefunden' }))
|
||||
|
||||
router.use((err, req, res, _next) => antworteNichtErreichbar(res, err))
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Router } from 'express'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { feiertageImZeitraum } from '../services/termine/feiertage.js'
|
||||
import { heute, plusTage } from '../services/termine/zeit.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { ladeZeiten, speichereZeiten } from '../services/zeiten/speicher.js'
|
||||
import { googleText, hauptplan, landVon, statusZu, zuUtcOderDanach } from '../services/zeiten/zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Kunden-Schnittstelle der Oeffnungszeiten: /api/zeiten (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 3.4).
|
||||
*
|
||||
* GET /:projectId Zeiten laden: { state, daten|null, version, kundenansicht, vorschau|null, feiertage, googleText }
|
||||
* PUT /:projectId { version, daten } speichern, Antwort wie GET
|
||||
* GET /:projectId/vorschau ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr, ohne zeit: jetzt): { offen, text, kurz } der gespeicherten Daten
|
||||
*
|
||||
* Zugang: Session, eigenes Projekt (403), Funktion im Zustand ordered, setup oder active (lesen und speichern). disabled mit
|
||||
* gespeicherten Zeiten: nur lesen (PUT 403); disabled ohne Zeiten, off und kein Funktionsdokument: 403. Der Schalter
|
||||
* ZEITEN_ENABLED entscheidet nur ueber das Einschalten (featureRegistry), nicht ueber diese Schnittstelle: wer die Funktion hat, bearbeitet sie.
|
||||
* Version: 0 vor dem ersten Speichern; veraltet => 409 ZEITEN_VERALTET (die Sperre `zeiten:<projectId>` nimmt der Speicher selbst).
|
||||
* Kundenansicht (Mitarbeiter): speichern ist erlaubt (Erlaubnisliste des Waechters) und wird als `zeiten_gespeichert` protokolliert;
|
||||
* den WhatsApp-Haken (kontakt.whatsapp.bestaetigtAt) setzen nur Kunden selbst (R12, siehe bestaetigtAtPruefen).
|
||||
* Schreiben zaehlt gegen 60 Speichervorgaenge je Kunde und Stunde (im Speicher, 429); Lesen nicht.
|
||||
*
|
||||
* Fehler: { error: <Text>, fehler?: { <pfad>: Text }, code? }. Texte von Appwrite oder unbekannten Fehlern verlassen den Server
|
||||
* nie (500 "Interner Serverfehler"). Antworten sind nie zwischenspeicherbar.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
|
||||
const MAX_SPEICHERN_JE_STUNDE = 60
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ // Form einer Appwrite-ID: andere Texte gehen nie als Dokument-ID an Appwrite
|
||||
const FEIERTAGE_TAGE = 366 // 12 Monate ab heute: derselbe Kalendertag im naechsten Jahr ist immer dabei
|
||||
|
||||
const MELDUNG = {
|
||||
fremd: 'Kein Zugriff auf dieses Projekt.',
|
||||
nichtEingeschaltet: 'Die Öffnungszeiten sind für diese Website nicht eingeschaltet.',
|
||||
abgeschaltet: 'Die Öffnungszeiten sind für diese Website abgeschaltet. Du kannst sie nur noch ansehen.',
|
||||
veraltet: 'Inzwischen geändert – bitte neu laden.',
|
||||
whatsappKundenansicht: 'In der Kundenansicht kann der WhatsApp-Hinweis nicht bestätigt werden.',
|
||||
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
|
||||
zeit: 'Bitte gib Tag und Uhrzeit an (JJJJ-MM-TTTHH:MM).',
|
||||
keineZeiten: 'Bitte speichere zuerst deine Öffnungszeiten.',
|
||||
}
|
||||
|
||||
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||
let uhr = () => new Date()
|
||||
export function setzeUhrFuerTests(neueUhr) {
|
||||
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||
}
|
||||
|
||||
// 60 Speichervorgaenge je Kunde und Stunde (im Speicher); die Uhr folgt setzeUhrFuerTests
|
||||
const limiter = createWriteLimiter({ max: MAX_SPEICHERN_JE_STUNDE, now: () => uhr().getTime() })
|
||||
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||
|
||||
export function resetZeitenRoutesForTests() {
|
||||
limiter.reset()
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Fehler
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/** Fehler des Kunden (4xx) mit eigenem Text; mit feld zusaetzlich als Feldfehler. `fail` macht daraus die Antwort. */
|
||||
const stop = (status, text, code, feld) =>
|
||||
Object.assign(new Error(text), { status, kunde: true, ...(code ? { code } : {}), ...(feld ? { fehler: { [feld]: text } } : {}) })
|
||||
|
||||
/**
|
||||
* Antwort fuer einen Fehler: eigene Fehler (stop), Pruefergebnisse des Speichers (400 mit `fehler`) und ZEITEN_VERALTET (409)
|
||||
* wie sie sind; alles andere (auch Appwrite-Fehler mit 4xx) 500 ohne Rohtext.
|
||||
*/
|
||||
function fail(res, err) {
|
||||
const status = Number.isInteger(err?.status) ? err.status : 500
|
||||
const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {}
|
||||
const durchgelassen = err?.kunde === true || Object.keys(texte).length > 0 || err?.code === 'ZEITEN_VERALTET'
|
||||
if (!durchgelassen || status < 400 || status >= 500) {
|
||||
console.error(`[zeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
return res.status(500).json({ error: 'Interner Serverfehler' })
|
||||
}
|
||||
const body = { error: String(err.message) }
|
||||
if (Object.keys(texte).length > 0) body.fehler = texte
|
||||
if (typeof err.code === 'string') body.code = err.code
|
||||
return res.status(status).json(body)
|
||||
}
|
||||
|
||||
/** Handler mit Fehlerbehandlung (Express 4 fängt async-Fehler nicht selbst). */
|
||||
const route = (fn) => async (req, res, next) => {
|
||||
try {
|
||||
await fn(req, res, next)
|
||||
} catch (err) {
|
||||
fail(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store')
|
||||
next()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Zugang
|
||||
|
||||
/**
|
||||
* Eigenes Projekt (403), Funktion im Zustand ordered, setup, active oder disabled mit gespeicherten Zeiten (sonst 403) und die
|
||||
* gespeicherten Zeiten => req.ctx = { projectId, project, feature, stored } (stored: ladeZeiten, null ohne Dokument).
|
||||
* schreiben: disabled ist nicht beschreibbar (403).
|
||||
*/
|
||||
const withZeiten = ({ schreiben = false } = {}) => route(async (req, _res, next) => {
|
||||
const projectId = req.params.projectId
|
||||
const customerId = getSessionCustomerId(req)
|
||||
if (!ID_RE.test(projectId)) throw stop(403, MELDUNG.fremd)
|
||||
let project
|
||||
try {
|
||||
project = await assertOwnProject(customerId, projectId)
|
||||
} catch (err) {
|
||||
// auch eine unbrauchbare ID (Appwrite 400) ist kein eigenes Projekt
|
||||
throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err
|
||||
}
|
||||
const feature = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!feature || !['ordered', 'setup', 'active', 'disabled'].includes(feature.state)) throw stop(403, MELDUNG.nichtEingeschaltet)
|
||||
if (feature.state === 'disabled' && schreiben) throw stop(403, MELDUNG.abgeschaltet)
|
||||
const stored = await ladeZeiten(feature.$id)
|
||||
if (feature.state === 'disabled' && !stored?.daten) throw stop(403, MELDUNG.nichtEingeschaltet)
|
||||
req.ctx = { projectId, project, feature, stored }
|
||||
return next()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Antwort
|
||||
|
||||
/** Alles, was der Editor nach dem Laden und nach dem Speichern braucht (daten null und version 0 vor dem ersten Speichern). */
|
||||
function ansicht(req, { feature, daten, version }, now) {
|
||||
const plan = hauptplan(daten)
|
||||
const von = heute(now)
|
||||
return {
|
||||
state: feature.state,
|
||||
daten: daten ?? null,
|
||||
version,
|
||||
kundenansicht: isKundenansicht(req.session),
|
||||
vorschau: daten && plan ? statusZu(daten, plan, now) : null,
|
||||
feiertage: feiertageImZeitraum(von, plusTage(von, FEIERTAGE_TAGE), landVon(daten)),
|
||||
googleText: daten ? googleText(daten, plan, now) : '',
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:projectId', requireSession, withZeiten(), route(async (req, res) => {
|
||||
const { feature, stored } = req.ctx
|
||||
res.json(ansicht(req, { feature, daten: stored?.daten ?? null, version: stored?.version ?? 0 }, uhr()))
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Vorschau
|
||||
|
||||
const ZEIT_RE = /^(20\d{2}-\d{2}-\d{2})T([01]\d|2[0-3]):([0-5]\d)$/
|
||||
|
||||
/** ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr) => Zeitpunkt; ohne zeit: jetzt; sonst 400. */
|
||||
function vorschauZeit(req) {
|
||||
const roh = req.query.zeit
|
||||
if (roh === undefined) return uhr()
|
||||
const m = typeof roh === 'string' ? ZEIT_RE.exec(roh) : null
|
||||
if (!m) throw stop(400, MELDUNG.zeit, undefined, 'zeit')
|
||||
try {
|
||||
return zuUtcOderDanach(m[1], `${m[2]}:${m[3]}`)
|
||||
} catch {
|
||||
throw stop(400, MELDUNG.zeit, undefined, 'zeit') // kein echter Kalendertag (30.02.)
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:projectId/vorschau', requireSession, withZeiten(), route(async (req, res) => {
|
||||
const { stored } = req.ctx
|
||||
const plan = hauptplan(stored?.daten)
|
||||
if (!plan) throw stop(409, MELDUNG.keineZeiten, 'keine_zeiten')
|
||||
res.json(statusZu(stored.daten, plan, vorschauZeit(req)))
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Speichern
|
||||
|
||||
/** Zwei Zeitpunkt-Texte, die denselben Moment meinen (auch in anderer ISO-Schreibweise). */
|
||||
function gleicherZeitpunkt(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false
|
||||
if (a === b) return true
|
||||
const x = Date.parse(a)
|
||||
return Number.isFinite(x) && x === Date.parse(b)
|
||||
}
|
||||
|
||||
/** Hat der Browser einen Zeitpunkt der Bestaetigung geschickt? (fehlt er, ist der Haken nicht gesetzt: das meldet die Pruefung als Eingabefehler) */
|
||||
const behauptetBestaetigung = (wert) => wert !== undefined && wert !== null && wert !== '' && wert !== false
|
||||
|
||||
/**
|
||||
* R12 (Spezifikation 3.4, Kontakt-Leiste): den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) glaubt der Server
|
||||
* dem Browser nie. Ist er gegenueber dem gespeicherten Stand (bisher) neu oder anders: in der Kundenansicht 403 (Mitarbeiter
|
||||
* bestaetigen nichts im Namen des Kunden), beim Kunden selbst setzt der Server ihn auf jetzt. Unveraendert bleibt er erhalten;
|
||||
* fehlt er ganz, bleibt die Eingabe wie sie ist (die Pruefung meldet "Bitte bestaetige ..."). Veraendert die Eingabe nicht, sondern
|
||||
* liefert eine Kopie.
|
||||
*/
|
||||
function bestaetigtAtPruefen(req, eingabe, bisher, jetzt) {
|
||||
const whatsapp = eingabe?.kontakt?.whatsapp
|
||||
if (!istObjekt(whatsapp) || !behauptetBestaetigung(whatsapp.bestaetigtAt)) return eingabe
|
||||
const gespeichert = bisher?.kontakt?.whatsapp?.bestaetigtAt
|
||||
const bestaetigtAt = gleicherZeitpunkt(whatsapp.bestaetigtAt, gespeichert) ? gespeichert : null
|
||||
if (bestaetigtAt === null && isKundenansicht(req.session)) throw stop(403, MELDUNG.whatsappKundenansicht)
|
||||
return { ...eingabe, kontakt: { ...eingabe.kontakt, whatsapp: { ...whatsapp, bestaetigtAt: bestaetigtAt ?? jetzt.toISOString() } } }
|
||||
}
|
||||
|
||||
/** Protokolltext der Kundenansicht: Projekt und neue Version (keine Inhalte). */
|
||||
const protokollText = (req, version) => `${String(req.ctx.project?.projectName || req.ctx.projectId).slice(0, 120)}, Version ${version}`
|
||||
|
||||
router.put('/:projectId', requireSession, withZeiten({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
const { feature, stored } = req.ctx
|
||||
const jetzt = uhr()
|
||||
const { version, daten } = istObjekt(req.body) ? req.body : {}
|
||||
// Veraltet gewinnt vor jeder anderen Pruefung (auch vor dem WhatsApp-Haken): der Editor soll erst neu laden
|
||||
if (version !== (stored?.version ?? 0)) throw stop(409, MELDUNG.veraltet, 'ZEITEN_VERALTET')
|
||||
|
||||
const eingabe = bestaetigtAtPruefen(req, daten, stored?.daten, jetzt)
|
||||
const by = isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : 'kunde'
|
||||
const gespeichert = await speichereZeiten({ feature, eingabe, version, by, now: jetzt })
|
||||
await logIfKundenansicht(req, 'zeiten_gespeichert', protokollText(req, gespeichert.version))
|
||||
res.json(ansicht(req, { feature, daten: gespeichert.daten, version: gespeichert.version }, jetzt))
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -307,6 +307,22 @@ export async function deleteCustomerWithPortalAccess(customerId) {
|
||||
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message)
|
||||
}
|
||||
|
||||
// Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind
|
||||
// disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in
|
||||
// try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID.
|
||||
try {
|
||||
const { disableCustomerFeatures } = await import('./siteFeatures.js')
|
||||
await disableCustomerFeatures(customerId, 'zeiten', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
|
||||
} catch (err) {
|
||||
console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
try {
|
||||
const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js')
|
||||
await loescheZeitenVonKunde(customerId)
|
||||
} catch (err) {
|
||||
console.error(`[customerAdmin] Öffnungszeiten von Kunde ${customerId} nicht gelöscht:`, err.message)
|
||||
}
|
||||
|
||||
// Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument
|
||||
// und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf.
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const VIEW_ACTIONS = [
|
||||
'termin_aktion',
|
||||
'termin_manuell',
|
||||
'shop_aenderung',
|
||||
'zeiten_gespeichert',
|
||||
]
|
||||
|
||||
const DETAIL_MAX = 300
|
||||
|
||||
@@ -158,3 +158,47 @@ export function nachweisTexte(feature, { belehrung = null } = {}) {
|
||||
texte.push({ id: 'knopf', text: feature.consent.knopf, haken: false })
|
||||
return texte
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Nachweis aus einer Anfrage (Checkout und Gratisweg "Einschalten"): Serverfassungen und -texte der Funktion, IP, Browser und Konto
|
||||
// aus der Anfrage. Hier statt in der Route, damit beide Wege denselben Nachweis schreiben.
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde. */
|
||||
export const actingPerson = (session) =>
|
||||
session.adminName || session.adminEmail
|
||||
? { name: session.adminName, email: session.adminEmail }
|
||||
: { name: session.name, email: session.email }
|
||||
|
||||
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
|
||||
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
|
||||
|
||||
/**
|
||||
* Zustimmungsnachweis aus einer Anfrage speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem Client), dazu
|
||||
* (falls der Haken `belehrung` der Funktion einen Text liefert) die Belehrung und die Zeit des Klicks. Jeder Fehler hier ist ein
|
||||
* Serverfehler: die Route antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext
|
||||
* steht nur im Log. Ohne sessionId (Gratisweg) traegt der Nachweis keine Stripe-Sitzung.
|
||||
* @param {object} req Express-Anfrage (ip, User-Agent, Sitzung)
|
||||
* @param {{ feature: object, haken?: object, customerId: string, projectId: string, sessionId?: string, klickAt: string }} p
|
||||
* feature: Registereintrag, beim Gratisweg freiweg(feature) (Zustimmung consentFrei)
|
||||
*/
|
||||
export async function speichereNachweisAusAnfrage(req, { feature, haken = {}, customerId, projectId, sessionId, klickAt }) {
|
||||
const belehrung = (await haken.belehrung?.()) ?? null
|
||||
const texte = nachweisTexte(feature, { belehrung })
|
||||
try {
|
||||
await speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: feature.key,
|
||||
stripeSessionId: sessionId,
|
||||
fassungen: zustimmungsfassungen(feature),
|
||||
texte,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
konto: kontoLabel(actingPerson(req.session)),
|
||||
at: klickAt,
|
||||
})
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config } from '../config.js'
|
||||
import { aboMemo } from './aboStatus.js'
|
||||
import { oeffentlicheAnsicht } from './featureRegistry.js'
|
||||
import { aboMemo, bestellbar } from './aboStatus.js'
|
||||
import { featureConfig } from './featureRegistry.js'
|
||||
import { istObjekt } from './termine/ablauf.js'
|
||||
import { holeOderNull, ladeEinstellung } from './termine/kontext.js'
|
||||
|
||||
@@ -31,6 +31,16 @@ const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
||||
const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.'
|
||||
const MAX_HAKEN = 50
|
||||
|
||||
/**
|
||||
* Sicht der Zustimmung fuer den Browser (Bestellseite, Antwort bei veralteter Fassung): Fassung, Knopf und alle Texte; ob ein Punkt
|
||||
* einen Haken braucht, steht je Punkt. Fuer feature.consent und, beim Gratisweg, freiweg(feature).consent.
|
||||
*/
|
||||
export const oeffentlicheConsent = (consent) => ({
|
||||
version: consent.version,
|
||||
knopf: consent.knopf,
|
||||
items: consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
|
||||
})
|
||||
|
||||
const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt')
|
||||
const hatWert = (wert) => wert !== undefined && wert !== null
|
||||
|
||||
@@ -63,7 +73,8 @@ export function pruefeZustimmung(feature, eingabe) {
|
||||
const { version, haken } = eingabe.zustimmung
|
||||
if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt()
|
||||
if (version !== feature.consent.version) {
|
||||
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent })
|
||||
// die Fassung des geprueften Eintrags (beim Gratisweg die Gratis-Zustimmung), nicht die des Registers
|
||||
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheConsent(feature.consent) })
|
||||
}
|
||||
if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt()
|
||||
const gesetzt = new Set(haken)
|
||||
@@ -132,3 +143,58 @@ export async function pruefeAbo(feature, customerId) {
|
||||
if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
|
||||
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
|
||||
}
|
||||
|
||||
const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
|
||||
|
||||
/**
|
||||
* Pruefstufe 7b: Funktionen mit aboInklusive (Oeffnungszeiten live) kosten im laufenden Wartungs-Abo nichts. Laeuft das Abo und ist
|
||||
* es nicht gekuendigt (bestellbar ok) oder nur gerade nicht aktiv (nicht_aktiv: Probezeit, offene Zahlung), gibt es keinen Grund zu
|
||||
* bezahlen: 409 inklusive mit dem Hinweis aufs Einschalten; body.grund ist null (Abo laeuft) oder 'nicht_aktiv' (wie zeiten.grund in
|
||||
* GET /api/funktionen). kein_abo und gekuendigt lassen den 30-Euro-Weg offen. Wie pruefeAbo: gleicher Memo je Anfrage, ein Lesefehler
|
||||
* wirft (500, die Route zeigt nur ihren allgemeinen Text), im Zweifel nicht bestellbar; ein Ergebnis ohne gueltigen Grund ist ein Fehler.
|
||||
* Funktionen ohne aboInklusive (News, Terminplaner) lassen es unberuehrt und lesen das Abo hier nicht.
|
||||
*/
|
||||
export async function pruefeInklusive(feature, customerId) {
|
||||
if (feature.aboInklusive !== true) return
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
|
||||
}
|
||||
if (ergebnis?.ok === true) throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund: null })
|
||||
const grund = ergebnis?.grund
|
||||
if (grund === 'nicht_aktiv') throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund })
|
||||
if (grund === 'kein_abo' || grund === 'gekuendigt') return
|
||||
throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
|
||||
}
|
||||
|
||||
const GRATIS_ABO_TEXT = {
|
||||
kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`,
|
||||
nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.',
|
||||
}
|
||||
const GERADE_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'
|
||||
|
||||
/**
|
||||
* Abo-Pruefung des Gratisweges "Einschalten" von Oeffnungszeiten live (Spec 3.2 Schritt 1): immer frisch gelesen (bestellbar direkt,
|
||||
* nie aboMemo), gedacht fuer den Aufruf unter der Sperre feature-order. Laeuft das Wartungs-Abo und ist es aktiv, geschieht nichts.
|
||||
* kein_abo und gekuendigt: 409 (code und body.grund wie der Grund) mit dem Hinweis auf den 30-Euro-Weg; nicht_aktiv: 409 "Einschaltbar,
|
||||
* sobald Dein Abo aktiv ist." Ein Lesefehler ist 503 (fail-closed, nie stillschweigend erlaubt); im Log stehen nur Art und Status.
|
||||
*/
|
||||
export async function pruefeAboFuerEinschalten(customerId) {
|
||||
const feature = featureConfig('zeiten')
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await bestellbar(customerId)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
throw Object.assign(new Error(GERADE_NICHT_PRUEFBAR), { status: 503 })
|
||||
}
|
||||
if (ergebnis?.ok === true) return
|
||||
const grund = ergebnis?.grund
|
||||
if (grund === 'nicht_aktiv') throw bestellFehler(409, GRATIS_ABO_TEXT.nicht_aktiv(), grund, { grund })
|
||||
if (grund === 'kein_abo' || grund === 'gekuendigt') {
|
||||
throw bestellFehler(409, GRATIS_ABO_TEXT.kein_abo_gekuendigt(feature.label.name, feature.priceLabel), grund, { grund })
|
||||
}
|
||||
throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { config } from '../config.js'
|
||||
import { termineFuerKunde } from './termine/schalter.js'
|
||||
import { shopFuerKunde } from './shop/schalter.js'
|
||||
import { zeitenFuerKunde } from './zeiten/schalter.js'
|
||||
|
||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop; Daten in server/config.js).
|
||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
|
||||
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen:
|
||||
// termine/schalter.js und shop/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
|
||||
// termine/schalter.js, shop/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
|
||||
// innerhalb der Funktion (sonst entstuenden Importzyklen).
|
||||
|
||||
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
||||
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
|
||||
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen, dazu nachFreischaltung. */
|
||||
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung']
|
||||
|
||||
/**
|
||||
* Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus
|
||||
@@ -39,6 +40,35 @@ async function termineFreischaltbar(doc) {
|
||||
return (await freischaltHindernis(doc)) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischaltpruefung der Oeffnungszeiten (Spec 4.4): die Einrichtung ist erst freizugeben, wenn der Kunde Zeiten eingetragen hat, also
|
||||
* ein Hauptplan gespeichert ist und mindestens ein Wochentag nicht "zu" ist (modusUndFenster: auch ein unlesbarer Eintrag zaehlt als zu).
|
||||
* Fail-closed: jeder Fehler (Lesefehler, kaputte Daten) ergibt false und bleibt im Log (ohne Kundendaten), das Dokument bleibt in
|
||||
* 'setup' und der naechste Lauf des 5-Minuten-Jobs fragt erneut. Der Aufrufer uebergibt das unter der Sperre frisch gelesene Dokument.
|
||||
*/
|
||||
async function zeitenFreischaltbar(doc) {
|
||||
try {
|
||||
const [{ ladeZeiten }, { hauptplan, modusUndFenster }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/zeitlogik.js')])
|
||||
const haupt = hauptplan((await ladeZeiten(doc.$id))?.daten ?? null)
|
||||
if (!haupt) return false
|
||||
return [1, 2, 3, 4, 5, 6, 7].some((tag) => modusUndFenster(haupt.tage?.[tag]).modus !== 'zu')
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Öffnungszeiten ${doc?.$id}: Zeiten für die Freischaltung nicht prüfbar:`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach der Freischaltung der Oeffnungszeiten (Spec 4.4): den Hash des Hauptplan-Wochenplans als ersatzHash merken. Das Ersatz-HTML,
|
||||
* das das Team beim Einbau setzt, zeigt diesen Wochenplan; weicht er beim Abschalten ab, entsteht das Ticket "Ersatz-Tabelle
|
||||
* aktualisieren". setzeErsatzHash nimmt die Sperre zeiten:<projekt> selbst (hier nicht halten) und fasst Daten und Versionen nicht an.
|
||||
* Wirft bei Lese- und Schreibfehlern; der Aufrufer (runActivation) loggt und laesst Freischaltung und K16 davon unberuehrt.
|
||||
*/
|
||||
async function zeitenNachFreischaltung(doc) {
|
||||
const [{ ladeZeiten, setzeErsatzHash }, { wochenplanHash }] = await Promise.all([import('./zeiten/speicher.js'), import('./zeiten/ersatz.js')])
|
||||
await setzeErsatzHash(doc, wochenplanHash((await ladeZeiten(doc.$id))?.daten ?? null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der
|
||||
* aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst
|
||||
@@ -52,7 +82,8 @@ async function termineUnterlagenBereit() {
|
||||
/**
|
||||
* Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf
|
||||
* zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen
|
||||
* wird sie erst beim Aufruf (kein Importzyklus).
|
||||
* wird sie erst beim Aufruf (kein Importzyklus). Sie gilt fuer jede kostenpflichtige Funktion; auch die Oeffnungszeiten live
|
||||
* (Haken belehrung von zeiten) liefern sie.
|
||||
*/
|
||||
async function termineBelehrung() {
|
||||
const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js')
|
||||
@@ -96,6 +127,16 @@ const STANDARD_HAKEN = {
|
||||
unterlagenBereit: shopUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
},
|
||||
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung wie beim Terminplaner,
|
||||
// unterlagenBereit immer (die Oeffnungszeiten haben nur die Leistungsbeschreibung als Seite, keine PDFs); freischaltbar: erst mit
|
||||
// eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken
|
||||
zeiten: {
|
||||
verfuegbar: (email) => zeitenFuerKunde(email),
|
||||
unterlagenBereit: async () => true,
|
||||
belehrung: termineBelehrung,
|
||||
freischaltbar: zeitenFreischaltbar,
|
||||
nachFreischaltung: zeitenNachFreischaltung,
|
||||
},
|
||||
}
|
||||
|
||||
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
|
||||
@@ -118,10 +159,12 @@ export function ticketFreischaltungKeys() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }.
|
||||
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar?, nachFreischaltung? }.
|
||||
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
|
||||
* freischaltbar(doc) => Promise<boolean>: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden?
|
||||
* Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets).
|
||||
* nachFreischaltung(doc) => Promise<void>: Nacharbeit nach der Freischaltung (doc ist das aktive Dokument); runActivation ruft sie
|
||||
* vor der K16 und faengt jeden Fehler (Log), Freischaltung und K16 haengen nie davon ab.
|
||||
*/
|
||||
export function featureHaken(key) {
|
||||
const eintrag = featureConfig(key)
|
||||
@@ -150,6 +193,14 @@ export function featureSperre(key, email) {
|
||||
return eintrag.unavailableMessage || NICHT_VERFUEGBAR
|
||||
}
|
||||
|
||||
/**
|
||||
* Gratisweg einer Funktion mit aboInklusive (Wartungs-Abo laeuft): derselbe Eintrag, nur mit der Zustimmung consentFrei
|
||||
* (ein Haken, Knopf "Einschalten") statt consent. Eine Kopie, das Register bleibt unveraendert.
|
||||
*/
|
||||
export function freiweg(feature) {
|
||||
return { ...feature, consent: feature.consentFrei }
|
||||
}
|
||||
|
||||
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { envKey, decryptEnv, maskLog } from './previewEnv.js'
|
||||
import { saveRunLog } from './previewLogs.js'
|
||||
import * as runnerClient from './previewRunnerClient.js'
|
||||
import { analysePrivacyData } from './analysePrivacy.js'
|
||||
import { buildZeiten } from './zeiten/einbau.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -688,7 +689,7 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
||||
}
|
||||
if (run.result === 'dev') {
|
||||
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
|
||||
try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined }) } catch (err) {
|
||||
try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined, zeiten: undefined }) } catch (err) {
|
||||
console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message)
|
||||
}
|
||||
} else {
|
||||
@@ -786,6 +787,7 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
|
||||
...(displayName ? { displayName } : {}),
|
||||
env,
|
||||
analytics: await buildAnalytics(doc),
|
||||
zeiten: await buildZeiten(doc),
|
||||
}
|
||||
const { runId } = await runnerClient.submitBuild(job)
|
||||
if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning })
|
||||
|
||||
@@ -33,6 +33,10 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
||||
* plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der
|
||||
* Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem
|
||||
* Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js).
|
||||
* - Oeffnungszeiten live: Zeigt der Plan Funktionen 'zeiten' (plan.news[].zeitenIds, Teilmenge von featureIds), loescht der Lauf vor dem
|
||||
* Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures).
|
||||
* Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter
|
||||
* "Fehlgeschlagen" im Bericht.
|
||||
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
|
||||
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
|
||||
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
|
||||
@@ -158,6 +162,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
|
||||
consentIds: consents.map((c) => c.$id),
|
||||
featureFieldIds: withFields.map((f) => f.$id),
|
||||
...(shops.length ? { shops } : {}),
|
||||
zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,12 +215,13 @@ async function deleteQuietly(collection, id) {
|
||||
* Daten fuer immer liegen. Lehnt Stripe die Aenderung ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
|
||||
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
|
||||
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
|
||||
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number },
|
||||
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number,
|
||||
* zeiten: number },
|
||||
* manual: { customerId: string, subscriptionId: string, reason: string }[],
|
||||
* failed: { what: string, id: string, reason: string }[], held: string[] }>}
|
||||
*/
|
||||
export async function applyDeletions(plan) {
|
||||
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0 }
|
||||
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0 }
|
||||
const manual = []
|
||||
const failed = []
|
||||
const held = []
|
||||
@@ -243,7 +249,19 @@ export async function applyDeletions(plan) {
|
||||
}
|
||||
if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1
|
||||
}
|
||||
// Oeffnungszeiten: erst die Daten (Dokumente, Merker, Speicher), dann das Abschalten; scheitert das Loeschen, bleiben diese Funktionen
|
||||
// unangetastet und der Fehler steht im Bericht (wie beim Bild vor dem News-Beitrag)
|
||||
const zeitenIds = new Set(entry.zeitenIds || [])
|
||||
let zeitenWeg = true
|
||||
if (zeitenIds.size) {
|
||||
zeitenWeg = await attempt('oeffnungszeiten', entry.customerId, async () => {
|
||||
const { loescheZeitenVonKunde } = await import('./zeiten/speicher.js')
|
||||
deleted.zeiten += await loescheZeitenVonKunde(entry.customerId)
|
||||
return true
|
||||
})
|
||||
}
|
||||
for (const id of entry.featureIds) {
|
||||
if (!zeitenWeg && zeitenIds.has(id)) continue
|
||||
const off = await attempt('portalFeatures', id, async () => {
|
||||
// disable() meldet bei Analyse auch den Sammeldienst ab (best effort)
|
||||
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||
@@ -348,8 +366,9 @@ export function formatReport(plan, result = null) {
|
||||
const consents = n.consentIds?.length || 0
|
||||
const fields = n.featureFieldIds?.length || 0
|
||||
const shops = n.shops?.length || 0
|
||||
const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : ''
|
||||
lines.push(
|
||||
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}`
|
||||
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}`
|
||||
)
|
||||
}
|
||||
const del = plan.takeover.filter((t) => t.action === 'loeschen')
|
||||
@@ -372,6 +391,7 @@ export function formatReport(plan, result = null) {
|
||||
`Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`,
|
||||
...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : [])
|
||||
)
|
||||
if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`)
|
||||
if (result.failed?.length) {
|
||||
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
||||
}
|
||||
|
||||
+230
-35
@@ -72,7 +72,11 @@ export async function getFeatureDoc(customerId, projectId, key) {
|
||||
}
|
||||
}
|
||||
|
||||
/** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */
|
||||
/**
|
||||
* Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes
|
||||
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt; der Kunde sieht archivierte
|
||||
* Projekte nicht mehr).
|
||||
*/
|
||||
export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) {
|
||||
let project = null
|
||||
try {
|
||||
@@ -114,7 +118,8 @@ export async function ensureFeatureDoc(customerId, projectId, key) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ticketWoid(ticketId) {
|
||||
/** Ticketnummer (woid) zur Ticket-ID; ein unbekanntes Ticket ergibt null. */
|
||||
export async function ticketWoid(ticketId) {
|
||||
if (!ticketId) return null
|
||||
try {
|
||||
const ticket = await getDocument(config.collections.workorders, ticketId)
|
||||
@@ -125,16 +130,28 @@ async function ticketWoid(ticketId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wodurch die Funktion freigeschaltet wurde: 'kauf' | 'abo' | 'team'. Altbestand ohne unlockedBy: unlockedByPurchase => 'kauf', sonst
|
||||
* 'team' (Kulanz, Pilot, Team-Aktivierung).
|
||||
*/
|
||||
export const unlockedByVon = (doc) => doc?.unlockedBy || (doc?.unlockedByPurchase ? 'kauf' : 'team')
|
||||
|
||||
/**
|
||||
* Ansicht einer Funktion fuer Kunde und Team. Abschaltgrund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt
|
||||
* (disabledAt) und Berliner Loeschtag (loeschungAm, 30 Tage danach, termine/frist.js) gibt es nur im Zustand `disabled`;
|
||||
* in jedem anderen Zustand sind alle drei null, auch wenn am Dokument noch Reste einer frueheren Abschaltung stehen.
|
||||
* loeschungAm gibt es nur fuer den Terminplaner (R105: der 30-Tage-Loeschlauf in jobs.js loescht nur Terminplaner-Daten,
|
||||
* bei News verspraeche das Datum eine Loeschung, die nie kommt) und ist null, wenn disabledAt fehlt oder nicht lesbar ist.
|
||||
* bei News verspraeche das Datum eine Loeschung, die nie kommt) und fuer Oeffnungszeiten live, dort nur beim Abschaltgrund
|
||||
* abo_ende (bei einer Abschaltung durch das Team gibt es keine Loeschfrist); es ist null, wenn disabledAt fehlt oder nicht lesbar ist.
|
||||
* disabledReason und disabledAt gelten fuer jede Funktion.
|
||||
* Funktionen mit aboInklusive (Oeffnungszeiten live) tragen zusaetzlich unlockedBy (kauf | abo | team, null solange die Funktion
|
||||
* aus ist), aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus).
|
||||
* key: Funktionsschluessel (auch ohne Dokument); ohne Angabe der des Dokuments.
|
||||
*/
|
||||
async function featureView(doc) {
|
||||
export async function featureView(doc, key = doc?.featureKey) {
|
||||
const abgeschaltet = doc?.state === 'disabled'
|
||||
const mitAbo = featureConfig(key)?.aboInklusive === true
|
||||
const loeschtag = abgeschaltet && (key === 'termine' || (mitAbo && doc.disabledReason === 'abo_ende'))
|
||||
return {
|
||||
state: doc?.state || 'off',
|
||||
siteKey: doc?.siteKey || null,
|
||||
@@ -144,7 +161,14 @@ async function featureView(doc) {
|
||||
invoiceUrl: doc?.invoiceUrl || null,
|
||||
disabledReason: abgeschaltet ? doc.disabledReason || null : null,
|
||||
disabledAt: abgeschaltet ? doc.disabledAt || null : null,
|
||||
loeschungAm: abgeschaltet && doc.featureKey === 'termine' ? loeschTag(doc.disabledAt) : null,
|
||||
loeschungAm: loeschtag ? loeschTag(doc.disabledAt) : null,
|
||||
...(mitAbo
|
||||
? {
|
||||
unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null,
|
||||
aboEndeAt: doc?.aboEndeAt || null,
|
||||
einbau: doc?.einbau || null,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +195,7 @@ export async function listCustomerFeatures(customerId, { docs: geladen, includeA
|
||||
const features = {}
|
||||
for (const key of FEATURE_KEYS) {
|
||||
const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key)
|
||||
features[key] = await featureView(doc)
|
||||
features[key] = await featureView(doc, key)
|
||||
}
|
||||
result.push({
|
||||
projectId: p.$id,
|
||||
@@ -314,12 +338,32 @@ function offenerFall(doc) {
|
||||
}
|
||||
|
||||
/** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */
|
||||
function notizVorn(previous, entry) {
|
||||
export function notizVorn(previous, entry) {
|
||||
const neu = entry.slice(0, NOTE_MAX)
|
||||
const aelter = String(previous || '')
|
||||
return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu
|
||||
}
|
||||
|
||||
/**
|
||||
* Funktionen mit aboInklusive (Oeffnungszeiten live) halten am Dokument fest, wodurch sie freigeschaltet wurden (unlockedBy) und was der
|
||||
* Neubau fuer den Einbau ergab (einbau). Fuer die uebrigen Funktionen (News, Terminplaner) schreibt der Kaufweg beides nicht: das Schema
|
||||
* dieser Felder gibt es nur, wo zeiten-setup.mjs gelaufen ist.
|
||||
*/
|
||||
const mitAbo = (key) => featureConfig(key)?.aboInklusive === true
|
||||
|
||||
/**
|
||||
* Neubau fuer den Einbau des Zeiten-Skripts (Spec 4.6) beim Kauf, VOR dem Einbau-Ticket: das Ticket nennt das Ergebnis (einbau).
|
||||
* Steht einbau schon am Dokument (Wiederholung des Webhooks nach einem gescheiterten Ticket), wird nicht noch einmal gebaut.
|
||||
* stosseNeubauAn nimmt die eigene Sperre 'zeiten-neubau' (andere Schluessel als feature-paid, also kein Wiederbetreten) und wirft nie;
|
||||
* zeiten/* nur per dynamischem Import (siehe Kopf). Liefert das Dokument mit dem gespeicherten einbau.
|
||||
*/
|
||||
async function baueZeitenEin(doc, project) {
|
||||
if (doc.einbau) return doc
|
||||
const { stosseNeubauAn } = await import('./zeiten/neubau.js')
|
||||
const einbau = await stosseNeubauAn(project)
|
||||
return updateDocument(config.collections.portalFeatures, doc.$id, { einbau })
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird
|
||||
* einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen
|
||||
@@ -333,6 +377,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
purchaseStatus: 'paid',
|
||||
unlockedByPurchase: true,
|
||||
// Eine Zahlung, die nichts einrichtet, aendert nicht, wodurch das Dokument freigeschaltet wurde: ein abo oder team bleibt (der
|
||||
// Abo-Abgleich und die Loeschfrist haengen an unlockedBy abo); nur ohne Angabe (Altbestand) wird es zu kauf, passend zu unlockedByPurchase
|
||||
...(mitAbo(doc.featureKey) && !doc.unlockedBy ? { unlockedBy: 'kauf' } : {}),
|
||||
stripeInvoiceId: invoiceId || '',
|
||||
invoiceUrl: invoiceUrl || '',
|
||||
adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`),
|
||||
@@ -346,6 +393,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
|
||||
* wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId
|
||||
* (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook:
|
||||
* 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus).
|
||||
* Funktionen mit aboInklusive (Oeffnungszeiten live): 'setup' traegt unlockedBy 'kauf' (und einbau leer), danach stoesst baueZeitenEin den
|
||||
* Neubau an und speichert einbau, erst dann entsteht das Ticket mit dem Ergebnis. Fortsetzbar ist dort nur setup mit unlockedBy kauf; ein
|
||||
* per Abo oder Team eingeschaltetes Dokument ohne Ticket heilt holeZeitenNach, die Zahlung ist fuer es ein Fall (bereits_aktiv).
|
||||
*
|
||||
* Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active',
|
||||
* 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert,
|
||||
@@ -358,20 +408,27 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||||
const doc = await findBySession(sessionId)
|
||||
if (!doc) return { doc: null, created: false, fall: null }
|
||||
if (doc.state === 'off') return { doc, created: false, fall: null }
|
||||
const resumable = doc.state === 'setup' && !doc.ticketId
|
||||
const mitAboKauf = mitAbo(doc.featureKey)
|
||||
// Funktionen mit aboInklusive: nur ein Dokument, das der Kauf auf setup gestellt hat (unlockedBy kauf), ist fortsetzbar. Eines, das per Abo
|
||||
// oder vom Team auf setup steht, bekommt sein Ticket vom Nachholen (holeZeitenNach); die Zahlung ist dort ein Fall fuers Team
|
||||
const resumable = doc.state === 'setup' && !doc.ticketId && (!mitAboKauf || unlockedByVon(doc) === 'kauf')
|
||||
if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl })
|
||||
|
||||
const setup = resumable
|
||||
let setup = resumable
|
||||
? doc
|
||||
: await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('setup'),
|
||||
purchaseStatus: 'paid',
|
||||
unlockedByPurchase: true,
|
||||
// einbau beginnt leer: Reste eines frueheren Laufs gelten nicht, der Neubau (baueZeitenEin) setzt es neu
|
||||
...(mitAboKauf ? { unlockedBy: 'kauf', einbau: '' } : {}),
|
||||
stripeInvoiceId: invoiceId || '',
|
||||
invoiceUrl: invoiceUrl || '',
|
||||
zahlungMeldung: '',
|
||||
})
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
|
||||
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
|
||||
const customer = await customerOf(doc.customerId).catch(() => null)
|
||||
const ticketId = await legeTicketAn(setup, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||||
@@ -418,11 +475,26 @@ export async function markCheckoutFailed(sessionId) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
|
||||
* Aktivierung. Als `extra` an activate() (nur der Zeiten-Pfad; die Felder legt scripts/zeiten-setup.mjs an, News und Terminplaner
|
||||
* teilen die Collection).
|
||||
*/
|
||||
export const ZEITEN_AKTIVIERUNG_FELDER = Object.freeze({ aboEndeAt: null, behaltenSessionId: '' })
|
||||
|
||||
function byNote(by, verb) {
|
||||
return by ? `${verb} von ${by}`.slice(0, 300) : undefined
|
||||
}
|
||||
|
||||
export async function activate(docId, { by, adminNote } = {}) {
|
||||
/**
|
||||
* Funktion aktivieren (jede Funktion gleich). `extra` sind zusaetzliche Felder, die der Aufrufer mitschreibt: Oeffnungszeiten live
|
||||
* leert damit sein erkanntes Abo-Ende und eine offene Behalten-Zahlung (ZEITEN_AKTIVIERUNG_FELDER). Diese Felder legt erst
|
||||
* scripts/zeiten-setup.mjs an; darum schreibt activate() sie nie von selbst, sonst scheiterte die Aktivierung von News und
|
||||
* Terminplaner mit "Unknown attribute", solange der Code vor dem Skript laeuft.
|
||||
* @param {string} docId
|
||||
* @param {{ by?: string, adminNote?: string, extra?: Record<string, unknown> }} [options]
|
||||
*/
|
||||
export async function activate(docId, { by, adminNote, extra } = {}) {
|
||||
const note = adminNote ?? byNote(by, 'Freigeschaltet')
|
||||
return updateDocument(config.collections.portalFeatures, docId, {
|
||||
...stateFields('active'),
|
||||
@@ -433,6 +505,7 @@ export async function activate(docId, { by, adminNote } = {}) {
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
aboEndeMailAt: null,
|
||||
...(extra ?? {}),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
}
|
||||
@@ -600,7 +673,7 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote,
|
||||
at: now,
|
||||
})
|
||||
}
|
||||
return activate(doc.$id, { by, adminNote })
|
||||
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -691,6 +764,9 @@ const ABNAHME_HINDERNIS = {
|
||||
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
|
||||
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
|
||||
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
|
||||
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
|
||||
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
|
||||
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'],
|
||||
}
|
||||
|
||||
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
|
||||
@@ -712,16 +788,33 @@ async function frischesDokument(vorab, key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen (Terminplaner:
|
||||
* laufendes Wartungs-Abo und gespeicherte Einstellung); fehlt der Haken (News), gibt es keine Pruefung und kein Abo wird gelesen.
|
||||
* Die Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert).
|
||||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken
|
||||
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
|
||||
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
|
||||
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
|
||||
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
|
||||
*/
|
||||
async function abnahmeHindernis(doc) {
|
||||
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
|
||||
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
|
||||
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
|
||||
return pruefe(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
|
||||
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
|
||||
*/
|
||||
async function nacharbeitNachFreischaltung(doc) {
|
||||
const { nachFreischaltung } = featureHaken(doc.featureKey)
|
||||
if (typeof nachFreischaltung !== 'function') return
|
||||
try {
|
||||
await nachFreischaltung(doc)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
|
||||
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
|
||||
@@ -753,6 +846,7 @@ export async function freischaltenNachAbnahme({ customerId, projectId, key, by,
|
||||
return { doc: updated, activated: true }
|
||||
})
|
||||
if (ergebnis.activated) {
|
||||
await nacharbeitNachFreischaltung(ergebnis.doc)
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
await meldeFreigeschaltet(ergebnis.doc)
|
||||
}
|
||||
@@ -780,6 +874,12 @@ async function bezahlteSitzung(doc) {
|
||||
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
|
||||
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
|
||||
}
|
||||
// Eine Behalten-Zahlung (Oeffnungszeiten live, zeiten/behalten.js) hat kein Einrichtungs-Ticket nachzulegen. Vor allem aber darf sie nie in
|
||||
// onFeaturePaid landen: dessen Behalten-Zweig (bucheBehalten) nimmt feature-admin:<projekt>, und die Team-Route haelt diese Sperre schon
|
||||
// (withLocks ist nicht wiederbetretbar => Stillstand, der den Schluessel dauerhaft blockiert).
|
||||
if (session?.metadata?.zweck === 'behalten') {
|
||||
throw httpError(409, 'Zu dieser Funktion gehört nur eine Behalten-Zahlung, es gibt kein Einrichtungs-Ticket nachzulegen.')
|
||||
}
|
||||
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
|
||||
return session
|
||||
}
|
||||
@@ -804,6 +904,26 @@ async function ticketNachZahlung(doc, note) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt
|
||||
* holeZeitenNach nach (Neubau, Einbau-Ticket, K27 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
|
||||
* erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen ->
|
||||
* zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es).
|
||||
*/
|
||||
async function ticketNachHolen(doc, note) {
|
||||
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
|
||||
const nachher = await holeZeitenNach(doc)
|
||||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||||
if (!note) return { doc: nachher, created: true }
|
||||
try {
|
||||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||||
} catch (err) {
|
||||
// Ticket und Mail sind raus; die Notiz ist nur ein Vermerk
|
||||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||||
return { doc: nachher, created: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
|
||||
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
|
||||
@@ -825,7 +945,8 @@ async function ersetzeGeloeschtesTicket(doc, note) {
|
||||
/**
|
||||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
|
||||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt
|
||||
* holeZeitenNach Neubau, Ticket und K27 nach (ticketNachHolen);
|
||||
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
|
||||
* (c) Ticket vorhanden: nichts, created false.
|
||||
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
|
||||
@@ -839,19 +960,24 @@ export async function ticketNachlegen({ customerId, projectId, key, by, adminNot
|
||||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (!doc.ticketId) return ticketNachZahlung(doc, note)
|
||||
if (!doc.ticketId) return key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : ticketNachZahlung(doc, note)
|
||||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||||
return ersetzeGeloeschtesTicket(doc, note)
|
||||
})
|
||||
}
|
||||
|
||||
/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */
|
||||
export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
|
||||
if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null
|
||||
/**
|
||||
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
|
||||
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
|
||||
* vor dem Schliessen des Tickets pruefen kann); eine leere Liste passt auf nichts und fragt Appwrite gar nicht erst.
|
||||
*/
|
||||
export async function findActiveBySiteKey(siteKey, featureKey = 'news', { states = ['active'] } = {}) {
|
||||
if (typeof siteKey !== 'string' || !/^[a-z2-7]{16}$/.test(siteKey) || !FEATURE_KEYS.includes(featureKey)) return null
|
||||
if (!Array.isArray(states) || states.length === 0) return null
|
||||
const [doc] = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('siteKey', siteKey),
|
||||
Query.equal('featureKey', featureKey),
|
||||
Query.equal('state', 'active'),
|
||||
Query.equal('state', states),
|
||||
Query.limit(1),
|
||||
])
|
||||
return doc || null
|
||||
@@ -890,8 +1016,10 @@ export function unterlagenAdressen(feature) {
|
||||
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
|
||||
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
|
||||
* Registerhaken dafuer.
|
||||
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
|
||||
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
|
||||
*/
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '' }) {
|
||||
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
|
||||
const { funktion, formen, preisHinweis, danach } = feature.mails
|
||||
return {
|
||||
template: 'K15',
|
||||
@@ -915,7 +1043,14 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlage
|
||||
widerruf_url: unterlagen?.widerruf ?? '',
|
||||
zustimmungen_text: zustimmungenText,
|
||||
},
|
||||
blocks: { rechnung: Boolean(invoiceUrl), anlagen: unterlagen !== null, anhang: unterlagen !== null && anhang === true, zustimmungen: zustimmungenText !== '' },
|
||||
blocks: {
|
||||
rechnung: Boolean(invoiceUrl),
|
||||
anlagen: unterlagen !== null,
|
||||
anhang: unterlagen !== null && anhang === true,
|
||||
zustimmungen: zustimmungenText !== '',
|
||||
behalten: Boolean(behalten),
|
||||
neu: !behalten,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,7 +1130,7 @@ export async function customerOf(customerId) {
|
||||
}
|
||||
|
||||
/** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */
|
||||
async function invoiceOf(invoiceRef) {
|
||||
export async function invoiceOf(invoiceRef) {
|
||||
const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id
|
||||
if (!id) return { invoiceId: '', invoiceUrl: '' }
|
||||
try {
|
||||
@@ -1040,6 +1175,8 @@ function sendT16({ funktion, ...werte }, bloecke) {
|
||||
anleitung: false,
|
||||
gesundheit: false,
|
||||
anhang_fehlt: false,
|
||||
abo: false,
|
||||
team: false,
|
||||
...bloecke,
|
||||
},
|
||||
})
|
||||
@@ -1106,7 +1243,7 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false, anha
|
||||
}
|
||||
|
||||
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
|
||||
async function unmatchedNotice(session) {
|
||||
export async function unmatchedNotice(session) {
|
||||
const { customerId, projectId, featureKey } = session.metadata || {}
|
||||
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
|
||||
await sendT16(
|
||||
@@ -1215,14 +1352,18 @@ export function meldeZahlungFall(doc, fall, { customer = null } = {}) {
|
||||
* am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet;
|
||||
* ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink.
|
||||
* Genau einmal je Dokument (siehe meldeEinmal), wirft nie.
|
||||
* ticketOffen und rechnungUrl ueberschreiben, was sonst am Dokument steht (Ticket vorhanden, invoiceUrl): bei einer Zahlung, die nicht zu
|
||||
* dessen Ticket und Rechnung gehoert (Behalten, zeiten/behalten.js), sagt T19 sonst "Ticket offen" und zeigt die Rechnung einer anderen Zahlung.
|
||||
* rechnungUrl '' heisst "keine Rechnung"; null (Vorgabe) nimmt die des Dokuments.
|
||||
* @param {object} doc portalFeatures-Dokument
|
||||
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options]
|
||||
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean, ticketOffen?: boolean|null, rechnungUrl?: string|null }} [options]
|
||||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||||
*/
|
||||
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) {
|
||||
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false, ticketOffen = null, rechnungUrl = null } = {}) {
|
||||
return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => {
|
||||
const kopf = await meldeKopf(frisch, customer)
|
||||
const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : ''
|
||||
const rechnung = rechnungUrl ?? frisch.invoiceUrl ?? ''
|
||||
return sendAdminNotice({
|
||||
template: 'T19',
|
||||
values: {
|
||||
@@ -1231,12 +1372,12 @@ export async function meldeErstattung(doc, { customer = null, sitzung = doc.stri
|
||||
betrag: featureConfig(frisch.featureKey)?.priceLabel || '',
|
||||
sitzung: sitzung || '',
|
||||
woid,
|
||||
rechnung_url: frisch.invoiceUrl || '',
|
||||
rechnung_url: rechnung,
|
||||
},
|
||||
blocks: {
|
||||
zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende',
|
||||
ticket_offen: Boolean(frisch.ticketId),
|
||||
rechnung: Boolean(frisch.invoiceUrl),
|
||||
ticket_offen: ticketOffen ?? Boolean(frisch.ticketId),
|
||||
rechnung: Boolean(rechnung),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1263,6 +1404,16 @@ async function meldeFallZurZahlung(doc, fall, session) {
|
||||
if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung "fuer 30 EUR behalten" (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3): eigener Weg in zeiten/behalten.js, nie ueber
|
||||
* markPaid und adoptPaidSession (die schalten nur aus 'ordered' ein und meldeten die Zahlung einer laufenden Funktion als ungeklaert).
|
||||
* Dynamischer Import: zeiten/* importiert dieses Modul.
|
||||
*/
|
||||
async function onBehaltenPaid(session) {
|
||||
const { bucheBehalten } = await import('./zeiten/behalten.js')
|
||||
await bucheBehalten(session)
|
||||
}
|
||||
|
||||
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
|
||||
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false })
|
||||
|
||||
@@ -1320,6 +1471,7 @@ async function k15Rechtsunterlagen(doc, feature, session) {
|
||||
}
|
||||
|
||||
async function onFeaturePaid(session) {
|
||||
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session)
|
||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||||
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
|
||||
let { doc, created, fall } = await markPaid(paid)
|
||||
@@ -1396,25 +1548,68 @@ export async function handleFeatureCheckoutEvent(event) {
|
||||
await onFeaturePaid(session)
|
||||
return
|
||||
case 'checkout.session.async_payment_failed': {
|
||||
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
|
||||
const doc = await markCheckoutFailed(session.id)
|
||||
if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true })
|
||||
return
|
||||
}
|
||||
case 'checkout.session.expired':
|
||||
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
|
||||
await markCheckoutFailed(session.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Behalten-Zahlung abgelaufen oder fehlgeschlagen: die offene Sitzung am Dokument wird frei (Dynamischer Import wie onBehaltenPaid). */
|
||||
async function verwerfeBehaltenSitzung(session) {
|
||||
const { verwerfeBehalten } = await import('./zeiten/behalten.js')
|
||||
await verwerfeBehalten(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden (meldeFreigeschaltet: Fehler
|
||||
* werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf). termine/freischaltung.js importiert diese
|
||||
* Datei, darum wird es hier erst im Aufruf geladen.
|
||||
* Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte
|
||||
* nach (Neubau, Einbau-Ticket, K27; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in
|
||||
* activateClosedTickets: dort zaehlen nur Dokumente MIT Ticket. Jedes Dokument fuer sich abgesichert (ein Fehler stoppt weder die
|
||||
* uebrigen noch den Job; der naechste Lauf versucht es erneut), das Log nennt Dokument-ID und Fehler, nie Kundendaten.
|
||||
* Dynamischer Import: zeiten/* importiert dieses Modul.
|
||||
*/
|
||||
async function heileZeiten({ now }) {
|
||||
if (!featureConfig('zeiten')) return
|
||||
let docs
|
||||
try {
|
||||
docs = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'zeiten'), Query.equal('state', 'setup'), Query.limit(1000)])
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Selbstheilung Öffnungszeiten: Dokumente nicht lesbar:', err.message)
|
||||
return
|
||||
}
|
||||
if (docs.length === 0) return
|
||||
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
await holeZeitenNach(doc, { now })
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Selbstheilung Öffnungszeiten (${doc.$id}) fehlgeschlagen:`, [err?.name ?? 'Fehler', err?.status, err?.message].filter(Boolean).join(' '))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
|
||||
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden
|
||||
* betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf).
|
||||
* termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs
|
||||
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
|
||||
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
|
||||
*/
|
||||
export async function runActivation({ now = new Date(), customerId = null } = {}) {
|
||||
const activated = await activateClosedTickets({ now, customerId })
|
||||
if (activated.length === 0) return activated
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
for (const doc of activated) await meldeFreigeschaltet(doc)
|
||||
if (activated.length > 0) {
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
for (const doc of activated) {
|
||||
await nacharbeitNachFreischaltung(doc)
|
||||
await meldeFreigeschaltet(doc)
|
||||
}
|
||||
}
|
||||
if (!customerId) await heileZeiten({ now })
|
||||
return activated
|
||||
}
|
||||
@@ -59,6 +59,7 @@ const PRODUCT_METADATA = {
|
||||
newsSetup: 'news-einrichtung',
|
||||
termineSetup: 'termine-einrichtung',
|
||||
shopSetup: 'shop-einrichtung',
|
||||
zeitenSetup: 'zeiten-einrichtung',
|
||||
}
|
||||
|
||||
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */
|
||||
@@ -67,13 +68,14 @@ function catalogFor(key) {
|
||||
if (key === 'newsSetup') return config.newsSetup
|
||||
if (key === 'termineSetup') return config.termineSetup
|
||||
if (key === 'shopSetup') return config.shopSetup
|
||||
if (key === 'zeitenSetup') return config.zeitenSetup
|
||||
return getProduct(key)
|
||||
}
|
||||
|
||||
const priceIdCache = new Map()
|
||||
/**
|
||||
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
|
||||
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
|
||||
*/
|
||||
export async function ensurePrice(key) {
|
||||
@@ -655,15 +657,22 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
|
||||
* 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die Rechnung erzeugt Stripe
|
||||
* selbst und traegt featureKey und projectId in ihren Metadaten. Nur die Zuordnung steht in den Metadaten der Sitzung,
|
||||
* der Zustimmungswortlaut nicht.
|
||||
* zweck: 'behalten' (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3) steht in den Metadaten der Sitzung und der Rechnung, damit der
|
||||
* Webhook die Zahlung nicht als Neubestellung verbucht, und die Rueckkehr traegt `behalten=1` statt `bestellt=1`; ohne zweck bleibt alles wie es war.
|
||||
* @returns {Promise<{ id: string, url: string }>}
|
||||
*/
|
||||
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
|
||||
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt, zweck = null }) {
|
||||
const feature = featureConfig(key)
|
||||
if (!feature) {
|
||||
const err = new Error('Unbekannte Funktion.')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (zweck !== null && zweck !== 'behalten') {
|
||||
const err = new Error('Unbekannter Zweck.')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
const stripe = getStripe()
|
||||
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice(feature.product), getOrCreateStripeCustomer(customer)])
|
||||
// Rueckkehr ins Dashboard: bei Funktionen mit returnProjekt (Terminplaner) mit dem Projekt, damit die Seite es oeffnet
|
||||
@@ -683,12 +692,12 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news',
|
||||
invoice_data: {
|
||||
description: catalogFor(feature.product).name,
|
||||
footer: config.stripe.invoiceFooter,
|
||||
metadata: { featureKey: key, projectId },
|
||||
metadata: { featureKey: key, projectId, ...(zweck ? { zweck } : {}) },
|
||||
},
|
||||
},
|
||||
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
|
||||
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}), ...(zweck ? { zweck } : {}) },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
|
||||
success_url: `${returnUrl}?bestellt=1${projekt ? `&projekt=${projekt}` : ''}`,
|
||||
success_url: `${returnUrl}?${zweck === 'behalten' ? 'behalten' : 'bestellt'}=1${projekt ? `&projekt=${projekt}` : ''}`,
|
||||
cancel_url: `${returnUrl}${projekt ? `?projekt=${projekt}` : ''}`,
|
||||
}
|
||||
let session
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isHoliday } from 'feiertagejs'
|
||||
import { getHolidays, isHoliday } from 'feiertagejs'
|
||||
|
||||
/**
|
||||
* Gesetzliche Feiertage je Bundesland (feiertagejs 1.5.1, Tage als Berliner Kalendertage).
|
||||
@@ -24,3 +24,72 @@ export function istFeiertag(tag, land) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage mit Namen und „bitte pruefen“ (Oeffnungszeiten live).
|
||||
*
|
||||
* art 'feiertag': gesetzlicher Feiertag des Landes (feiertagejs; in BB auch Oster- und Pfingstsonntag).
|
||||
* art 'pruefen': Tag, den der Kunde selbst bedenken muss, weil er nicht ueberall oder nicht gesetzlich gilt:
|
||||
* Heiligabend, Silvester, Mariae Himmelfahrt in BY (nur oertlich) und Fronleichnam in SN und TH (dort nur
|
||||
* in einzelnen Gemeinden; das Datum kommt aus der RP-Liste). Ist ein solcher Tag im Land zugleich
|
||||
* gesetzlicher Feiertag (Mariae Himmelfahrt im SL, Fronleichnam in RP), bleibt er 'feiertag'.
|
||||
*/
|
||||
|
||||
const PRUEFEN_LAENDER = Object.freeze({
|
||||
MARIAHIMMELFAHRT: Object.freeze(['BY']),
|
||||
})
|
||||
const FRONLEICHNAM_ORTSABHAENGIG = Object.freeze(['SN', 'TH'])
|
||||
|
||||
/** Alle Eintraege eines Kalenderjahres fuer ein (gueltiges) Land, nach Tag sortiert, ein Eintrag je Tag. */
|
||||
function jahresEintraege(jahr, land) {
|
||||
let liste
|
||||
try {
|
||||
liste = getHolidays(jahr, land).map((h) => ({
|
||||
tag: h.dateString,
|
||||
name: h.translate('de'),
|
||||
art: PRUEFEN_LAENDER[h.name]?.includes(land) ? 'pruefen' : 'feiertag',
|
||||
}))
|
||||
if (FRONLEICHNAM_ORTSABHAENGIG.includes(land)) {
|
||||
const rp = getHolidays(jahr, 'RP').find((h) => h.name === 'FRONLEICHNAM')
|
||||
if (rp && !liste.some((e) => e.tag === rp.dateString)) {
|
||||
liste.push({ tag: rp.dateString, name: rp.translate('de'), art: 'pruefen' })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const jj = String(jahr).padStart(4, '0')
|
||||
for (const [tag, name] of [[`${jj}-12-24`, 'Heiligabend'], [`${jj}-12-31`, 'Silvester']]) {
|
||||
if (!liste.some((e) => e.tag === tag)) liste.push({ tag, name, art: 'pruefen' })
|
||||
}
|
||||
return liste.sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Name des gesetzlichen Feiertags (z. B. 'Tag der Deutschen Einheit') oder null.
|
||||
* Nur art 'feiertag': Pruefe-Tage (Heiligabend, Mariae Himmelfahrt in BY, ...) liefert nur feiertageImZeitraum.
|
||||
* (istFeiertag fuehrt Mariae Himmelfahrt in BY weiterhin als Feiertag, das bleibt unveraendert.)
|
||||
* Wirft nie: ungueltiger Tag oder unbekanntes Land ergeben null.
|
||||
*/
|
||||
export function feiertagName(tag, land) {
|
||||
if (typeof tag !== 'string' || !TAG_RE.test(tag) || !BUNDESLAENDER.includes(land)) return null
|
||||
const e = jahresEintraege(Number(tag.slice(0, 4)), land).find((x) => x.tag === tag)
|
||||
return e && e.art === 'feiertag' ? e.name : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage und Pruefe-Tage von bis (beide 'YYYY-MM-DD', inklusive), sortiert, ueber Jahresgrenzen.
|
||||
* Ergebnis: [{ tag, name, art: 'feiertag' | 'pruefen' }]. Ungueltige Eingaben, unbekanntes Land oder
|
||||
* bis vor von ergeben []. Je Kalenderjahr ein getHolidays-Aufruf (nicht istFeiertag je Tag).
|
||||
*/
|
||||
export function feiertageImZeitraum(von, bis, land) {
|
||||
if (typeof von !== 'string' || typeof bis !== 'string') return []
|
||||
if (!TAG_RE.test(von) || !TAG_RE.test(bis) || von > bis || !BUNDESLAENDER.includes(land)) return []
|
||||
const ergebnis = []
|
||||
for (let jahr = Number(von.slice(0, 4)); jahr <= Number(bis.slice(0, 4)); jahr += 1) {
|
||||
for (const e of jahresEintraege(jahr, land)) {
|
||||
if (e.tag >= von && e.tag <= bis) ergebnis.push(e)
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { config } from '../../config.js'
|
||||
import { aboLage, aboMemo } from '../aboStatus.js'
|
||||
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon, ZEITEN_AKTIVIERUNG_FELDER } from '../siteFeatures.js'
|
||||
import { LOESCHFRIST_TAGE } from '../termine/frist.js'
|
||||
import { pruefeNow, zeitMs } from '../termine/util.js'
|
||||
import { heute, plusTage, zuUtc } from '../termine/zeit.js'
|
||||
import { nachAbschaltung } from './abschalten.js'
|
||||
import { k27Mail, k29Mail, k30Mail, sendeZeitenMail } from './mails.js'
|
||||
import { stosseNeubauAn } from './neubau.js'
|
||||
import { ladeZeiten, loescheZeiten } from './speicher.js'
|
||||
|
||||
/**
|
||||
* Abo-Abgleich von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 5 "Abo-Abgleich" und Z11):
|
||||
* Die Funktion gehört im Wartungs-Abo ohne Aufpreis dazu und endet mit ihm, aber nicht sofort: 30 Tage Frist zum Behalten (30 €), danach
|
||||
* ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in
|
||||
* Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen.
|
||||
*
|
||||
* Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo
|
||||
* wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel):
|
||||
*
|
||||
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K29 (Variante nach art; Frist = Berliner Tag von
|
||||
* aboEndeAt + 30 Tage)
|
||||
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', dann das Ersatz-Ticket des Teams
|
||||
* (nachAbschaltung), dann K30 (Löschdatum = Abschalten + 30 Tage)
|
||||
* setup/active, Abo läuft, aboEndeAt gesetzt Schritt 3a: das Abo ist vor dem Abschalten zurück: aboEndeAt, Marker und offene
|
||||
* Behalten-Sitzung leeren, Zustand bleibt, KEIN Neubau (die Leisten waren nie weg),
|
||||
* K27 "wieder" ohne den Kasten "Wie geht es weiter?" (k27Mail einbau null, R17b)
|
||||
* disabled/'abo_ende', Abo läuft Schritt 3b: zurück auf active (war die Funktion schon einmal freigeschaltet:
|
||||
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K27 "wieder"
|
||||
* mit dem echten einbau. Solange die Eingaben noch da sind: auch am Tag der Löschung
|
||||
* geht die Rückkehr der Löschung vor (die Rückkehr wird je Lauf zuerst entschieden).
|
||||
* disabled/'abo_ende', Abo läuft nicht, 30 Tage Schritt 4: loescheZeiten (daten, team, Merker) und state 'off'; ticketId, einbau,
|
||||
* nach disabledAt einschaltMailAt, activatedAt, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
|
||||
* Zeiten-Dokument). Grund 'team' (und jeder andere Grund) wird nie gelöscht.
|
||||
*
|
||||
* Tage zählen nach BERLINER KALENDERTAGEN (plusTage, nie Vielfache von 24 Stunden): "Tag 30" ist der 30. Berliner Tag nach dem Tag, an dem
|
||||
* das Abo-Ende erkannt wurde, egal um welche Uhrzeit der Tagesjob an diesem Tag läuft (er läuft im 24-Stunden-Takt ab dem Start des Servers
|
||||
* und kann je Tag ein paar Minuten früher oder später liegen; eine Zeitumstellung verschöbe die Frist um eine Stunde). Dieselben Tage stehen
|
||||
* in K29 und K30.
|
||||
*
|
||||
* Regeln (wie termine/aboAbgleich.js):
|
||||
* - Eine Listenabfrage für alle Kunden (Entwürfe 'off' bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird für die
|
||||
* Vorauswahl nur einmal gelesen; Dokumente, bei denen nichts zu tun ist (Abo läuft, kein Abo-Ende), kosten weder Sperre noch Schreiben.
|
||||
* - Geschrieben wird nur unter der Sperre feature-admin:<projectId> (wie die Team-Route) und nach frischem Lesen von Dokument UND Abo
|
||||
* (aboLage direkt, nie der Memo): der frische Stand entscheidet, nicht die Vorauswahl. Mails, Neubau und Ersatz-Ticket folgen NACH
|
||||
* der Sperre (Netzwerk; Sperren sind nicht wiederbetretbar).
|
||||
* - Fail-closed: ist das Abo nicht lesbar (Vorauswahl oder unter der Sperre), wird nichts abgeschaltet, nichts gelöscht und nichts
|
||||
* eingeschaltet. Der Kunde wird übersprungen, `fehler` zählt, der nächste Lauf versucht es wieder. Ein Dokument mit unlesbarem Datum
|
||||
* wird übersprungen (`fehler`), die übrigen laufen weiter. Das Log nennt weder Namen noch Adressen.
|
||||
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K29, nach dem
|
||||
* Abschalten der K30 (nur ein Marker, der nicht vor disabledAt liegt: k30Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es
|
||||
* könnte scheitern). Scheitert der Versand, bleibt der Marker leer, der nächste Lauf sendet nach (dieselben Daten; die Frist wächst
|
||||
* nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Die K30 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es
|
||||
* weder K30 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K30 so höchstens einmal). Ein Kunde ohne
|
||||
* Adresse bekommt den Marker (es gibt nichts zu senden). K27 "wieder" geht nur an Kunden, die K29 oder K30 bekommen haben (Marker, vor dem Leeren gelesen)
|
||||
* und hat keinen Marker: höchstens einmal je Rückkehr (scheitert der Versand, steht es im Log und in `fehler`).
|
||||
* - Ruling R25: die Rückkehr leert aboEndeAt und behaltenSessionId; eine noch offene Behalten-Zahlung bucht danach nicht mehr zum Kauf
|
||||
* (bucheBehalten: Sitzung passt nicht, T19), und starteBehalten lehnt bei laufendem Abo ab.
|
||||
*/
|
||||
|
||||
const KEY = 'zeiten'
|
||||
const GRUND = 'abo_ende'
|
||||
/** Frist zum Behalten nach dem erkannten Abo-Ende (Z11); die Datenfrist nach dem Abschalten ist LOESCHFRIST_TAGE (30). */
|
||||
const FRIST_TAGE = 30
|
||||
|
||||
const NOTIZ_ABGESCHALTET = 'System: Abgeschaltet: Wartungs-Abo beendet, Frist abgelaufen'
|
||||
const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Wartungs-Abo läuft wieder'
|
||||
const NOTIZ_OHNE_NEUBAU = 'System: Wartungs-Abo läuft wieder, Abo-Ende zurückgenommen'
|
||||
const NOTIZ_GELOESCHT = `System: Öffnungszeiten gelöscht, ${LOESCHFRIST_TAGE} Tage nach dem Abschalten`
|
||||
|
||||
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
|
||||
|
||||
/** Fehlertext fürs Log: Name und gekürzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */
|
||||
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}`
|
||||
|
||||
const portalFeatures = () => config.collections.portalFeatures
|
||||
|
||||
const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active'
|
||||
const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND
|
||||
/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */
|
||||
const istGekoppelt = (doc) => unlockedByVon(doc) === 'abo' && (istLaufend(doc) || istAbgeschaltet(doc))
|
||||
|
||||
/** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */
|
||||
function tagVon(wert, feld) {
|
||||
const t = zeitMs(wert)
|
||||
if (!Number.isFinite(t)) throw new Error(`${feld} nicht lesbar`)
|
||||
return heute(new Date(t))
|
||||
}
|
||||
/** Tag, an dem abgeschaltet wird: Berliner Tag von aboEndeAt + 30 Tage. */
|
||||
const fristTag = (doc) => plusTage(tagVon(doc.aboEndeAt, 'aboEndeAt'), FRIST_TAGE)
|
||||
/** Tag, an dem die Eingaben gelöscht werden: Berliner Tag von disabledAt + 30 Tage. */
|
||||
const loeschTag = (doc) => plusTage(tagVon(doc.disabledAt, 'disabledAt'), LOESCHFRIST_TAGE)
|
||||
/** Ein Date mitten an einem Berliner Tag: die Mail-Builder zeigen den Berliner Tag dieses Zeitpunkts (kein Springen über Mitternacht). */
|
||||
const mittags = (tag) => zuUtc(tag, '12:00')
|
||||
|
||||
/**
|
||||
* Was mit dem Dokument bei dieser Abo-Lage und diesem Tag zu tun ist (reine Entscheidung, null = nichts):
|
||||
* 'ende' | 'k29' | 'abschalten' | 'k30' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum.
|
||||
*/
|
||||
function entscheide(doc, lage, now) {
|
||||
const tag = heute(now)
|
||||
if (istAbgeschaltet(doc)) {
|
||||
if (lage.laeuft) return 'zurueck'
|
||||
if (tag >= loeschTag(doc)) return 'loeschen'
|
||||
return k30Gesendet(doc) ? null : 'k30'
|
||||
}
|
||||
if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null
|
||||
if (!doc.aboEndeAt) return 'ende'
|
||||
if (tag >= fristTag(doc)) return 'abschalten'
|
||||
return doc.aboEndeMailAt ? null : 'k29'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat die K30 ihren Marker? aboEndeMailAt gilt der K30 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K29 (aus der Frist)
|
||||
* ist älter als disabledAt. So hängt die K30 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte
|
||||
* (scheiterte dieses Schreiben, blieb der K29-Marker stehen und K30 samt Ersatz-Ticket liefen nie).
|
||||
*/
|
||||
const k30Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt))
|
||||
|
||||
/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
|
||||
async function datenDa(featureId) {
|
||||
return (await ladeZeiten(featureId))?.daten != null
|
||||
}
|
||||
|
||||
/** Daten löschen (unter der Sperre feature-admin:<projectId>), danach das Dokument auf 'off' und leer. */
|
||||
async function loesche(frisch, ctx) {
|
||||
await loescheZeiten(frisch.$id)
|
||||
await updateDocument(portalFeatures(), frisch.$id, {
|
||||
...stateFields('off'),
|
||||
ticketId: '',
|
||||
einbau: '',
|
||||
einschaltMailAt: null,
|
||||
activatedAt: null, // ein späteres Einschalten beginnt wieder in setup (sonst käme es durch das alte activatedAt zu früh auf active)
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`),
|
||||
})
|
||||
ctx.erg.geloescht += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Dokument unter der Sperre feature-admin:<projectId>: frisch lesen, frisch das Abo lesen, den frischen Stand entscheiden lassen und
|
||||
* schreiben. Liefert, was nach der Sperre zu tun ist ({ aktion, doc, lage, hatteMail }), oder null (nichts mehr zu tun, das Team war
|
||||
* schneller). Wirft bei Lesefehlern (Dokument oder Abo): dann ist nichts geschrieben.
|
||||
*/
|
||||
async function unterSperre(doc, ctx) {
|
||||
const { erg, jetzt, now } = ctx
|
||||
const frisch = await getDocument(portalFeatures(), doc.$id)
|
||||
if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo)
|
||||
const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
|
||||
const aktion = entscheide(frisch, lage, now)
|
||||
const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr
|
||||
|
||||
switch (aktion) {
|
||||
case 'ende': {
|
||||
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
|
||||
erg.aboEnde += 1
|
||||
return { aktion: 'k29', doc: neu, lage }
|
||||
}
|
||||
case 'k29':
|
||||
case 'k30':
|
||||
return { aktion, doc: frisch, lage }
|
||||
case 'abschalten': {
|
||||
const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) })
|
||||
if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
|
||||
erg.abgeschaltet += 1
|
||||
// ab jetzt gilt der Marker der K30, nicht mehr der der K29 (k30Gesendet: der K29-Marker ist älter als disabledAt, kein Zurücksetzen nötig)
|
||||
return { aktion: 'k30', doc: aus, lage }
|
||||
}
|
||||
case 'loeschen':
|
||||
await loesche(frisch, ctx)
|
||||
return null
|
||||
case 'zurueck': {
|
||||
// Löschtag erreicht und die Daten sind schon weg (ein früherer Lauf wurde nach dem Löschen unterbrochen): nichts einzuschalten
|
||||
if (heute(now) >= loeschTag(frisch) && !(await datenDa(frisch.$id))) {
|
||||
await loesche(frisch, ctx)
|
||||
return null
|
||||
}
|
||||
const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`)
|
||||
const zurueck = frisch.activatedAt
|
||||
? await activate(frisch.$id, { adminNote: note, extra: ZEITEN_AKTIVIERUNG_FELDER })
|
||||
: await updateDocument(portalFeatures(), frisch.$id, {
|
||||
...stateFields('setup'),
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
aboEndeMailAt: null,
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: note,
|
||||
})
|
||||
erg.wiederEin += 1
|
||||
return { aktion, doc: zurueck, lage, hatteMail }
|
||||
}
|
||||
case 'zurueck_ohne_neubau': {
|
||||
const zurueck = await updateDocument(portalFeatures(), frisch.$id, {
|
||||
aboEndeAt: null,
|
||||
aboEndeMailAt: null,
|
||||
behaltenSessionId: '',
|
||||
adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_OHNE_NEUBAU}`),
|
||||
})
|
||||
erg.wiederEin += 1
|
||||
return { aktion, doc: zurueck, lage, hatteMail }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Das Projekt für Neubau und Mailtext; nicht lesbar oder gelöscht ergibt null (der Neubau meldet dann 'manuell', der Name fehlt). */
|
||||
async function ladeProjekt(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) console.error(`[zeiten-abo] Projekt ${projectId} nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
return null
|
||||
}
|
||||
}
|
||||
const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || ''
|
||||
const preis = () => config.features.zeiten.priceLabel
|
||||
|
||||
/**
|
||||
* K29 oder K30 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt).
|
||||
* `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K30: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem
|
||||
* `now` des Laufs schreibt; ein früherer Marker gälte k30Gesendet nicht und die K30 ginge jeden Tag erneut raus).
|
||||
*/
|
||||
async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
const projekt = projektName(await ladeProjekt(doc.projectId))
|
||||
const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt }))
|
||||
if (sent) ctx.erg.mails += 1
|
||||
else if (reason !== 'keine_adresse') {
|
||||
ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach
|
||||
return
|
||||
}
|
||||
const marker = nichtVor && zeitMs(nichtVor) > zeitMs(ctx.jetzt) ? new Date(zeitMs(nichtVor)).toISOString() : ctx.jetzt
|
||||
await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: marker })
|
||||
}
|
||||
|
||||
/** K27 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
|
||||
async function sendeWiederMail(doc, einbau, ctx) {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
const projekt = projektName(await ladeProjekt(doc.projectId))
|
||||
const { sent, reason } = await sendeZeitenMail(customer, k27Mail({ kunde: customer, projekt, einbau, wieder: true }))
|
||||
if (sent) ctx.erg.mails += 1
|
||||
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K29 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
|
||||
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K30 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
|
||||
* und K27 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K27 "wieder" mit einbau null.
|
||||
*/
|
||||
async function danach({ aktion, doc, lage, hatteMail }, ctx) {
|
||||
switch (aktion) {
|
||||
case 'k29':
|
||||
await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
|
||||
return
|
||||
case 'k30':
|
||||
// Erst das Ersatz-Ticket, dann K30 mit Marker. Scheitert das Ticket, gibt es weder K30 noch Marker: der nächste Lauf wiederholt beides
|
||||
// (das Ticket ist über den Hash idempotent, die K30 ging noch nicht raus und geht so höchstens einmal raus)
|
||||
if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') {
|
||||
ctx.erg.fehler += 1
|
||||
return
|
||||
}
|
||||
await sendeMitMarker(doc, ({ kunde, projekt }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt)
|
||||
return
|
||||
case 'zurueck': {
|
||||
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
|
||||
try {
|
||||
await updateDocument(portalFeatures(), doc.$id, { einbau })
|
||||
} catch (err) {
|
||||
console.error(`[zeiten-abo] einbau für Funktion ${doc.$id} nicht gespeichert: ${fehlerText(err)}`)
|
||||
}
|
||||
if (hatteMail) await sendeWiederMail(doc, einbau, ctx)
|
||||
return
|
||||
}
|
||||
case 'zurueck_ohne_neubau':
|
||||
if (hatteMail) await sendeWiederMail(doc, null, ctx)
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Dokument: unter der Sperre schreiben, danach die Folgen. Wirft bei Fehlern (der Aufrufer zählt fehler). */
|
||||
async function bearbeiteDokument(doc, ctx) {
|
||||
const folge = await withLocks([`feature-admin:${doc.projectId}`], () => unterSperre(doc, ctx))
|
||||
if (folge) await danach(folge, ctx)
|
||||
}
|
||||
|
||||
async function bearbeiteKunde(customerId, dokumente, ctx) {
|
||||
const { erg, now } = ctx
|
||||
const gekoppelt = dokumente.filter(istGekoppelt)
|
||||
if (gekoppelt.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen
|
||||
|
||||
let lage
|
||||
try {
|
||||
lage = await ctx.memo.lage(customerId)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
|
||||
return
|
||||
}
|
||||
erg.geprueft += gekoppelt.length
|
||||
|
||||
for (const doc of gekoppelt) {
|
||||
// je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf
|
||||
try {
|
||||
if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage
|
||||
await bearbeiteDokument(doc, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Täglicher Abgleich von Öffnungszeiten live mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie
|
||||
* alle Termine-Jobs); jedes Problem steht im Log und in `fehler`.
|
||||
* @param {Date} now Zeitpunkt des Laufs (die Tage zählen nach dem Berliner Kalendertag von now)
|
||||
* @returns {Promise<{ geprueft: number, aboEnde: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>}
|
||||
* geprueft = ans Abo gekoppelte Dokumente (unlockedBy abo, setup/active oder abgeschaltet mit Grund abo_ende), deren Kunde lesbar war;
|
||||
* aboEnde = in diesem Lauf als beendet erkannt (Schritt 1); abgeschaltet = in diesem Lauf abgeschaltet (2); wiederEin = in diesem Lauf
|
||||
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K27 "wieder", K29 und
|
||||
* K30; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
|
||||
*/
|
||||
export async function gleicheZeitenAboAb(now) {
|
||||
pruefeNow(now, 'gleicheZeitenAboAb')
|
||||
const erg = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
|
||||
try {
|
||||
let dokumente
|
||||
try {
|
||||
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.notEqual('state', 'off')])
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`)
|
||||
return erg
|
||||
}
|
||||
const nachKunde = new Map()
|
||||
for (const doc of dokumente) {
|
||||
if (!doc.customerId || !doc.projectId) {
|
||||
// ohne Kunde gälte "kein Abo": nie abschalten oder löschen, was sich nicht zuordnen lässt
|
||||
if (unlockedByVon(doc) === 'abo') {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, [])
|
||||
nachKunde.get(doc.customerId).push(doc)
|
||||
}
|
||||
const ctx = { erg, now, jetzt: now.toISOString(), memo: aboMemo() }
|
||||
for (const [customerId, liste] of nachKunde) {
|
||||
try {
|
||||
await bearbeiteKunde(customerId, liste, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-abo] Abgleich abgebrochen: ${fehlerText(err)}`)
|
||||
}
|
||||
return erg
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { config } from '../../config.js'
|
||||
import { berlinDateTime } from '../berlinTime.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { createFeatureTicket } from '../ticketAdmin.js'
|
||||
import { ersatzHtml, wochenplanHash } from './ersatz.js'
|
||||
import { ladeZeiten, setzeErsatzHash } from './speicher.js'
|
||||
import { hauptplan } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Nach dem Abschalten von Oeffnungszeiten live (Spec 4.5): steht in den Platzhaltern der Kundenseite noch eine Ersatz-Tabelle, die
|
||||
* nicht mehr zum aktuellen Wochenplan passt, bekommt das Team ein WOMS-Ticket "Ersatz-Tabelle aktualisieren - <Projekt>" mit dem
|
||||
* aktuellen Ersatz-HTML (gehoert zum enthaltenen Einbau). Ohne Skript zeigt die Seite ab jetzt nur noch diese Tabelle.
|
||||
*
|
||||
* Aufrufer: die Team-Route (disable) und spaeter der Abo-Abgleich (Abo-Ende). Aufruf NACH dem Speichern des Zustands und ausserhalb
|
||||
* jeder Sperre `feature-admin:<projekt>`: das Ticket legt eine Netzwerkanfrage an, und Sperren sind nicht wiederbetretbar.
|
||||
*/
|
||||
|
||||
const THEMA = 'Ersatz-Tabelle aktualisieren'
|
||||
|
||||
/** Text des Tickets: Kopfzeilen wie bei den Einbau-Tickets, Anlass, Aufgabe und das aktuelle Ersatz-HTML des Hauptplans. */
|
||||
function ticketDetails({ doc, project, daten, haupt, now }) {
|
||||
const projektName = project?.projectName || project?.subdomain || ''
|
||||
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
|
||||
return [
|
||||
'Funktion: zeiten',
|
||||
`Projekt: ${projektName}`,
|
||||
`Domain: ${domain}`,
|
||||
`siteKey: ${doc.siteKey ?? ''}`,
|
||||
'',
|
||||
`Öffnungszeiten live wurde am ${berlinDateTime(now).date} abgeschaltet. Der Wochenplan weicht von der Tabelle ab, die beim Einbau auf die Seite gesetzt wurde (oder der damalige Stand ist nicht bekannt).`,
|
||||
'Aufgabe: Auf der Kundenseite in jedem Platzhalter <div data-webklar-zeiten ...> die Tabelle durch die aktuelle ersetzen. Ohne Skript zeigt die Seite nur noch diese Tabelle.',
|
||||
'Weitere Pläne: WOMS → Funktionen → Ersatz-HTML kopieren.',
|
||||
'',
|
||||
haupt.kurz ? `Ersatz-HTML (Hauptplan ${haupt.kurz}):` : 'Ersatz-HTML (Hauptplan):',
|
||||
ersatzHtml(daten, haupt),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Das Projekt des Dokuments; ein geloeschtes Projekt (404) ist kein Fehler, der Name fehlt dann im Ticket. */
|
||||
async function ladeProjekt(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefen, ob die Ersatz-Tabelle der Seite noch zum Wochenplan passt, und sonst das Ticket anlegen.
|
||||
* Ruling R23: ein leerer oder fehlender gemerkter Hash (ersatzHash) gilt als "unbekannt", also wie geaendert, sobald Zeiten da sind
|
||||
* (schlimmstenfalls ein unnoetiges Ticket). Nach dem Ticket wird der Hash auf den aktuellen Stand gesetzt: er gehoert dann zur Tabelle,
|
||||
* die das Team einsetzt, und ein zweiter Aufruf (Doppelklick, spaeterer Lauf) legt kein zweites Ticket an. Scheitert das Ticket,
|
||||
* bleibt der Hash, und der naechste Aufruf versucht es erneut.
|
||||
* @param {{ $id: string, customerId?: string, projectId?: string, siteKey?: string }} doc portalFeatures-Dokument der Zeiten
|
||||
* (aus disable(): der Stand nach dem Abschalten)
|
||||
* @param {{ now?: Date }} [options]
|
||||
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>} angelegt: Ticket entstanden; aktuell: nichts zu tun (keine Zeiten oder Hash gleich);
|
||||
* fehler: nicht pruefbar oder Ticket nicht angelegt (im Log ohne Kundendaten). Wirft nie.
|
||||
*/
|
||||
export async function nachAbschaltung(doc, { now = new Date() } = {}) {
|
||||
try {
|
||||
if (typeof doc?.$id !== 'string' || doc.$id === '') throw new TypeError('Dokument ohne $id')
|
||||
const gespeichert = await ladeZeiten(doc.$id)
|
||||
const daten = gespeichert?.daten ?? null
|
||||
const haupt = hauptplan(daten)
|
||||
// ohne Zeiten wurde nie eine Tabelle zum Ersetzen vorbereitet
|
||||
if (!haupt) return 'aktuell'
|
||||
const hash = wochenplanHash(daten)
|
||||
if (gespeichert.ersatzHash !== '' && gespeichert.ersatzHash === hash) return 'aktuell'
|
||||
|
||||
const project = await ladeProjekt(doc.projectId)
|
||||
const projektName = project?.projectName || project?.subdomain || ''
|
||||
await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: 'zeiten',
|
||||
siteKey: doc.siteKey,
|
||||
topic: projektName ? `${THEMA} – ${projektName}` : THEMA,
|
||||
details: ticketDetails({ doc, project, daten, haupt, now }),
|
||||
})
|
||||
|
||||
// Das Ticket steht; scheitert nur das Merken, entsteht beim naechsten Abschalten ein zweites (Log, kein Fehler)
|
||||
try {
|
||||
await setzeErsatzHash(doc, hash)
|
||||
} catch (err) {
|
||||
console.error(`[zeiten] Ersatz-Hash für Projekt ${doc.projectId} nach dem Ticket nicht gemerkt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
return 'angelegt'
|
||||
} catch (err) {
|
||||
// nie Kundendaten ins Log: Art und Statuscode, keine Meldung (Appwrite-Meldungen koennen Feldinhalte nennen)
|
||||
console.error(`[zeiten] Ersatz-Ticket nach dem Abschalten (Projekt ${doc?.projectId ?? '?'}): ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.replace(/\s+/g, ' ').trim())
|
||||
return 'fehler'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { config } from '../../config.js'
|
||||
import { aboLage } from '../aboStatus.js'
|
||||
import { getDocument, updateDocument } from '../appwriteAdmin.js'
|
||||
import { featureConfig } from '../featureRegistry.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendCustomerMail } from '../mailer.js'
|
||||
import { bestellMail, customerOf, getFeatureDoc, invoiceOf, meldeErstattung, notizVorn, projectName, stateFields, unlockedByVon, unmatchedNotice } from '../siteFeatures.js'
|
||||
import { createFeatureCheckout, retrieveCheckoutSession } from '../stripeBilling.js'
|
||||
import { stosseNeubauAn } from './neubau.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
|
||||
/**
|
||||
* "Fuer 30 EUR behalten": nach dem Ende des Wartungs-Abos behaelt der Kunde Oeffnungszeiten live fuer einmalig 30 EUR, ohne neuen Einbau
|
||||
* und ohne neues Ticket (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 3.3). Eigener Modus neben Bestellen und Einschalten:
|
||||
* markOrdered und markPaid antworten bei setup/active/disabled mit 409 bzw. als Anomalie.
|
||||
*
|
||||
* starteBehalten Stripe-Checkout mit zweck 'behalten' und behaltenSessionId am Dokument. Der Zustand bleibt unveraendert, die Leisten
|
||||
* bleiben waehrend der Zahlung live. Unter der Sperre `feature-order:<kunde>:<projekt>` (wie Checkout und Einschalten).
|
||||
* bucheBehalten Webhook: passt die Sitzung zu behaltenSessionId, wird das Dokument unter `feature-admin:<projekt>` zum Kauf (unlockedBy
|
||||
* kauf, Frist weg) und, wenn es abgeschaltet war, wieder eingeschaltet (Neubau nach der Sperre). K15 mit Behalten-Wortlaut
|
||||
* nach der Sperre. Passt sie nicht (schon behalten, Daten geloescht, vom Team abgeschaltet): nichts wird gebucht, T19
|
||||
* "Erstattung faellig" geht nach der Sperre ans Team.
|
||||
* verwerfeBehalten Webhook: Sitzung abgelaufen oder SEPA fehlgeschlagen => behaltenSessionId wird frei.
|
||||
*
|
||||
* Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn, K15 und T19 laufen nie innerhalb von `feature-admin` oder `feature-order`.
|
||||
*/
|
||||
|
||||
const KEY = 'zeiten'
|
||||
|
||||
const konflikt = (text) => Object.assign(new Error(text), { status: 409 })
|
||||
|
||||
const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.'
|
||||
const ABO_LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
const ABO_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'
|
||||
const ZAHLUNG_LAEUFT = 'Deine Zahlung wird gerade bestätigt. Bitte schau in einigen Minuten noch einmal vorbei.'
|
||||
const STRIPE_NICHT_ERREICHBAR = 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
|
||||
|
||||
/** Sind Eingaben des Kunden gespeichert (oder nur Team-Einstellungen bzw. gar nichts)? Lesefehler werden weitergereicht. */
|
||||
async function datenDa(featureId) {
|
||||
return (await ladeZeiten(featureId))?.daten != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf der Kunde die Funktion jetzt behalten? In Einrichtung oder aktiv: sobald das Ende des Abos erkannt ist (aboEndeAt). Abgeschaltet:
|
||||
* nur nach dem Abo-Ende (disabledReason abo_ende, nicht: Team, vor der Einrichtung, Kunde geloescht) und solange die Eingaben noch da sind.
|
||||
*/
|
||||
async function behaltenMoeglich(doc) {
|
||||
if (!doc) return false
|
||||
if (doc.state === 'setup' || doc.state === 'active') return Boolean(doc.aboEndeAt)
|
||||
if (doc.state === 'disabled' && doc.disabledReason === 'abo_ende') return datenDa(doc.$id)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft das Wartungs-Abo wieder, ist die Funktion darin enthalten und der Abo-Abgleich nimmt das Abo-Ende zurück (zeiten/aboAbgleich.js):
|
||||
* in der Zeit dazwischen soll niemand 30 € für etwas bezahlen, das ohnehin enthalten ist (Ruling R25). Frisch gelesen (aboLage direkt, nie
|
||||
* ein Zwischenspeicher); ein Lesefehler ist 503 (fail-closed: unklar, ob das Abo läuft), im Log stehen nur Art und Status.
|
||||
*/
|
||||
async function pruefeAboLaeuftNicht(customerId) {
|
||||
let lage
|
||||
try {
|
||||
lage = await aboLage(customerId)
|
||||
} catch (err) {
|
||||
console.error(`[zeiten] Behalten: Wartungs-Abo nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
throw Object.assign(new Error(ABO_NICHT_PRUEFBAR), { status: 503 })
|
||||
}
|
||||
if (lage.laeuft === true) throw konflikt(ABO_LAEUFT)
|
||||
}
|
||||
|
||||
/** Die gespeicherte Behalten-Sitzung bei Stripe: offen => { url }; abgelaufen oder unbekannt => null (neue Sitzung); sonst 409 bzw. 503. */
|
||||
async function offeneSitzung(sessionId) {
|
||||
let session
|
||||
try {
|
||||
session = await retrieveCheckoutSession(sessionId)
|
||||
} catch (err) {
|
||||
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
|
||||
console.warn(`[zeiten] Behalten: Sitzung nicht abrufbar: ${err?.name ?? 'Fehler'} ${err?.statusCode ?? err?.status ?? ''}`.trim())
|
||||
throw Object.assign(new Error(STRIPE_NICHT_ERREICHBAR), { status: 503 })
|
||||
}
|
||||
if (session.status === 'open' && session.url) return { url: session.url }
|
||||
if (session.status === 'expired') return null
|
||||
// abgeschlossen: Zahlung eingegangen oder (SEPA) unterwegs; der Webhook bucht oder gibt die Sitzung frei
|
||||
throw konflikt(ZAHLUNG_LAEUFT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Behalten-Checkout starten (Spec 3.3). Die Vorpruefungen der Route (Kundenansicht, Zustimmung, Projekt, Schalter, Unterlagen) liegen
|
||||
* davor; hier entscheidet das Dokument. Unter der Sperre `feature-order:<kunde>:<projekt>`: zwei schnelle Klicks legen eine Sitzung an, ein
|
||||
* zweiter Klick bekommt die offene Zahlungsseite zurueck (eine abgelaufene wird ersetzt). Reihenfolge wie im Checkout: erst die Stripe-Sitzung
|
||||
* (der Nachweis traegt ihre ID, sie ist der Anker der Widerrufsfrist), dann der Nachweis, dann behaltenSessionId; scheitert der Nachweis,
|
||||
* gibt es keine URL und kein behaltenSessionId (die Sitzung verfaellt nach 24 h). Der Zustand des Dokuments aendert sich nicht.
|
||||
* @param {{ customer: { $id: string }, projectId: string, nachweis: (sessionId: string) => Promise<unknown>, now?: Date }} p
|
||||
* customer: Kundendokument; nachweis: speichert den Zustimmungsnachweis zur Stripe-Sitzung; now: Zeit des Klicks (Metadaten consentAt)
|
||||
* @returns {Promise<{ url: string }>}
|
||||
* @throws {Error} 409 (nicht moeglich, Wartungs-Abo laeuft wieder, Zahlung laeuft), 503 (Abo oder Stripe nicht erreichbar), sonst Fehler von Appwrite, Stripe und Nachweis
|
||||
*/
|
||||
export async function starteBehalten({ customer, projectId, nachweis, now = new Date() } = {}) {
|
||||
if (typeof customer?.$id !== 'string' || customer.$id === '') throw new TypeError('starteBehalten: customer fehlt')
|
||||
if (typeof projectId !== 'string' || projectId === '') throw new TypeError('starteBehalten: projectId fehlt')
|
||||
if (typeof nachweis !== 'function') throw new TypeError('starteBehalten: nachweis fehlt')
|
||||
const customerId = customer.$id
|
||||
|
||||
return withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, KEY)
|
||||
if (!(await behaltenMoeglich(doc))) throw konflikt(NICHT_MOEGLICH)
|
||||
// läuft das Abo wieder (der Abgleich hat das Abo-Ende noch nicht zurückgenommen), gibt es weder eine neue noch die alte Zahlungsseite
|
||||
await pruefeAboLaeuftNicht(customerId)
|
||||
|
||||
// eine schon begonnene Zahlung: dieselbe Seite nochmal, nie eine zweite Sitzung daneben
|
||||
if (doc.behaltenSessionId) {
|
||||
const offen = await offeneSitzung(doc.behaltenSessionId)
|
||||
if (offen) return offen
|
||||
}
|
||||
|
||||
const created = await createFeatureCheckout(customer, { projectId, key: KEY, consentAt: now.toISOString(), zweck: 'behalten' })
|
||||
await nachweis(created.id)
|
||||
await updateDocument(config.collections.portalFeatures, doc.$id, { behaltenSessionId: created.id })
|
||||
return { url: created.url }
|
||||
})
|
||||
}
|
||||
|
||||
/** Passt die bezahlte Sitzung (mit passender behaltenSessionId) noch zum Zustand? Abgeschaltet nur nach dem Abo-Ende und mit Eingaben. */
|
||||
async function passtZumZustand(doc) {
|
||||
if (doc.state === 'setup' || doc.state === 'active') return true
|
||||
if (doc.state === 'disabled' && doc.disabledReason === 'abo_ende') return datenDa(doc.$id)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Zeitpunkt des Klicks aus den Metadaten der Sitzung (consentAt), sonst jetzt. */
|
||||
function bestelltAm(session) {
|
||||
const ms = Date.parse(session.metadata?.consentAt)
|
||||
return new Date(Number.isNaN(ms) ? Date.now() : ms).toISOString()
|
||||
}
|
||||
|
||||
/** Das Projekt fuer den Neubau; nicht lesbar oder geloescht ergibt null (der Neubau meldet dann manuell). */
|
||||
async function ladeProjekt(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) console.error(`[zeiten] Behalten: Projekt ${projectId} fuer den Neubau nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchung unter `feature-admin:<projekt>`: das Dokument zum Kauf machen. Schon gebucht (stripeSessionId ist diese Sitzung, Wiederholung des
|
||||
* Webhooks) => { schonGebucht }. Passt die Sitzung nicht => nichts gebucht; der Vermerk mit der Sitzung (einmal je Sitzung, der Schutz vor
|
||||
* einem zweiten T19 bei Wiederholung) und das Zuruecksetzen eines frueheren Markers 'gemeldet' (sonst verschluckte meldeEinmal dieses T19)
|
||||
* stehen in einem Schreibzugriff.
|
||||
*/
|
||||
async function buche(session, { customerId, projectId, invoice }) {
|
||||
const feature = featureConfig(KEY)
|
||||
return withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, KEY)
|
||||
if (!doc) return { fall: 'unpassend', doc: null }
|
||||
if (doc.stripeSessionId === session.id && unlockedByVon(doc) === 'kauf') return { fall: 'behalten', doc, schonGebucht: true }
|
||||
|
||||
const jetzt = new Date().toISOString()
|
||||
if (doc.behaltenSessionId !== session.id || !(await passtZumZustand(doc))) {
|
||||
if (String(doc.adminNote || '').includes(session.id)) return { fall: 'unpassend', doc }
|
||||
const vermerkt = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
adminNote: notizVorn(doc.adminNote, `${jetzt} System: Behalten-Zahlung (Sitzung ${session.id}) passt nicht, Erstattung fällig`),
|
||||
...(doc.zahlungMeldung === 'gemeldet' ? { zahlungMeldung: '' } : {}),
|
||||
})
|
||||
return { fall: 'unpassend', doc: vermerkt }
|
||||
}
|
||||
|
||||
const warAus = doc.state === 'disabled'
|
||||
const gebucht = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
// abgeschaltet: zurueck auf active, wenn die Funktion schon einmal freigeschaltet war (activatedAt, das Ticket war geschlossen), sonst
|
||||
// setup; Grund und Marker der Abschaltung enden damit (wie activate), activatedAt bleibt. zahlungMeldung bleibt unberuehrt.
|
||||
...(warAus ? { ...stateFields(doc.activatedAt ? 'active' : 'setup'), disabledAt: null, disabledReason: null, aboEndeMailAt: null } : {}),
|
||||
featureKey: KEY,
|
||||
unlockedBy: 'kauf',
|
||||
unlockedByPurchase: true,
|
||||
purchaseStatus: 'paid',
|
||||
orderedAt: bestelltAm(session),
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
stripeSessionId: session.id,
|
||||
stripeInvoiceId: invoice.invoiceId || '',
|
||||
invoiceUrl: invoice.invoiceUrl || '',
|
||||
adminNote: notizVorn(doc.adminNote, `${jetzt} System: Nach dem Abo-Ende für ${feature.priceLabel} behalten (Sitzung ${session.id})`),
|
||||
})
|
||||
return { fall: 'behalten', doc: gebucht, warAus }
|
||||
})
|
||||
}
|
||||
|
||||
/** Nach der Buchung, ausserhalb der Sperre: Neubau (nur wenn die Funktion abgeschaltet war), dann K15. Wirft nie: gebucht ist gebucht. */
|
||||
async function nachBuchung(doc, { warAus, invoice }) {
|
||||
if (warAus) {
|
||||
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
|
||||
try {
|
||||
await updateDocument(config.collections.portalFeatures, doc.$id, { einbau })
|
||||
} catch (err) {
|
||||
console.error(`[zeiten] Behalten: einbau für Projekt ${doc.projectId} nicht gespeichert: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
}
|
||||
try {
|
||||
const customer = await customerOf(doc.customerId)
|
||||
if (!customer?.email) return
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
bestellMail(featureConfig(KEY), { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl: invoice.invoiceUrl, behalten: true })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[zeiten] K15 (Behalten) fehlgeschlagen:', err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine bezahlte Behalten-Sitzung verbuchen (Webhook-Zweig zweck 'behalten', vor markPaid und adoptPaidSession); idempotent.
|
||||
* @param {{ id: string, invoice?: unknown, metadata?: { customerId?: string, projectId?: string, featureKey?: string, consentAt?: string } }} session
|
||||
* bezahlte Checkout-Sitzung (checkout.session.completed mit payment_status paid bzw. async_payment_succeeded)
|
||||
* @returns {Promise<{ fall: 'behalten'|'unpassend' }>} behalten: gebucht (auch bei einer Wiederholung); unpassend: nichts gebucht, das Team
|
||||
* bekommt T19 (bzw. T16 ungeklaert, wenn es zur Sitzung gar kein Dokument gibt)
|
||||
* @throws {Error} bei Lesefehlern und Schreibfehlern der Buchung sowie wenn T19 weder raus ist noch schon draussen war: der Webhook
|
||||
* antwortet 500 und Stripe wiederholt
|
||||
*/
|
||||
export async function bucheBehalten(session) {
|
||||
const { customerId, projectId, featureKey } = session?.metadata ?? {}
|
||||
if (typeof session?.id !== 'string' || session.id === '' || featureKey !== KEY || !customerId || !projectId) {
|
||||
await meldeOhneDokument(session)
|
||||
return { fall: 'unpassend' }
|
||||
}
|
||||
// die Rechnung vor der Sperre (Netzwerkanfrage); nicht lesbar => ohne Link
|
||||
const invoice = await invoiceOf(session.invoice)
|
||||
|
||||
const ergebnis = await buche(session, { customerId, projectId, invoice })
|
||||
if (ergebnis.schonGebucht) return { fall: 'behalten' }
|
||||
if (ergebnis.fall === 'behalten') {
|
||||
await nachBuchung(ergebnis.doc, { warAus: ergebnis.warAus, invoice })
|
||||
return { fall: 'behalten' }
|
||||
}
|
||||
|
||||
// unpassend, ausserhalb der Sperre: T19 an das Team
|
||||
if (!ergebnis.doc) {
|
||||
await meldeOhneDokument(session)
|
||||
return { fall: 'unpassend' }
|
||||
}
|
||||
console.error(
|
||||
`[funktionen] BEZAHLTE Behalten-Sitzung ${session.id} passt nicht zur Funktion zeiten (Kunde ${customerId}, Projekt ${projectId}, Zustand ${ergebnis.doc.state}) - nichts gebucht, Erstattung wird gemeldet`
|
||||
)
|
||||
const customer = await customerOf(customerId).catch((err) => {
|
||||
console.error('[funktionen] Kunde für die Zahlungsmeldung nicht lesbar:', err.message)
|
||||
return null
|
||||
})
|
||||
// Die Zahlung gehoert zu keinem Ticket und nicht zur Rechnung am Dokument: T19 sagt "Zahlung nach dem Abo-Ende" und nennt deren Rechnung
|
||||
const gemeldet = await meldeErstattung(ergebnis.doc, { customer, sitzung: session.id, nachAboEnde: true, ticketOffen: false, rechnungUrl: invoice.invoiceUrl })
|
||||
if (!gemeldet.sent && !gemeldet.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (Behalten passt nicht) nicht gesendet`)
|
||||
return { fall: 'unpassend' }
|
||||
}
|
||||
|
||||
/** Bezahlte Sitzung ohne Dokument (oder ohne verwertbare Metadaten): T16 "ungeklaert", wie adoptPaidSession; ein Fehler dabei wird nur geloggt. */
|
||||
async function meldeOhneDokument(session) {
|
||||
console.error(
|
||||
`[funktionen] BEZAHLTE Behalten-Sitzung ${session?.id} ohne passende Funktion (Kunde ${session?.metadata?.customerId}, Projekt ${session?.metadata?.projectId}) - bitte von Hand pruefen`
|
||||
)
|
||||
try {
|
||||
await unmatchedNotice(session)
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behalten-Sitzung abgelaufen oder die SEPA-Zahlung fehlgeschlagen: behaltenSessionId wird frei, wenn es diese Sitzung ist (Stripe meldet
|
||||
* eine fehlgeschlagene Lastschrift als abgeschlossene Sitzung, der Kunde kaeme sonst nie mehr zu einer neuen). Der Zustand bleibt.
|
||||
* @returns {Promise<boolean>} true, wenn die Sitzung freigegeben wurde
|
||||
*/
|
||||
export async function verwerfeBehalten(session) {
|
||||
const { customerId, projectId, featureKey } = session?.metadata ?? {}
|
||||
if (typeof session?.id !== 'string' || session.id === '' || featureKey !== KEY || !customerId || !projectId) return false
|
||||
return withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, KEY)
|
||||
if (!doc || doc.behaltenSessionId !== session.id) return false
|
||||
await updateDocument(config.collections.portalFeatures, doc.$id, { behaltenSessionId: '' })
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { neuerLru } from './lru.js'
|
||||
|
||||
/**
|
||||
* Speicher der oeffentlichen Zeiten-Antwort (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4: 30 Sekunden,
|
||||
* als LRU hoechstens 500 Eintraege). Schluessel = siteKey. Wer Zeiten oder Team-Einstellungen schreibt, ruft vergissZeiten;
|
||||
* wer eine Antwort baut, legt sie mit zeitenCache.setze(siteKey, antwort, jetztMs) ab und holt sie mit hole (undefined = nicht da).
|
||||
* Ein Eintrag verfaellt 30 Sekunden nach dem Setzen, auch ohne Speichern (Zustandswechsel der Funktion wirken so mit hoechstens 30 s Verzoegerung).
|
||||
*/
|
||||
export const zeitenCache = neuerLru({ max: 500, ttlMs: 30000 })
|
||||
|
||||
/** Entfernt die Antwort dieses siteKeys (nach jedem Schreiben). Unbekannte oder fehlende Schluessel schaden nicht. */
|
||||
export function vergissZeiten(siteKey) {
|
||||
zeitenCache.vergiss(siteKey)
|
||||
}
|
||||
|
||||
export function resetZeitenCacheForTests() {
|
||||
zeitenCache.leere()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getFeatureDoc } from '../siteFeatures.js'
|
||||
|
||||
/**
|
||||
* Einbau des Zeiten-Skripts beim Deploy (Spec 4.6, Runner-Feld `zeiten`).
|
||||
* Darf weder neubau.js noch previewRebuild.js importieren: previewDeploy laedt diese Datei, previewRebuild laedt
|
||||
* previewDeploy (Zyklus).
|
||||
*/
|
||||
|
||||
/** Zustaende, in denen die Leisten ausgespielt werden (wie teilweiseLive im Register). */
|
||||
const AUSGEBEND = ['setup', 'active']
|
||||
|
||||
/** Subdomain auf der Sperrliste ZEITEN_KEIN_NEUBAU (Einbau von Hand, weder Neubau noch Einsetzen)? */
|
||||
export function aufSperrliste(project) {
|
||||
const subdomain = String(project?.subdomain || '').trim().toLowerCase()
|
||||
return subdomain !== '' && config.zeiten.keinNeubau.includes(subdomain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiten-Feld fuer den Build-Auftrag: { siteKey } nur bei Funktion in setup/active und Projekt nicht auf der
|
||||
* Sperrliste, sonst null. isPublic ist nicht noetig. Wirft nie: der Build darf an den Zeiten nicht scheitern.
|
||||
*/
|
||||
export async function buildZeiten(project) {
|
||||
try {
|
||||
if (!project?.$id || !project.customerId || aufSperrliste(project)) return null
|
||||
const feature = await getFeatureDoc(project.customerId, project.$id, 'zeiten')
|
||||
if (!feature || !AUSGEBEND.includes(feature.state) || !feature.siteKey) return null
|
||||
return { siteKey: feature.siteKey }
|
||||
} catch (err) {
|
||||
console.warn(`[zeiten] Einbau-Abfrage für Projekt ${project?.$id} fehlgeschlagen: ${err?.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, updateDocument } from '../appwriteAdmin.js'
|
||||
import { pruefeAboFuerEinschalten } from '../featureOrder.js'
|
||||
import { featureConfig, ticketTexte } from '../featureRegistry.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { customerOf, ensureFeatureDoc, getFeatureDoc, stateFields, ticketWoid, unlockedByVon } from '../siteFeatures.js'
|
||||
import { createFeatureTicket } from '../ticketAdmin.js'
|
||||
import { k27Mail, sendeZeitenMail } from './mails.js'
|
||||
import { stosseNeubauAn } from './neubau.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
import { hauptplan, statusZu } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Gratisweg "Einschalten" von Oeffnungszeiten live im Wartungs-Abo (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 3.2)
|
||||
* und die Selbstheilung der Schritte danach. Vorbild ist activateAnalyse (analyse.js).
|
||||
*
|
||||
* einschaltenZeiten Schritte 1-4 unter der Sperre `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout, damit sich
|
||||
* Bestellen und Einschalten ausschliessen): Abo frisch pruefen, Zustand pruefen (idempotent), Nachweis speichern,
|
||||
* Dokument auf setup. Danach, ohne diese Sperre: holeZeitenNach (Neubau, Ticket, K27).
|
||||
* holeZeitenNach Schritte 5 und 6, jeder fuer sich nur, wenn er noch fehlt: Neubau (einbau), Einbau-Ticket, K27, dann einschaltMailAt.
|
||||
* Eigene Sperre `zeiten-nachholen:<dokument>`: ein Doppelklick und der 5-Minuten-Job legen nie zwei Tickets an.
|
||||
*
|
||||
* Sperren (keyedLock) sind nicht wiederbetretbar: stosseNeubauAn nimmt seine eigene Sperre `zeiten-neubau`, darum laufen Neubau, Ticket
|
||||
* und Mail nie innerhalb von `feature-order`. Aufrufer, die `feature-order` oder `zeiten-nachholen` halten, duerfen diese Funktionen
|
||||
* nicht aufrufen.
|
||||
*/
|
||||
|
||||
const KEY = 'zeiten'
|
||||
|
||||
/** Einhaengepunkt fuer Tests: die Uhr der Projektliste (GET /api/funktionen), die den Kurzstatus der Website berechnet. */
|
||||
export const einschaltenDeps = { jetzt: () => new Date() }
|
||||
|
||||
const NOTIZ_MAX = 300
|
||||
const NICHT_FERTIG = 'Öffnungszeiten live ist eingeschaltet, aber das Einrichten ist noch nicht fertig. Bitte versuch es in einer Minute noch einmal.'
|
||||
|
||||
const konflikt = (text) => Object.assign(new Error(text), { status: 409 })
|
||||
|
||||
/** Notiz am Dokument: die der Route (staff.adminNote) oder "<Zeit> <Name>: Eingeschaltet ohne Zahlung" vor der bisherigen Notiz. */
|
||||
function notizDesTeams(staff, bisherige, now) {
|
||||
if (typeof staff?.adminNote === 'string' && staff.adminNote) return staff.adminNote.slice(0, NOTIZ_MAX)
|
||||
if (!staff?.name) return null
|
||||
const eintrag = `${now.toISOString()} ${staff.name}: Eingeschaltet ohne Zahlung`
|
||||
return (bisherige ? `${eintrag} | ${bisherige}` : eintrag).slice(0, NOTIZ_MAX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live fuer ein Projekt einschalten (Spec 3.2, Schritte 1 bis 6).
|
||||
* @param {{ customerId: string, projectId: string, by?: 'kunde'|'team', staff?: { name?: string, adminNote?: string }|null,
|
||||
* nachweis?: (() => Promise<unknown>)|null, now?: Date }} p
|
||||
* by 'kunde': Abo frisch pruefen (kein_abo/gekuendigt/nicht_aktiv 409, Lesefehler 503), unlockedBy 'abo', K27 geht an den Kunden.
|
||||
* by 'team': keine Abo-Pruefung, unlockedBy 'team' (ein bisher gekauftes Dokument bleibt kauf), keine K27; staff ergibt die Notiz am Dokument.
|
||||
* nachweis: speichert den Zustimmungsnachweis (Schritt 3, nur wenn wirklich eingeschaltet wird); wirft er, bleibt der Zustand unveraendert.
|
||||
* @returns {Promise<{ doc: object, changed: boolean }>} doc: das Dokument nach dem Nachholen; changed: false bei idempotentem Aufruf
|
||||
* @throws {Error} 409 (Abo, anderer Zustand), 503 (Abo nicht lesbar; oder Zustand gespeichert, aber Neubau/Ticket nicht fertig: erneut aufrufen)
|
||||
*/
|
||||
export async function einschaltenZeiten({ customerId, projectId, by = 'kunde', staff = null, nachweis = null, now = new Date() } = {}) {
|
||||
if (by !== 'kunde' && by !== 'team') throw new TypeError(`einschaltenZeiten: by muss kunde oder team sein, nicht ${String(by)}`)
|
||||
const feature = featureConfig(KEY)
|
||||
|
||||
const { doc, changed } = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
// 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es
|
||||
if (by === 'kunde') await pruefeAboFuerEinschalten(customerId)
|
||||
|
||||
// 2 Zustand: schon eingeschaltet (setup/active) ist idempotent; sonst nur aus off (der Kunde) bzw. off/ordered/disabled (das Team)
|
||||
const bisher = await getFeatureDoc(customerId, projectId, KEY)
|
||||
if (bisher?.state === 'setup' || bisher?.state === 'active') {
|
||||
if (by === 'team' || unlockedByVon(bisher) === 'abo') return { doc: bisher, changed: false }
|
||||
throw konflikt(feature.orderedConflictMessage)
|
||||
}
|
||||
if (by === 'kunde' && bisher?.state === 'ordered') throw konflikt(feature.orderedConflictMessage)
|
||||
if (by === 'kunde' && bisher?.state === 'disabled') throw konflikt(feature.disabledMessage)
|
||||
|
||||
// 3 Zustimmung speichern (ohne Stripe-Sitzung), vor dem Zustand: scheitert sie, bleibt alles wie es war
|
||||
if (nachweis) await nachweis()
|
||||
|
||||
// 4 Dokument auf setup. Reste einer frueheren Abschaltung oder eines Abo-Endes gehoeren zum alten Lauf und enden hier;
|
||||
// einbau beginnt leer, der Neubau (holeZeitenNach) setzt es neu.
|
||||
const vorhanden = bisher ?? (await ensureFeatureDoc(customerId, projectId, KEY))
|
||||
const notiz = by === 'team' ? notizDesTeams(staff, vorhanden.adminNote, now) : null
|
||||
// Ein gekauftes Dokument (bisher kauf), das das Team wieder einschaltet, bleibt gekauft: unlockedBy/unlockedByPurchase ordnen den
|
||||
// Widerruf zu (paidFeatureOrders). Neubau und Ticket kennt dort markPaid, nicht das Nachholen (holeZeitenNach laesst kauf aus): einbau
|
||||
// bleibt darum stehen, statt auf leer gesetzt zu werden, ohne dass es je neu gefuellt wuerde.
|
||||
const gekauft = by === 'team' && unlockedByVon(bisher) === 'kauf'
|
||||
const gespeichert = await updateDocument(config.collections.portalFeatures, vorhanden.$id, {
|
||||
...stateFields('setup'),
|
||||
featureKey: KEY,
|
||||
unlockedBy: gekauft ? 'kauf' : by === 'kunde' ? 'abo' : 'team',
|
||||
unlockedByPurchase: gekauft,
|
||||
...(gekauft ? {} : { einbau: '' }),
|
||||
aboEndeAt: null,
|
||||
behaltenSessionId: '',
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
aboEndeMailAt: null,
|
||||
...(notiz ? { adminNote: notiz } : {}),
|
||||
})
|
||||
return { doc: gespeichert, changed: true }
|
||||
})
|
||||
|
||||
// 5 und 6 nach der Sperre: Neubau, Ticket, K27
|
||||
try {
|
||||
return { doc: await holeZeitenNach(doc, { now }), changed }
|
||||
} catch (err) {
|
||||
// der Zustand ist gespeichert; ein erneuter Aufruf (oder der 5-Minuten-Job) holt nach. Im Log Art und Meldung, nie beim Kunden.
|
||||
console.error(`[zeiten] Einschalten für Projekt ${projectId}: Nachholen fehlgeschlagen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.message ?? ''}`.trim())
|
||||
throw Object.assign(new Error(NICHT_FERTIG), { status: 503, cause: err })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlende Schritte des Einschaltens nachholen (Spec 3.2 Schritt 5/6 und Selbstheilung): fuer ein Dokument im Zustand setup, das nicht
|
||||
* vom Kauf stammt (unlockedBy kauf bringt markPaid in Ordnung):
|
||||
* 1. einbau leer => Neubau anstossen (stosseNeubauAn), Ergebnis in einbau
|
||||
* 2. ticketId leer => Einbau-Ticket aus dem Register (Text mit dem frischen einbau), ticketId
|
||||
* 3. unlockedBy abo und einschaltMailAt leer => K27 an den Kunden, danach einschaltMailAt. Gesendet wird erst nach dem Ticket (die Mail nennt es);
|
||||
* nicht gesendet (Fehler) laesst einschaltMailAt leer, ein Kunde ohne Adresse oder ein geloeschter Kunde bekommt einschaltMailAt (es gibt nichts zu senden).
|
||||
* Jeder Schritt erst nach dem Erfolg des vorigen. Liest das Dokument unter der Sperre frisch; andere Zustaende bleiben unberuehrt.
|
||||
* Wirft bei Lesefehlern und wenn das Ticket nicht angelegt werden kann (der Aufrufer faengt und versucht es spaeter); eine
|
||||
* nicht gesendete K27 wirft nicht.
|
||||
* @param {{ $id: string }} doc portalFeatures-Dokument der Zeiten (nur $id zaehlt)
|
||||
* @param {{ now?: Date }} [options]
|
||||
* @returns {Promise<object>} das aktuelle Dokument
|
||||
*/
|
||||
export async function holeZeitenNach(doc, { now = new Date() } = {}) {
|
||||
if (!doc?.$id) return doc
|
||||
return withLocks([`zeiten-nachholen:${doc.$id}`], async () => {
|
||||
let frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.state !== 'setup' || unlockedByVon(frisch) === 'kauf') return frisch
|
||||
const mitK27 = unlockedByVon(frisch) === 'abo'
|
||||
// nichts fehlt: weder Projekt noch Kunde lesen (der 5-Minuten-Job fragt jedes Dokument in setup)
|
||||
if (frisch.einbau && frisch.ticketId && !(mitK27 && !frisch.einschaltMailAt)) return frisch
|
||||
const feature = featureConfig(KEY)
|
||||
// das Projekt frisch: der Neubau und der Text des Tickets brauchen den Stand nach dem Speichern des Zustands
|
||||
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
||||
|
||||
if (!frisch.einbau) {
|
||||
const einbau = await stosseNeubauAn(project)
|
||||
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einbau })
|
||||
}
|
||||
|
||||
const customer = await customerOf(frisch.customerId)
|
||||
if (!frisch.ticketId) {
|
||||
const texte = await ticketTexte({ feature, doc: frisch, project, customer })
|
||||
const { ticketId } = await createFeatureTicket({ customerId: frisch.customerId, project, featureKey: KEY, siteKey: frisch.siteKey, ...texte })
|
||||
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId })
|
||||
}
|
||||
|
||||
if (mitK27 && !frisch.einschaltMailAt) {
|
||||
const nachricht = k27Mail({
|
||||
kunde: customer,
|
||||
projekt: project?.projectName || project?.subdomain || '',
|
||||
einbau: frisch.einbau,
|
||||
wieder: false,
|
||||
ticketNr: await ticketWoid(frisch.ticketId).catch(() => null),
|
||||
})
|
||||
const { sent, reason } = await sendeZeitenMail(customer, nachricht)
|
||||
if (sent || reason === 'keine_adresse') {
|
||||
frisch = await updateDocument(config.collections.portalFeatures, frisch.$id, { einschaltMailAt: now.toISOString() })
|
||||
}
|
||||
}
|
||||
return frisch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Teil der Projektliste (GET /api/funktionen) zu den Zeiten eines Projekts: { zeitenStatus, zeitenLeer }. Eine Abfrage je Projekt mit
|
||||
* Funktionsdokument (nicht je Kachel im Browser). zeitenLeer: es sind keine Plaene gespeichert (auch ohne Dokument oder im Zustand off).
|
||||
* zeitenStatus { offen, kurz, text } (Hauptplan, wie ihn die Website zeigt): nur in setup und active und nur mit gespeichertem
|
||||
* Hauptplan, sonst null. Wirft bei Lesefehlern.
|
||||
* @param {object|null|undefined} doc portalFeatures-Dokument der Zeiten
|
||||
* @param {Date} now
|
||||
*/
|
||||
export async function zeitenProjektAnsicht(doc, now) {
|
||||
if (!doc || doc.state === 'off') return { zeitenStatus: null, zeitenLeer: true }
|
||||
const daten = (await ladeZeiten(doc.$id))?.daten ?? null
|
||||
const leer = !Array.isArray(daten?.plaene) || daten.plaene.length === 0
|
||||
const haupt = !leer && (doc.state === 'setup' || doc.state === 'active') ? hauptplan(daten) : null
|
||||
if (!haupt) return { zeitenStatus: null, zeitenLeer: leer }
|
||||
const { offen, kurz, text } = statusZu(daten, haupt, now)
|
||||
return { zeitenStatus: { offen, kurz, text }, zeitenLeer: false }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { createDocument, deleteDocument, getDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
|
||||
import { customerOf } from '../siteFeatures.js'
|
||||
import { heute, plusTage } from '../termine/zeit.js'
|
||||
import { pruefeNow } from '../termine/util.js'
|
||||
import { k28Mail, sendeZeitenMail } from './mails.js'
|
||||
import { zeitenFuerKunde, zeitenModus } from './schalter.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Feiertags-Erinnerung von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 7 und Z18).
|
||||
* erinnereFeiertage(now) ist der Baustein des Stundenjobs (zeitenErinnerungsJob in jobs.js, angeschlossen in server/index.js); er kennt
|
||||
* keine Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen (der Merker verhindert trotzdem jede doppelte Mail).
|
||||
*
|
||||
* Wann: nur bei Schalter `pilot` oder `on` (bei `pilot` nur für Kunden der Pilotliste, zeitenFuerKunde) und nur zwischen 08:00 und
|
||||
* 20:00 Berliner Zeit. Sonst kehrt der Lauf sofort zurück, ohne eine einzige Abfrage.
|
||||
*
|
||||
* Für welche: jedes portalFeatures-Dokument mit featureKey 'zeiten' im Zustand setup oder active (seitenweise gelesen), das gespeicherte
|
||||
* Zeiten hat. Welche Tage: zeitlogik.erinnerungsTage (1 bis 7 Berliner Tage, Feiertage und „bitte prüfen“-Tage des Landes, ohne Tage,
|
||||
* an denen der Hauptplan zu ist, eine Ausnahme gilt oder der Kunde schon eine Abweichung gespeichert hat).
|
||||
*
|
||||
* Höchstens einmal: je Tag zuerst der Merker (zeitenErinnerungen, Dokument-ID = sha1(`featureId|datum`), 36 Zeichen); 409 = der Tag wurde
|
||||
* schon erinnert, er fehlt in der Mail. Danach EINE Mail K28 je Dokument mit allen neu vorgemerkten Tagen, ohne Kopie ans Team
|
||||
* (bccTeam false, R17c). Der Merker steht VOR der Mail: Stirbt der Server dazwischen oder scheitert der Versand, geht für diese Tage nie
|
||||
* eine Mail raus ("lieber einmal zu wenig als doppelt"); die Mail wird nicht wiederholt, der Fehler steht im Log und in `fehler`.
|
||||
* Hat der Kunde keine Adresse (Kundendokument fehlt oder ohne E-Mail), wird gar kein Merker angelegt: sobald es eine Adresse gibt,
|
||||
* erinnert der nächste Lauf noch an die Tage, die noch im Fenster liegen.
|
||||
*
|
||||
* Aufräumen (im selben Lauf, nach den Erinnerungen): Merker, deren Datum mehr als 30 Berliner Tage zurückliegt, werden gelöscht
|
||||
* (Spezifikation 2.2; das Datum ist der Tag des Feiertags, nach dem Fenster von 7 Tagen sind die Merker längst wirkungslos).
|
||||
*
|
||||
* Fehler: je Dokument gefangen (Lesen, Merker, Mailbau und Versand): ein kaputtes Dokument stoppt weder die anderen noch den Lauf. Das Log
|
||||
* nennt weder Namen noch Adressen, nur Funktions-IDs.
|
||||
*/
|
||||
|
||||
const KEY = 'zeiten'
|
||||
/** Zustände, in denen die Funktion läuft und erinnert wird. */
|
||||
const LAUFENDE_ZUSTAENDE = ['setup', 'active']
|
||||
/** Merker, deren Datum länger als so viele Berliner Tage zurückliegt, werden gelöscht. */
|
||||
const MERKER_AUFBEWAHRUNG_TAGE = 30
|
||||
const SEITE = 100
|
||||
|
||||
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
|
||||
/** Fehlertext fürs Log: Name und gekürzte Meldung, E-Mail-Adressen unkenntlich. */
|
||||
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}`
|
||||
|
||||
const portalFeatures = () => config.collections.portalFeatures
|
||||
const merkerCollection = () => config.collections.zeitenErinnerungen
|
||||
|
||||
/** Dokument-ID des Merkers: sha1(featureId|datum) in Hex, die ersten 36 Zeichen (Appwrite-Grenze, beginnt mit Ziffer oder Buchstabe). */
|
||||
export const merkerId = (featureId, datum) => createHash('sha1').update(`${featureId}|${datum}`).digest('hex').slice(0, 36)
|
||||
|
||||
const hatAdresse = (kunde) => typeof kunde?.email === 'string' && kunde.email.trim() !== ''
|
||||
|
||||
/** Projektname für die Mail; ein Lesefehler ergibt '' (die Mail geht trotzdem raus, der Merker steht schon). */
|
||||
async function projektName(projectId) {
|
||||
try {
|
||||
const projekt = await getDocument(config.collections.websiteProjects, projectId)
|
||||
return projekt?.projectName || projekt?.subdomain || ''
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) console.error(`[zeiten-erinnerung] Projekt ${projectId} nicht lesbar: ${fehlerText(err)}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Je Tag den Merker anlegen; => die Einträge, die neu vorgemerkt wurden (409 = schon erinnert, fehlt). Andere Fehler zählen und überspringen den Tag. */
|
||||
async function merkeVor(doc, tage, ctx) {
|
||||
const neu = []
|
||||
for (const eintrag of tage) {
|
||||
try {
|
||||
await createDocument(merkerCollection(), { featureId: doc.$id, datum: eintrag.tag, createdAt: ctx.jetzt }, merkerId(doc.$id, eintrag.tag))
|
||||
ctx.erg.merker += 1
|
||||
neu.push(eintrag)
|
||||
} catch (err) {
|
||||
if (err?.status === 409) continue
|
||||
ctx.erg.fehler += 1
|
||||
console.error(`[zeiten-erinnerung] Merker für Funktion ${doc.$id} nicht angelegt: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
return neu
|
||||
}
|
||||
|
||||
/** Ein Dokument: Zeiten lesen, Tage bestimmen, Merker anlegen, K28 senden. Wirft bei Fehlern (der Aufrufer zählt fehler). */
|
||||
async function bearbeiteDokument(doc, ctx) {
|
||||
let kunde // undefined = noch nicht gelesen
|
||||
const holeKunde = async () => {
|
||||
if (kunde === undefined) kunde = doc.customerId ? await customerOf(doc.customerId) : null
|
||||
return kunde
|
||||
}
|
||||
const ohneAdresse = () => console.warn(`[zeiten-erinnerung] Funktion ${doc.$id}: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, keine Erinnerung`)
|
||||
|
||||
// Pilot: nur freigegebene Kunden (vor dem Lesen der Zeiten)
|
||||
if (ctx.modus === 'pilot') {
|
||||
const k = await holeKunde()
|
||||
if (!hatAdresse(k)) return ohneAdresse()
|
||||
if (!zeitenFuerKunde(k.email)) return undefined
|
||||
}
|
||||
|
||||
const zeiten = await ladeZeiten(doc.$id)
|
||||
if (zeiten?.daten == null) return undefined // der Kunde hat noch nichts eingetragen
|
||||
ctx.erg.geprueft += 1
|
||||
const tage = erinnerungsTage(zeiten.daten, ctx.now)
|
||||
if (tage.length === 0) return undefined
|
||||
|
||||
const k = await holeKunde()
|
||||
if (!hatAdresse(k)) return ohneAdresse()
|
||||
|
||||
const neu = await merkeVor(doc, tage, ctx)
|
||||
if (neu.length === 0) return undefined // alles schon erinnert
|
||||
|
||||
const projekt = await projektName(doc.projectId)
|
||||
const { sent, reason } = await sendeZeitenMail(k, k28Mail({ kunde: k, projekt, tage: neu }), { bccTeam: false })
|
||||
if (sent) ctx.erg.mails += 1
|
||||
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Merker mit Datum vor dem Stichtag löschen. Ein Fehler bricht das Aufräumen ab (der nächste Lauf macht weiter). */
|
||||
async function raeumeAuf(ctx) {
|
||||
const stichtag = plusTage(heute(ctx.now), -MERKER_AUFBEWAHRUNG_TAGE)
|
||||
let geloescht = 0
|
||||
try {
|
||||
const alte = await listAllDocuments(merkerCollection(), [Query.lessThan('datum', stichtag), Query.select(['datum'])], { pageSize: SEITE })
|
||||
for (const merker of alte) {
|
||||
try {
|
||||
await deleteDocument(merkerCollection(), merker.$id)
|
||||
geloescht += 1
|
||||
} catch (err) {
|
||||
if (err?.status === 404) continue // schon weg
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.erg.fehler += 1
|
||||
console.error(`[zeiten-erinnerung] Aufräumen alter Merker abgebrochen: ${fehlerText(err)}`)
|
||||
}
|
||||
if (geloescht > 0) console.log(`[zeiten-erinnerung] ${geloescht} alte Merker gelöscht`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stündliche Feiertags-Erinnerung (siehe Kopf der Datei). Wirft nie (außer bei fehlendem oder ungültigem `now`, wie gleicheZeitenAboAb);
|
||||
* jedes Problem steht im Log und in `fehler`.
|
||||
* @param {Date} now Zeitpunkt des Laufs (Fenster, Tage und Aufräumen zählen nach der Berliner Zeit von now)
|
||||
* @returns {Promise<{ geprueft: number, mails: number, merker: number, fehler: number }>}
|
||||
* geprueft = Funktionen mit gespeicherten Zeiten, die in diesem Lauf bewertet wurden (freigegebener Kunde, Zustand setup/active);
|
||||
* mails = versendete K28; merker = in diesem Lauf neu vorgemerkte Tage; fehler = Dokumente und Schritte, die scheiterten, sowie
|
||||
* gescheiterte Mails
|
||||
*/
|
||||
export async function erinnereFeiertage(now) {
|
||||
pruefeNow(now, 'erinnereFeiertage')
|
||||
const erg = { geprueft: 0, mails: 0, merker: 0, fehler: 0 }
|
||||
const modus = zeitenModus()
|
||||
if (modus === 'off' || !imErinnerungsfenster(now)) return erg
|
||||
const ctx = { erg, now, jetzt: now.toISOString(), modus }
|
||||
|
||||
try {
|
||||
let dokumente
|
||||
try {
|
||||
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.equal('state', LAUFENDE_ZUSTAENDE)])
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-erinnerung] Funktionen nicht lesbar, die Erinnerung entfällt: ${fehlerText(err)}`)
|
||||
dokumente = []
|
||||
}
|
||||
for (const doc of dokumente) {
|
||||
try {
|
||||
await bearbeiteDokument(doc, ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-erinnerung] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
await raeumeAuf(ctx)
|
||||
} catch (err) {
|
||||
erg.fehler += 1
|
||||
console.error(`[zeiten-erinnerung] Lauf abgebrochen: ${fehlerText(err)}`)
|
||||
}
|
||||
return erg
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { hauptplan, modusUndFenster } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: statische Ersatz-Tabelle und Wochenplan-Hash (Spec 4.5, rein).
|
||||
* Kein Appwrite, keine Config, keine Uhr. Die Tabelle steht in den Platzhaltern der Kundenseite
|
||||
* (ohne JavaScript und nach Abschaltung der Funktion) und zeigt nur den Wochenplan eines Plans,
|
||||
* keine Ausnahmen, Feiertage oder Hinweise.
|
||||
*
|
||||
* Ausgabe ist reines ASCII: Umlaute und ss-Ligatur als benannte Entities (ä ... ß), Gedankenstrich
|
||||
* als –, jedes andere Nicht-ASCII-Zeichen als numerische Entity (&#NNNN;). Kundentext (Planname) wird
|
||||
* zuerst HTML-maskiert (& < > " ').
|
||||
*
|
||||
* Mitternacht: Ein Fensterende '24:00' steht in der Tabelle als '00:00' ("18:00-00:00"), nie als "24:00".
|
||||
* Ein einziges Fenster 00:00-24:00 gilt als "rund um die Uhr".
|
||||
*/
|
||||
|
||||
const WOCHENTAGE = Object.freeze(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
|
||||
const GEDANKENSTRICH = '–'
|
||||
const STANDARD_UEBERSCHRIFT = 'Öffnungszeiten'
|
||||
|
||||
const ENTITIES = new Map([
|
||||
['&', '&'],
|
||||
['<', '<'],
|
||||
['>', '>'],
|
||||
['"', '"'],
|
||||
["'", '''],
|
||||
['\u00e4', 'ä'],
|
||||
['\u00f6', 'ö'],
|
||||
['\u00fc', 'ü'],
|
||||
['\u00c4', 'Ä'],
|
||||
['\u00d6', 'Ö'],
|
||||
['\u00dc', 'Ü'],
|
||||
['\u00df', 'ß'],
|
||||
])
|
||||
|
||||
/**
|
||||
* Kundentext -> sicheres ASCII-HTML. Steuerzeichen (auch C1) werden zu Leerzeichen (der HTML-Parser
|
||||
* wuerde numerische Verweise darauf umdeuten), einzelne Surrogate zu U+FFFD.
|
||||
*/
|
||||
function asciiText(roh) {
|
||||
let aus = ''
|
||||
for (const zeichen of String(roh)) {
|
||||
const cp = zeichen.codePointAt(0)
|
||||
if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) aus += ' '
|
||||
else if (ENTITIES.has(zeichen)) aus += ENTITIES.get(zeichen)
|
||||
else if (cp < 0x7f) aus += zeichen
|
||||
else if (cp >= 0xd800 && cp <= 0xdfff) aus += '�'
|
||||
else aus += `&#${cp};`
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
/** Text eines Tages; modus/fenster kommen bereits bereinigt aus modusUndFenster (gueltige 'HH:MM'-Fenster). */
|
||||
function tagText({ modus, fenster }) {
|
||||
if (modus === 'rund') return 'rund um die Uhr'
|
||||
if (modus === 'vereinbarung') return 'nach Vereinbarung'
|
||||
if (modus !== 'zeiten' || fenster.length === 0) return 'geschlossen'
|
||||
if (fenster.length === 1 && fenster[0][0] === '00:00' && fenster[0][1] === '24:00') return 'rund um die Uhr'
|
||||
return fenster
|
||||
.map(([von, bis]) => `${von}${GEDANKENSTRICH}${bis === '24:00' ? '00:00' : bis}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlichte Wochentabelle des Plans: <table class="wkz-ersatz"> mit <caption> = Planname (leer => "Oeffnungszeiten")
|
||||
* und einer Zeile je Wochentag Montag bis Sonntag (<th scope="row"> + <td>). Ohne 'plan' gilt der Hauptplan;
|
||||
* gibt es keinen Plan, ist das Ergebnis '' (der Aufrufer entscheidet, z. B. 409). Kaputte Tageseintraege
|
||||
* (unbekannter Modus, fehlende Tage, unlesbare Fenster) zaehlen als geschlossen.
|
||||
*/
|
||||
export function ersatzHtml(daten, plan) {
|
||||
const p = plan && typeof plan === 'object' ? plan : hauptplan(daten)
|
||||
if (!p) return ''
|
||||
const name = typeof p.name === 'string' ? p.name.trim() : ''
|
||||
const zeilen = WOCHENTAGE.map((tag, i) => {
|
||||
const text = tagText(modusUndFenster(p.tage?.[i + 1]))
|
||||
return `<tr><th scope="row">${tag}</th><td>${text}</td></tr>`
|
||||
})
|
||||
return [
|
||||
'<table class="wkz-ersatz">',
|
||||
`<caption>${name ? asciiText(name) : STANDARD_UEBERSCHRIFT}</caption>`,
|
||||
'<tbody>',
|
||||
...zeilen,
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON mit rekursiv sortierten Objektschluesseln (Arrays behalten ihre Reihenfolge), sonst wie JSON.stringify:
|
||||
* undefined-Werte in Objekten entfallen, der Gesamtwert undefined ergibt undefined.
|
||||
*/
|
||||
function stabilesJson(wert) {
|
||||
if (Array.isArray(wert)) return `[${wert.map((w) => stabilesJson(w) ?? 'null').join(',')}]`
|
||||
if (wert && typeof wert === 'object') {
|
||||
const teile = []
|
||||
for (const schluessel of Object.keys(wert).sort()) {
|
||||
const v = stabilesJson(wert[schluessel])
|
||||
if (v !== undefined) teile.push(`${JSON.stringify(schluessel)}:${v}`)
|
||||
}
|
||||
return `{${teile.join(',')}}`
|
||||
}
|
||||
return JSON.stringify(wert)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash des Wochenplans des Hauptplans: sha1 ueber das stabile JSON von 'tage', die ersten 16 Hex-Zeichen.
|
||||
* Aendert sich nur mit 'tage' des Hauptplans (nicht mit Name, Ausnahmen, Hinweisen, Feiertagen, Land oder anderen
|
||||
* Plaenen); '' ohne Hauptplan. Hauptplan ohne 'tage' ergibt den Hash von 'null'.
|
||||
*/
|
||||
export function wochenplanHash(daten) {
|
||||
const haupt = hauptplan(daten)
|
||||
if (!haupt) return ''
|
||||
const json = stabilesJson(haupt.tage) ?? 'null'
|
||||
return createHash('sha1').update(json).digest('hex').slice(0, 16)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { gleicheZeitenAboAb } from './aboAbgleich.js'
|
||||
import { erinnereFeiertage } from './erinnerung.js'
|
||||
|
||||
/**
|
||||
* Hintergrund-Jobs von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitte 5 und 7).
|
||||
* server/index.js ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
|
||||
*
|
||||
* zeitenAboJob täglich (240 s nach dem Start beginnend) Abo-Abgleich (aboAbgleich.js): Abo-Ende erkennen, nach 30 Tagen
|
||||
* abschalten, bei Rückkehr wieder einschalten, nach weiteren 30 Tagen
|
||||
* löschen. Eigener Tagesjob, nicht an den Schalter ZEITEN_ENABLED
|
||||
* gekoppelt (wie beim Terminplaner): ein beendetes Abo muss auch bei
|
||||
* Schalter off zum Abschalten und zur Löschung führen. Ohne Zeiten-
|
||||
* Dokumente kostet er eine Abfrage.
|
||||
* zeitenErinnerungsJob stündlich (300 s nach dem Start beginnend) Feiertags-Erinnerung (erinnerung.js): nur bei Schalter pilot/on,
|
||||
* nur 08:00 bis 20:00 Berliner Zeit, Merker älter als 30 Tage löschen
|
||||
*
|
||||
* Gemeinsame Regeln:
|
||||
* - Keine überlappenden Läufe, je Job: läuft ein Job noch, kehrt der nächste Takt sofort mit { uebersprungen: true } zurück (einmal
|
||||
* im Log je Überlappung). Die Sperre ist ein Merker im Speicher des Prozesses (ein Server, ein Prozess); die Jobs sperren einander nicht.
|
||||
* - Ein Job wirft nie: ein unerwarteter Fehler (etwa ein ungültiges `now`) steht im Log, das Ergebnis ist { fehler: 1 }. Die Bausteine
|
||||
* melden einzelne Fehler selbst im Ergebnis (`fehler`).
|
||||
* - Das Log nennt weder Namen noch Adressen von Kunden.
|
||||
*/
|
||||
|
||||
const laufend = new Set()
|
||||
const ueberlappGemeldet = new Set()
|
||||
|
||||
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
|
||||
|
||||
/** Führt arbeit() aus, sofern dieser Job nicht schon läuft; fängt jeden Fehler. */
|
||||
async function fuehreAus(job, arbeit) {
|
||||
if (laufend.has(job)) {
|
||||
if (!ueberlappGemeldet.has(job)) {
|
||||
ueberlappGemeldet.add(job)
|
||||
console.warn(`[zeiten-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
|
||||
}
|
||||
return { uebersprungen: true }
|
||||
}
|
||||
laufend.add(job)
|
||||
try {
|
||||
return await arbeit()
|
||||
} catch (err) {
|
||||
console.error(`[zeiten-jobs] ${job} fehlgeschlagen: ${fehlerText(err)}`)
|
||||
return { fehler: 1 }
|
||||
} finally {
|
||||
laufend.delete(job)
|
||||
ueberlappGemeldet.delete(job)
|
||||
}
|
||||
}
|
||||
|
||||
/** Täglicher Abo-Abgleich. => Ergebnis von gleicheZeitenAboAb | { uebersprungen: true } | { fehler: 1 } */
|
||||
export const zeitenAboJob = (now) => fuehreAus('zeitenAboJob', () => gleicheZeitenAboAb(now))
|
||||
|
||||
/** Stündliche Feiertags-Erinnerung. => Ergebnis von erinnereFeiertage | { uebersprungen: true } | { fehler: 1 } */
|
||||
export const zeitenErinnerungsJob = (now) => fuehreAus('zeitenErinnerungsJob', () => erinnereFeiertage(now))
|
||||
|
||||
/** Nur für Tests: alle Sperren freigeben. */
|
||||
export function resetZeitenJobsForTests() {
|
||||
laufend.clear()
|
||||
ueberlappGemeldet.clear()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ipSchluessel, zaehle } from '../termine/limits.js'
|
||||
|
||||
/**
|
||||
* Grenze der oeffentlichen Zeiten-Schnittstelle (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4.1):
|
||||
* hoechstens 120 Abrufe je Minute und Client-IP. Der Zaehler liegt im Speicher dieses Prozesses (services/termine/limits.js:
|
||||
* IPv6 je /64, 'ip:'-Schluessel verdraengen keine langlebigen Zaehler) und traegt einen eigenen Schluessel, damit Zeiten und
|
||||
* Terminplaner nicht aus demselben Topf zaehlen. Tests leeren ihn mit resetTermineLimitsForTests.
|
||||
*/
|
||||
const ANFRAGEN_JE_IP = 120
|
||||
const MINUTE_MS = 60 * 1000
|
||||
|
||||
/** Darf diese IP (IPv6 je /64) jetzt die Zeiten abrufen? Hoechstens 120 je Minute; true = erlaubt (gezaehlt), false = Grenze erreicht. */
|
||||
export function darfZeitenAnfrage(ip, now) {
|
||||
return zaehle(`ip:zeiten:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Oeffnungszeiten live: kleiner LRU-Speicher mit Ablaufzeit (rein, ohne eigene Uhr: die Zeit kommt als Millisekunden-Parameter).
|
||||
*
|
||||
* - hoechstens max Eintraege; ist er voll, faellt der am laengsten nicht benutzte (hole oder setze) heraus;
|
||||
* - jeder Eintrag verfaellt ttlMs nach dem Setzen (genau nach ttlMs ist er weg); ein Treffer erneuert nur seinen Platz in
|
||||
* der Reihe, nie die Ablaufzeit, damit auch ein oft abgefragter Eintrag spaetestens nach ttlMs neu gebaut wird;
|
||||
* - hole liefert bei Fehlschlag oder Ablauf undefined; gespeichert werden darf jeder Wert (auch null, 0, '').
|
||||
* Umsetzung: eine Map; ihre Einfuegereihenfolge ist die Nutzungsreihenfolge (aeltester zuerst), ein Treffer haengt den Eintrag neu an.
|
||||
*/
|
||||
|
||||
const pruefeZeit = (nowMs, wer) => {
|
||||
if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) throw new TypeError(`${wer}: nowMs muss eine Zahl (Millisekunden) sein`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ max: number, ttlMs: number }} optionen max = ganze Zahl >= 1, ttlMs = Zahl > 0 (endlich)
|
||||
* @returns {{ hole(key: string, nowMs: number): *, setze(key: string, wert: *, nowMs: number): void, vergiss(key: string): void, leere(): void, groesse(): number }}
|
||||
*/
|
||||
export function neuerLru({ max, ttlMs } = {}) {
|
||||
if (!Number.isInteger(max) || max < 1) throw new TypeError('neuerLru: max muss eine ganze Zahl ab 1 sein')
|
||||
if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('neuerLru: ttlMs muss eine Zahl groesser 0 sein')
|
||||
|
||||
/** @type {Map<string, { bis: number, wert: * }>} */
|
||||
const eintraege = new Map()
|
||||
|
||||
return {
|
||||
hole(key, nowMs) {
|
||||
pruefeZeit(nowMs, 'hole')
|
||||
const eintrag = eintraege.get(key)
|
||||
if (!eintrag) return undefined
|
||||
eintraege.delete(key)
|
||||
if (eintrag.bis <= nowMs) return undefined
|
||||
eintraege.set(key, eintrag)
|
||||
return eintrag.wert
|
||||
},
|
||||
|
||||
setze(key, wert, nowMs) {
|
||||
pruefeZeit(nowMs, 'setze')
|
||||
eintraege.delete(key)
|
||||
eintraege.set(key, { bis: nowMs + ttlMs, wert })
|
||||
while (eintraege.size > max) eintraege.delete(eintraege.keys().next().value)
|
||||
},
|
||||
|
||||
vergiss(key) {
|
||||
eintraege.delete(key)
|
||||
},
|
||||
|
||||
leere() {
|
||||
eintraege.clear()
|
||||
},
|
||||
|
||||
groesse() {
|
||||
return eintraege.size
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { config } from '../../config.js'
|
||||
import { sendCustomerMail } from '../mailer.js'
|
||||
import { datumKurz, wochentag } from '../termine/zeit.js'
|
||||
|
||||
/**
|
||||
* Mails von Öffnungszeiten live an Kunden (Vorlagen server/mail-templates/K27 bis K30):
|
||||
* K27 eingeschaltet (Gratisweg im Wartungs-Abo; mit Block `wieder` nach der Rückkehr des Abos)
|
||||
* K28 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
|
||||
* K29 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
|
||||
* K30 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
|
||||
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
|
||||
* siteFeatures.bestellMail: die Werte decken genau die Platzhalter der Vorlage ab. sendeZeitenMail verschickt sie.
|
||||
*
|
||||
* Anrede: `kunde` ist der Name oder das Kundendokument; ohne Namen bleibt kunde_name leer (wie bei K22/K23 im Terminplaner).
|
||||
* Datumsangaben (`frist`, `loeschdatum`) sind Date-Objekte und stehen als TT.MM.JJJJ (Berliner Tag) in der Mail.
|
||||
*/
|
||||
|
||||
const WOCHENTAG_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const TAG_FORM = /^\d{4}-\d{2}-\d{2}$/
|
||||
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
|
||||
|
||||
/** Adresse des Editors im Kundenportal. */
|
||||
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#zeiten`
|
||||
|
||||
/** Anrede-Wert: Name aus einem String oder einem Kundendokument, getrimmt; sonst '' (die Vorlage sagt dann „Hallo ,“, wie K22/K23). */
|
||||
function kundenName(kunde) {
|
||||
const name = typeof kunde === 'string' ? kunde : kunde?.name
|
||||
return typeof name === 'string' ? name.trim() : ''
|
||||
}
|
||||
|
||||
/** Date -> 'TT.MM.JJJJ' (Berliner Tag). Wirft bei etwas anderem als einem gültigen Date: ein leeres Datum in der Mail wäre falsch. */
|
||||
function datum(wert, feld) {
|
||||
if (!(wert instanceof Date) || Number.isNaN(wert.getTime())) throw new TypeError(`${feld}: gültiges Date erwartet`)
|
||||
return datumKurz(wert)
|
||||
}
|
||||
|
||||
/**
|
||||
* K27: Öffnungszeiten live ist eingeschaltet.
|
||||
* @param {{ kunde: string | { name?: string }, projekt: string, einbau?: string | null, wieder?: boolean, ticketNr?: string | number | null }} p
|
||||
* einbau: 'angestossen' (Neubau läuft: Leisten erscheinen nach dem nächsten Veröffentlichen) oder 'manuell' (das Team baut ein);
|
||||
* alles andere, auch ein fehlender Wert, sagt das Vorsichtige: das Team baut ein.
|
||||
* Ausnahme: einbau null zusammen mit wieder = es gab keinen Neubau (das Abo kehrte vor dem Abschalten zurück, die Leisten waren nie weg):
|
||||
* beide Einbau-Blöcke und der ganze Kasten „Wie geht es weiter?“ (Block weiter) entfallen.
|
||||
* wieder: das Wartungs-Abo läuft wieder und die Funktion wurde automatisch wieder eingeschaltet.
|
||||
* ticketNr: Nummer des Einbau-Tickets; ohne sie steht kein Ticket in der Mail.
|
||||
*/
|
||||
export function k27Mail({ kunde, projekt, einbau, wieder = false, ticketNr = null }) {
|
||||
const nr = String(ticketNr ?? '').trim().replace(/^#/, '')
|
||||
const keinNeubau = wieder === true && einbau === null
|
||||
const angestossen = einbau === 'angestossen'
|
||||
const manuell = !keinNeubau && !angestossen
|
||||
return {
|
||||
template: 'K27',
|
||||
values: {
|
||||
kunde_name: kundenName(kunde),
|
||||
projekt,
|
||||
// führendes Leerzeichen: steht direkt hinter einem Wort, ohne Ticket bleibt nichts übrig
|
||||
ticket: nr ? ` (Ticket #${nr})` : '',
|
||||
portal_url: portalUrl(),
|
||||
},
|
||||
blocks: { angestossen, manuell, wieder: Boolean(wieder), weiter: angestossen || manuell },
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Zeile je Erinnerungstag: 'Sa 03.10. – Tag der Deutschen Einheit', bei „bitte prüfen“ mit Zusatz. */
|
||||
function tagesZeile(eintrag) {
|
||||
if (typeof eintrag === 'string') return eintrag
|
||||
const { tag, name, art } = eintrag || {}
|
||||
const zeile = TAG_FORM.test(String(tag))
|
||||
? `${WOCHENTAG_KURZ[wochentag(tag) - 1]} ${tag.slice(8, 10)}.${tag.slice(5, 7)}.${name ? ` – ${name}` : ''}`
|
||||
: [tag, name].filter(Boolean).join(' – ')
|
||||
return art === 'pruefen' ? `${zeile} (bitte prüfen)` : zeile
|
||||
}
|
||||
|
||||
/**
|
||||
* K28: Feiertage stehen an (eine Mail für alle neuen Tage; Aufrufer: Erinnerungs-Job, ohne BCC ans Team).
|
||||
* @param {{ kunde: string | { name?: string }, projekt: string, tage: Array<{ tag: string, name?: string, art?: 'feiertag' | 'pruefen' }> }} p
|
||||
* tage: [{ tag: 'YYYY-MM-DD', name, art }] wie zeitlogik.erinnerungsTage(); Blöcke: pruefen (mindestens ein „bitte prüfen“-Tag), mehrere (> 1 Tag).
|
||||
*/
|
||||
export function k28Mail({ kunde, projekt, tage }) {
|
||||
const liste = Array.isArray(tage) ? tage : []
|
||||
return {
|
||||
template: 'K28',
|
||||
values: {
|
||||
kunde_name: kundenName(kunde),
|
||||
projekt,
|
||||
tage: liste.map(tagesZeile).join('\n'),
|
||||
portal_url: portalUrl(),
|
||||
},
|
||||
blocks: { pruefen: liste.some((e) => e?.art === 'pruefen'), mehrere: liste.length > 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* K29: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
|
||||
* @param {{ kunde: string | { name?: string }, projekt: string, art?: string, frist: Date, preis: string }} p
|
||||
* art: Abo-Lage aus aboStatus.aboLage(): 'hosting' (das Abo ist ein Hosting-Abo), 'wechsel_widerrufen' (Wechsel ins Wartungs-Abo
|
||||
* widerrufen), sonst (auch 'beendet' und Unbekanntes) 'abo_ende'. frist: Tag des Abschaltens (aboEndeAt + 30 Tage).
|
||||
*/
|
||||
export function k29Mail({ kunde, projekt, art, frist, preis }) {
|
||||
return {
|
||||
template: 'K29',
|
||||
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() },
|
||||
blocks: { abo_ende: art !== 'hosting' && art !== 'wechsel_widerrufen', hosting: art === 'hosting', wechsel_widerrufen: art === 'wechsel_widerrufen' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* K30: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
|
||||
* @param {{ kunde: string | { name?: string }, projekt: string, loeschdatum: Date, preis: string }} p
|
||||
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden (30 Tage nach dem Abschalten); preis: Preis zum Wiedereinschalten.
|
||||
*/
|
||||
export function k30Mail({ kunde, projekt, loeschdatum, preis }) {
|
||||
return {
|
||||
template: 'K30',
|
||||
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlertext fürs Log: Name und gekürzte Meldung. SMTP-Antworten nennen gern die Adresse (und fremde Server den Namen):
|
||||
* E-Mail-Adressen und der Name des Kunden werden unkenntlich gemacht.
|
||||
*/
|
||||
function fehlerText(err, customer) {
|
||||
let meldung = String(err?.message ?? '').replace(ADRESSE, '[Adresse]')
|
||||
const name = kundenName(customer)
|
||||
if (name.length >= 2) meldung = meldung.split(name).join('[Name]')
|
||||
return `${err?.name ?? 'Fehler'} ${meldung.slice(0, 200)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine der Mails K27 bis K30 an die E-Mail-Adresse des Kundenkontos senden. Wirft nie; das Log nennt weder Adresse noch Namen.
|
||||
* @param {{ email?: string, name?: string } | null | undefined} customer Kundendokument
|
||||
* @param {{ template: string, values?: object, blocks?: object }} nachricht Ergebnis von k27Mail ... k30Mail
|
||||
* @param {{ bccTeam?: boolean }} [options] bccTeam false: keine Kopie ans Team (K28)
|
||||
* @returns {Promise<{ sent: boolean, reason?: 'keine_adresse' | 'fehler' }>}
|
||||
*/
|
||||
export async function sendeZeitenMail(customer, nachricht, { bccTeam = true } = {}) {
|
||||
const vorlage = nachricht?.template ?? 'Mail'
|
||||
try {
|
||||
const to = String(customer?.email ?? '').trim()
|
||||
if (!to) {
|
||||
console.warn(`[zeiten-mail] ${vorlage} entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse`)
|
||||
return { sent: false, reason: 'keine_adresse' }
|
||||
}
|
||||
await sendCustomerMail(to, { ...nachricht, bccTeam })
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error(`[zeiten-mail] ${vorlage} nicht gesendet: ${fehlerText(err, customer)}`)
|
||||
return { sent: false, reason: 'fehler' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { triggerRebuild } from '../previewRebuild.js'
|
||||
import { isDevPreview } from '../analyse.js'
|
||||
import { aufSperrliste } from './einbau.js'
|
||||
|
||||
/** Austauschbar fuer Tests: Neubau (Tests ersetzen das Feld, nie destrukturieren). */
|
||||
export const zeitenDeps = { rebuild: triggerRebuild }
|
||||
|
||||
/**
|
||||
* Neubau fuer den Einbau des Zeiten-Skripts anstossen (Spec 4.6), wenn die Funktion in einen ausgebenden Zustand
|
||||
* (setup/active) wechselt. Hoechstens ein angestossener Neubau gleichzeitig (Portal-weite Sperre 'zeiten-neubau';
|
||||
* nicht wiederbetretbar, nie innerhalb dieser Sperre aufrufen).
|
||||
* 'angestossen': der Runner hat den Auftrag angenommen (result.queued === true).
|
||||
* 'manuell': kein Repo, Preview aus, Dev-Modus, Sperrliste, Runner ausgelastet (503) oder Fehler; das Team baut ein.
|
||||
* Wirft nie.
|
||||
*/
|
||||
export async function stosseNeubauAn(project) {
|
||||
try {
|
||||
if (!project?.repoFullName || project.previewMode === 'off' || isDevPreview(project) || aufSperrliste(project)) return 'manuell'
|
||||
const result = await withLocks(['zeiten-neubau'], () => zeitenDeps.rebuild(project))
|
||||
return result?.queued === true ? 'angestossen' : 'manuell'
|
||||
} catch (err) {
|
||||
// Zustand bleibt gespeichert, das Team setzt die Zeile von Hand ein
|
||||
console.warn(`[zeiten] Neubau für Projekt ${project?.$id} nicht angestoßen: ${err?.message}`)
|
||||
return 'manuell'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { heute } from '../termine/zeit.js'
|
||||
import { customerOf, findActiveBySiteKey, projectName } from '../siteFeatures.js'
|
||||
import { zeitenCache } from './cache.js'
|
||||
import { zeitenAus } from './schalter.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
import { gueltigeHinweise, hauptplan, jsonLdFuer, modusUndFenster, tageListe, wechselListe } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: die oeffentliche Antwort fuer das Website-Skript (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
|
||||
* Abschnitt 4.1). Die Route (Task 12) reicht den siteKey der URL durch, setzt CORS, ETag und Limits und macht aus einem Fehler
|
||||
* dieses Moduls ein 503; hier steht nur, was in der Antwort steht und wann sie gebaut wird.
|
||||
*
|
||||
* Die Antwort enthaelt nie interne IDs (kein $id, customerId, projectId, featureId, keine Plan-ID, nur den Kurznamen) und keine
|
||||
* Kundendaten ausser dem Firmennamen und den veroeffentlichten Kontaktangaben.
|
||||
*/
|
||||
|
||||
const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
||||
const TAGE_VORSCHAU = 60 // Spec 4.1: tage je Plan (heute bis heute + 59)
|
||||
const ROUTE_BASIS = 'https://www.google.com/maps/dir/?api=1&destination='
|
||||
const WHATSAPP_BASIS = 'https://wa.me/'
|
||||
const TELEFON_RE = /^[1-9][0-9]{5,14}$/ // Ziffern mit Laendervorwahl, 6 bis 15 (E.164): so speichert pruefen.js
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const nichtLeererText = (wert) => (typeof wert === 'string' && wert.trim() !== '' ? wert.trim() : null)
|
||||
|
||||
function pruefeJetzt(now) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum')
|
||||
return now.getTime()
|
||||
}
|
||||
|
||||
/** Beliebiger Text fuer eine URL; ein einzelnes Surrogat aus von Hand veraenderten Daten wuerde encodeURIComponent sonst werfen. */
|
||||
const kodiere = (wert) => encodeURIComponent(String(wert).toWellFormed())
|
||||
|
||||
/** Nur Ziffern (nie ein Text, der in tel: oder wa.me landen darf, ohne dass er so gespeichert wurde), sonst null. */
|
||||
const ziffern = (wert) => (typeof wert === 'string' && TELEFON_RE.test(wert) ? wert : null)
|
||||
|
||||
/**
|
||||
* Kontakt-Leiste: null, wenn sie aus ist oder es nichts anzuzeigen gibt; sonst { telefon, routeUrl, whatsappUrl } mit null fuer
|
||||
* Fehlendes. WhatsApp nur mit bestaetigtAt (der Kunde hat den Datenschutz-Haken gesetzt); der fremde Dienst oeffnet erst beim Klick.
|
||||
*/
|
||||
function kontaktAntwort(kontakt) {
|
||||
if (!istObjekt(kontakt) || kontakt.an !== true) return null
|
||||
const telefon = ziffern(kontakt.telefon)
|
||||
const adresse = nichtLeererText(kontakt.adresse)
|
||||
const wa = istObjekt(kontakt.whatsapp) ? kontakt.whatsapp : null
|
||||
const nummer = wa && nichtLeererText(wa.bestaetigtAt) ? ziffern(wa.nummer) : null
|
||||
|
||||
const routeUrl = adresse ? ROUTE_BASIS + kodiere(adresse) : null
|
||||
const whatsappUrl = nummer ? `${WHATSAPP_BASIS}${nummer}?text=${kodiere(typeof wa.starttext === 'string' ? wa.starttext : '')}` : null
|
||||
if (!telefon && !routeUrl && !whatsappUrl) return null
|
||||
return { telefon, routeUrl, whatsappUrl }
|
||||
}
|
||||
|
||||
/** Team-Einstellungen mit den Vorgaben (null, false, false); nur diese drei Schluessel verlassen den Server. */
|
||||
function teamAntwort(team) {
|
||||
const t = istObjekt(team) ? team : {}
|
||||
return {
|
||||
navSelektor: nichtLeererText(t.navSelektor),
|
||||
kontaktleisteAus: t.kontaktleisteAus === true,
|
||||
hinweisleisteAus: t.hinweisleisteAus === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Plan der Antwort; interne Plan-ID bleibt draussen, der Kurzname ist der oeffentliche Schluessel. */
|
||||
function planAntwort(daten, plan, now, heuteTag) {
|
||||
const woche = []
|
||||
for (let tag = 1; tag <= 7; tag += 1) woche.push({ tag, ...modusUndFenster(plan.tage?.[tag]) })
|
||||
return {
|
||||
kurz: plan.kurz,
|
||||
name: typeof plan.name === 'string' ? plan.name : '',
|
||||
haupt: plan.haupt === true,
|
||||
woche,
|
||||
tage: tageListe(daten, plan, heuteTag, TAGE_VORSCHAU),
|
||||
// tag = Berliner Kalendertag bei ab: das Skript rechnet keine Zeitzonen und braucht ihn fuer „heute“ und die naechsten 14 Tage
|
||||
wechsel: wechselListe(daten, plan, now).map(({ ab, tag, offen, text, kurz }) => ({ ab, tag, offen, text, kurz })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Antwort (rein: kein Appwrite, keine Uhr ausser now) aus den gespeicherten Daten.
|
||||
* @param {{ daten: object|null, team: object, firma: { name: string }, now: Date }} p
|
||||
* daten null (oder unbrauchbar) = noch nichts gespeichert: plaene leer, kein JSON-LD (jsonld null, damit der Browser vorhandenes
|
||||
* JSON-LD nicht mit leeren Zeiten ueberschreibt). firma.name kommt vom Aufrufer, firma.adresse ist kontakt.adresse.
|
||||
* @returns {object} { aktiv: true, stand, plaene, hinweise, kontakt, jsonld, firma, team }
|
||||
*/
|
||||
export function baueOeffentlicheAntwort({ daten, team, firma, now } = {}) {
|
||||
pruefeJetzt(now)
|
||||
const d = istObjekt(daten) ? daten : null
|
||||
const heuteTag = heute(now)
|
||||
const haupt = d ? hauptplan(d) : null
|
||||
const plaene = Array.isArray(d?.plaene) ? d.plaene.filter((p) => istObjekt(p) && nichtLeererText(p.kurz)) : []
|
||||
|
||||
return {
|
||||
aktiv: true,
|
||||
stand: now.toISOString(),
|
||||
plaene: plaene.map((plan) => planAntwort(d, plan, now, heuteTag)),
|
||||
hinweise: d ? gueltigeHinweise(d, now) : [],
|
||||
kontakt: kontaktAntwort(d?.kontakt),
|
||||
jsonld: haupt ? jsonLdFuer(d, haupt, now) : null,
|
||||
firma: { name: nichtLeererText(firma?.name) ?? '', adresse: nichtLeererText(d?.kontakt?.adresse) },
|
||||
team: teamAntwort(team),
|
||||
}
|
||||
}
|
||||
|
||||
const AUS = Object.freeze({ aktiv: false })
|
||||
|
||||
/** Friert die Antwort samt allem Darunterliegenden ein: was im Speicher liegt, darf kein Aufrufer mehr veraendern. */
|
||||
function friere(wert) {
|
||||
if (wert !== null && typeof wert === 'object' && !Object.isFrozen(wert)) {
|
||||
Object.freeze(wert)
|
||||
for (const teil of Object.values(wert)) friere(teil)
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Firmenname des Kunden; fehlen Firmen- und Kundenname, der Projektname, sonst ''. */
|
||||
async function firmenName(kunde, projectId) {
|
||||
const name = nichtLeererText(kunde.companyName) ?? nichtLeererText(kunde.name)
|
||||
if (name) return name
|
||||
return typeof projectId === 'string' && projectId !== '' ? (nichtLeererText(await projectName(projectId)) ?? '') : ''
|
||||
}
|
||||
|
||||
async function ladeAntwort(siteKey, now) {
|
||||
// 'setup' antwortet schon: das Team prueft die Leisten, bevor das Ticket geschlossen und die Funktion 'active' wird (Spec 4.4)
|
||||
const feature = await findActiveBySiteKey(siteKey, 'zeiten', { states: ['setup', 'active'] })
|
||||
if (!feature) return AUS
|
||||
const kunde = typeof feature.customerId === 'string' && feature.customerId !== '' ? await customerOf(feature.customerId) : null
|
||||
if (!kunde) return AUS
|
||||
|
||||
const gespeichert = await ladeZeiten(feature.$id)
|
||||
return baueOeffentlicheAntwort({
|
||||
daten: gespeichert?.daten ?? null,
|
||||
team: gespeichert?.team,
|
||||
firma: { name: await firmenName(kunde, feature.projectId) },
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Die oeffentliche Antwort zu einem siteKey (nie null, nie ein Fehler fuer Unbekanntes):
|
||||
* Schalter aus oder siteKey nicht im Format [a-z2-7]{16} => { aktiv: false } ohne Appwrite-Abfrage und ohne Eintrag im Speicher;
|
||||
* sonst aus dem Speicher (30 s, auch fuer { aktiv: false } bei unbekanntem Schluessel, falschem Zustand oder geloeschtem Kunden);
|
||||
* sonst Funktion (setup/active) und Kunde laden, Zeiten laden, bauen, merken.
|
||||
* Appwrite-Fehler gehen unveraendert an den Aufrufer (die Route macht daraus 503) und werden nicht gemerkt.
|
||||
* Die Antwort ist eingefroren (sie wird geteilt).
|
||||
* @param {string} siteKey Schluessel aus der URL
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export async function oeffentlicheZeitenAntwort(siteKey, now = new Date()) {
|
||||
if (zeitenAus()) return AUS
|
||||
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return AUS
|
||||
const jetztMs = pruefeJetzt(now)
|
||||
|
||||
const gemerkt = zeitenCache.hole(siteKey, jetztMs)
|
||||
if (gemerkt !== undefined) return gemerkt
|
||||
|
||||
const antwort = friere(await ladeAntwort(siteKey, now))
|
||||
zeitenCache.setze(siteKey, antwort, jetztMs)
|
||||
return antwort
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { text } from '../termine/einstellungen.js'
|
||||
import { BUNDESLAENDER } from '../termine/feiertage.js'
|
||||
import { heute, plusTage } from '../termine/zeit.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: Pruefen und Normalisieren (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1).
|
||||
* Rein: kein Appwrite, keine Config, kein eigener Zeitgeber ('now' ist Pflicht). Jede Speicherung (Kunde, Kundenansicht,
|
||||
* Team) geht durch normalisiereZeiten / normalisiereTeam; gespeichert wird nur das Ergebnis.
|
||||
*
|
||||
* Ergebnis: { daten } bzw. { team } oder { fehler: { [pfad]: text } } (je Pfad die erste Meldung, Texte in Du-Form).
|
||||
* Pfade tragen die IDs der Eintraege: 'plaene.<id>.name', 'plaene.<id>.tage.3', 'ausnahmen.<id>.von',
|
||||
* 'hinweise.<id>.knopf.ziel', 'kontakt.telefon', 'daten' (zu gross). Eintraege ohne Objektform haben nur einen Index
|
||||
* ('ausnahmen.4'); ein Eintrag ohne gueltige ID bekommt eine neue und der Pfad nennt diese.
|
||||
*
|
||||
* Kanonische Form (gespeichert wird nur sie, damit gleiche Zeiten gleich aussehen und wochenplanHash stabil bleibt):
|
||||
* - je Tag/Eintrag modus 'zu'|'zeiten'|'rund'|'vereinbarung'; fenster nur bei 'zeiten' (dort 1-2 Fenster, nach Beginn
|
||||
* sortiert), sonst immer []; alle sieben Wochentage sind da (fehlende = zu);
|
||||
* - unbekannte Schluessel fallen ueberall weg; Texte ohne Steuerzeichen, Zeitpunkte als toISOString;
|
||||
* - ausnahmen und hinweise BLEIBEN in Eingabereihenfolge (Gleichstand in der Zeitlogik: der spaetere Index gewinnt),
|
||||
* feiertage ist nach Datum sortiert; Plaene behalten ihre Reihenfolge.
|
||||
*
|
||||
* Ausnahmen/Hinweise mit Ende vor heute - 30 Tagen und feiertage-Schluessel vor diesem Tag werden entfernt (nach der Pruefung).
|
||||
*/
|
||||
|
||||
export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, fensterJeTag: 2, ausnahmen: 100, hinweise: 20, urlaubTage: 366, vergangenTage: 30, daten: 32768 })
|
||||
export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24, navSelektor: 120 })
|
||||
|
||||
const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
|
||||
const AUSNAHME_ARTEN = Object.freeze(['urlaub', 'sondertag'])
|
||||
const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig'])
|
||||
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
|
||||
const STANDARD_LAND = 'RP'
|
||||
const DATEN_ZU_GROSS = 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.'
|
||||
|
||||
// Obergrenzen fuer Rohdaten, damit uebergrosse Eingaben nie teuer werden (Pruefung kommt danach mit der echten Grenze).
|
||||
const ROH_TEXT = 5000
|
||||
const ROH_LISTE = 1000
|
||||
|
||||
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
|
||||
const KURZ_RE = /^[a-z0-9-]{1,24}$/
|
||||
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
// Zeitpunkt mit Zeitzone ('Z' oder +hh:mm), Sekunden optional; der Kalendertag wird gesondert geprueft (Date rollt 30.02. um).
|
||||
const ZEITPUNKT_RE = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
|
||||
const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const fehltWert = (wert) => wert === undefined || wert === null
|
||||
|
||||
/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */
|
||||
const istTag = (t) => typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t
|
||||
|
||||
/** Kalendertage von bis zu (jeweils 'YYYY-MM-DD'), ohne Tages-Millisekunden-Annahmen ueber die Umstellung zu brauchen (UTC-Mitternacht). */
|
||||
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
|
||||
|
||||
const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
|
||||
|
||||
// ---------------------------------------------------------------- IDs und Kurznamen
|
||||
|
||||
function neueId(praefix) {
|
||||
let zeichen = ''
|
||||
for (let i = 0; i < 8; i += 1) zeichen += ID_ALPHABET[randomInt(ID_ALPHABET.length)]
|
||||
return `${praefix}_${zeichen}`
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs fuer eine Liste: gueltige, in der Liste noch nicht gesehene bleiben, alle anderen (fehlend, falsches Format,
|
||||
* doppelt) werden neu erzeugt (kryptografisch, praefix_ + 8 Zeichen a-z0-9) und kollidieren nie mit einer gegebenen.
|
||||
*/
|
||||
function ausweisen(liste, praefix) {
|
||||
const gesehen = new Set()
|
||||
const gegeben = liste.map((e) => {
|
||||
const id = istObjekt(e) ? e.id : undefined
|
||||
if (typeof id === 'string' && ID_RE.test(id) && !gesehen.has(id)) {
|
||||
gesehen.add(id)
|
||||
return id
|
||||
}
|
||||
return null
|
||||
})
|
||||
return gegeben.map((id) => {
|
||||
if (id) return id
|
||||
let neu
|
||||
do { neu = neueId(praefix) } while (gesehen.has(neu))
|
||||
gesehen.add(neu)
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzname [a-z0-9-]{1,24} aus dem Namen: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich; leer = 'plan';
|
||||
* ist er in vorhandene (Array oder Set), kommt -2, -3, ... davor (immer <= 24 Zeichen). Gleiche Regel im Editor (Task 27).
|
||||
*/
|
||||
export function kurzname(name, vorhandene = []) {
|
||||
const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene)
|
||||
let basis = (typeof name === 'string' ? name : '')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '')
|
||||
if (basis === '') basis = 'plan'
|
||||
if (!belegt.has(basis)) return basis
|
||||
for (let n = 2; ; n += 1) {
|
||||
const endung = `-${n}`
|
||||
const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}`
|
||||
if (!belegt.has(kandidat)) return kandidat
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine zum Pruefen
|
||||
|
||||
/**
|
||||
* Bereinigter Text (text() aus dem Terminplaner: ohne Steuerzeichen, getrimmt, Umbrueche = Leerzeichen); zu lang ist ein
|
||||
* Fehler, nie ein stilles Kuerzen. Fehlt der Wert, ist das Ergebnis '' (mit pflicht ein Fehler). Fehler liefern ''.
|
||||
*/
|
||||
function feldText(fehl, pfad, wert, max, { pflicht = false, leer = 'Bitte fülle dieses Feld aus.' } = {}) {
|
||||
if (fehltWert(wert)) {
|
||||
if (pflicht) fehl(pfad, leer)
|
||||
return ''
|
||||
}
|
||||
if (typeof wert !== 'string') {
|
||||
fehl(pfad, 'Bitte gib einen Text an.')
|
||||
return ''
|
||||
}
|
||||
const s = wert.length > ROH_TEXT ? null : text(wert, ROH_TEXT)
|
||||
if (s === null || [...s].length > max) {
|
||||
fehl(pfad, `Höchstens ${max} Zeichen.`)
|
||||
return ''
|
||||
}
|
||||
if (pflicht && s === '') fehl(pfad, leer)
|
||||
return s
|
||||
}
|
||||
|
||||
function wahrheit(fehl, pfad, wert, vorgabe) {
|
||||
if (fehltWert(wert)) return vorgabe
|
||||
if (typeof wert !== 'boolean') {
|
||||
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
|
||||
return vorgabe
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Kalendertag oder null: fehlend/leer = null; ungueltig = Fehler (und null). */
|
||||
function tagOptional(fehl, pfad, wert) {
|
||||
if (fehltWert(wert) || wert === '') return null
|
||||
if (!istTag(wert)) {
|
||||
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
|
||||
return null
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Zeitpunkt (ISO mit Zeitzone) als toISOString oder null. */
|
||||
function zeitpunkt(wert) {
|
||||
const m = typeof wert === 'string' ? ZEITPUNKT_RE.exec(wert) : null
|
||||
if (!m || !istTag(m[1])) return null
|
||||
const ms = Date.parse(wert)
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Telefon und Links
|
||||
|
||||
/**
|
||||
* Telefonnummer -> nur Ziffern mit Laendervorwahl (deutsche Nummern mit fuehrendem 49), sonst null.
|
||||
* '0171 1234567', '+49 171 1234567', '0049 171 1234567', '+49 (0) 171 1234567' -> '491711234567'; '(06 31) 12 34' -> '496311234'.
|
||||
* Erlaubt sind Ziffern, Leerzeichen, ( ) / . - und ein '+' ganz vorn; Buchstaben und alles andere = null. Eine Nummer ohne
|
||||
* 0, 00 oder + vorn gilt als schon international (so bleibt ein gespeichertes '491711234567' beim erneuten Speichern gleich);
|
||||
* 6 bis 15 Ziffern insgesamt (E.164).
|
||||
*/
|
||||
export function telefonZiffern(eingabe) {
|
||||
if (typeof eingabe !== 'string' || eingabe.length > 100) return null
|
||||
let s = eingabe.replace(/\s+/g, ' ').trim()
|
||||
if (!/^\+?[0-9 ()/.-]+$/.test(s)) return null
|
||||
s = s.replace(/^(\+ ?49|00 ?49) ?\( ?0 ?\)/, '$1').replace(/[()]/g, '')
|
||||
const ziffern = s.replace(/\D/g, '')
|
||||
let nummer
|
||||
if (s.startsWith('+')) {
|
||||
nummer = ziffern
|
||||
} else if (ziffern.startsWith('00')) {
|
||||
nummer = ziffern.slice(2)
|
||||
} else if (ziffern.startsWith('0')) {
|
||||
nummer = `49${ziffern.slice(1)}`
|
||||
} else {
|
||||
nummer = ziffern
|
||||
}
|
||||
if (nummer.startsWith('0')) return null
|
||||
if (nummer.startsWith('490')) nummer = `49${nummer.slice(3)}` // '+49 0171 …' (haeufiger Tippfehler)
|
||||
return nummer.length >= 6 && nummer.length <= 15 ? nummer : null
|
||||
}
|
||||
|
||||
const URL_MELDUNG = 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).'
|
||||
|
||||
// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern.
|
||||
const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/
|
||||
|
||||
/**
|
||||
* https-URL (<= 300, ohne Zugangsdaten, nur ASCII-Hostname) in kanonischer Schreibweise. Diese Adressen landen als href
|
||||
* auf oeffentlichen Seiten: Anfuehrungszeichen, Apostroph und Backtick im Host werden abgelehnt, ein Apostroph im Pfad
|
||||
* wird als %27 gespeichert (URL kodiert ihn dort nicht selbst). Wie urlFeld in termine/einstellungen.js (dort nicht exportiert).
|
||||
*/
|
||||
function linkZiel(fehl, pfad, wert) {
|
||||
const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : ''
|
||||
let url = null
|
||||
if (s !== '' && s.length <= ZEITEN_MAX.link && !/\s/.test(s)) {
|
||||
try { url = new URL(s) } catch { url = null }
|
||||
}
|
||||
const href = url ? url.href.replace(/'/g, '%27') : ''
|
||||
if (!url || url.protocol !== 'https:' || url.username || url.password || !HOST_RE.test(url.hostname) || href.length > ZEITEN_MAX.link) {
|
||||
fehl(pfad, URL_MELDUNG)
|
||||
return ''
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
/** Rufnummer eines Anruf-Knopfs: nur Ziffern, Leerzeichen, / und -, ein '+' nur vorn, mindestens eine Ziffer, <= 30 Zeichen; so gespeichert wie getippt. */
|
||||
function anrufZiel(fehl, pfad, wert) {
|
||||
const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : ''
|
||||
if (s === '' || [...s].length > ZEITEN_MAX.telefon || !/^\+?[0-9 /-]+$/.test(s) || !/\d/.test(s)) {
|
||||
fehl(pfad, 'Bitte gib eine Telefonnummer an (nur Ziffern, Leerzeichen, + / und -, höchstens 30 Zeichen).')
|
||||
return ''
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** Telefonfeld des Kontakts: leer = ''; sonst Ziffern mit Laendervorwahl. */
|
||||
function telefonFeld(fehl, pfad, wert, { pflicht = false } = {}) {
|
||||
if (fehltWert(wert) && !pflicht) return ''
|
||||
const s = feldText(fehl, pfad, wert, ZEITEN_MAX.telefon, { pflicht, leer: 'Bitte gib die Nummer an.' })
|
||||
if (s === '') return ''
|
||||
const ziffern = telefonZiffern(s)
|
||||
if (ziffern === null) {
|
||||
fehl(pfad, 'Bitte gib eine gültige Telefonnummer an (nur Ziffern, Leerzeichen, + ( ) / . und -).')
|
||||
return ''
|
||||
}
|
||||
return ziffern
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zeitfenster
|
||||
|
||||
/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), sonst null. */
|
||||
function minuten(wert, istEnde) {
|
||||
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenster eines Tages: { fehler } oder { fenster (sortiert, 'HH:MM'), ersteVon (Minuten|null), nachtBis (Minuten|null) }.
|
||||
* Regeln: <= 2 Fenster, 5er-Raster, bis == von ungueltig, ueberlappen verboten (Beruehren ist erlaubt), bis < von
|
||||
* (ueber Mitternacht) nur beim letzten Fenster. nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht
|
||||
* (der Aufrufer prueft ggf. gegen den Folgetag).
|
||||
*/
|
||||
function pruefeFenster(wert) {
|
||||
if (!Array.isArray(wert)) return { fehler: 'Ungültige Zeitfenster.' }
|
||||
if (wert.length > ZEITEN_LIMITS.fensterJeTag) return { fehler: `Pro Tag sind höchstens ${ZEITEN_LIMITS.fensterJeTag} Zeitfenster möglich.` }
|
||||
const f = []
|
||||
for (const eintrag of wert) {
|
||||
const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null
|
||||
const bis = von === null ? null : minuten(eintrag[1], true)
|
||||
if (von === null || bis === null) return { fehler: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.' }
|
||||
if (bis === von) return { fehler: 'Beginn und Ende dürfen nicht gleich sein.' }
|
||||
f.push({ von, bis })
|
||||
}
|
||||
f.sort((a, b) => a.von - b.von)
|
||||
for (let i = 0; i < f.length; i += 1) {
|
||||
if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.' }
|
||||
if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: 'Die Zeitfenster überschneiden sich.' }
|
||||
}
|
||||
const letztes = f[f.length - 1]
|
||||
return {
|
||||
fenster: f.map((w) => [hhmm(w.von), hhmm(w.bis)]),
|
||||
ersteVon: f.length ? f[0].von : null,
|
||||
nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modus + Fenster eines Tages/Eintrags: { modus, fenster, ersteVon, nachtBis } oder null (Fehler gemeldet).
|
||||
* Ein gueltiger Modus ist Pflicht; 'zeiten' braucht mindestens ein Fenster; bei jedem anderen Modus wird fenster [].
|
||||
*/
|
||||
function modusUndFenster(fehl, modusPfad, fensterPfad, modus, fensterRoh) {
|
||||
if (!MODI.includes(modus)) {
|
||||
fehl(modusPfad, 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.')
|
||||
return null
|
||||
}
|
||||
if (modus !== 'zeiten') return { modus, fenster: [], ersteVon: null, nachtBis: null }
|
||||
const r = pruefeFenster(fensterRoh)
|
||||
if (r.fehler) {
|
||||
fehl(fensterPfad, r.fehler)
|
||||
return null
|
||||
}
|
||||
if (r.fenster.length === 0) {
|
||||
fehl(fensterPfad, 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.')
|
||||
return null
|
||||
}
|
||||
return { modus, fenster: r.fenster, ersteVon: r.ersteVon, nachtBis: r.nachtBis }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitte
|
||||
|
||||
function normalisierePlaene(fehl, roh, alt) {
|
||||
const keine = { liste: [], ids: null }
|
||||
if (!Array.isArray(roh) || roh.length === 0) {
|
||||
fehl('plaene', 'Bitte lege mindestens einen Zeitplan an.')
|
||||
return keine
|
||||
}
|
||||
if (roh.length > ZEITEN_LIMITS.plaene) {
|
||||
fehl('plaene', `Es sind höchstens ${ZEITEN_LIMITS.plaene} Zeitpläne möglich.`)
|
||||
return keine
|
||||
}
|
||||
if (roh.filter((p) => istObjekt(p) && p.haupt === true).length !== 1) fehl('plaene', 'Genau ein Zeitplan muss der Hauptplan sein.')
|
||||
|
||||
// Kurznamen sind unveraenderlich (Platzhalter auf Kundenseiten nennen sie): bekannte ID = bisheriger Kurzname.
|
||||
// Auch die Kurznamen frueherer, inzwischen geloeschter Plaene bleiben belegt, damit ein neuer Plan nie unter einem
|
||||
// alten Platzhalter erscheint (kein stilles Umschalten).
|
||||
const bisherKurz = new Map()
|
||||
const belegt = new Set()
|
||||
for (const p of Array.isArray(alt?.plaene) ? alt.plaene : []) {
|
||||
if (istObjekt(p) && typeof p.id === 'string' && typeof p.kurz === 'string' && KURZ_RE.test(p.kurz)) {
|
||||
bisherKurz.set(p.id, p.kurz)
|
||||
belegt.add(p.kurz)
|
||||
}
|
||||
}
|
||||
|
||||
const idListe = ausweisen(roh, 'p')
|
||||
const liste = []
|
||||
roh.forEach((p, i) => {
|
||||
const id = idListe[i]
|
||||
if (!istObjekt(p)) return fehl(`plaene.${i}`, 'Ungültiger Eintrag.')
|
||||
const pfad = `plaene.${id}`
|
||||
const name = feldText(fehl, `${pfad}.name`, p.name, ZEITEN_MAX.name, { pflicht: true, leer: 'Bitte gib dem Zeitplan einen Namen.' })
|
||||
let kurz = bisherKurz.get(id)
|
||||
if (kurz === undefined) {
|
||||
kurz = kurzname(name, belegt)
|
||||
belegt.add(kurz)
|
||||
}
|
||||
liste.push({ id, kurz, name, haupt: p.haupt === true, tage: normalisiereWoche(fehl, pfad, p.tage) })
|
||||
})
|
||||
return { liste, ids: new Set(liste.map((p) => p.id)) }
|
||||
}
|
||||
|
||||
/** Wochenplan eines Plans: alle Tage 1..7, Fehler je Tag; Fenster ueber Mitternacht werden gegen das erste Fenster des Folgetags geprueft (7 -> 1). */
|
||||
function normalisiereWoche(fehl, pfad, roh) {
|
||||
const quelle = fehltWert(roh) ? {} : roh
|
||||
if (!istObjekt(quelle)) {
|
||||
fehl(`${pfad}.tage`, 'Ungültiger Wochenplan.')
|
||||
return {}
|
||||
}
|
||||
const tage = {}
|
||||
const geprueft = {}
|
||||
for (let d = 1; d <= 7; d += 1) {
|
||||
const tagPfad = `${pfad}.tage.${d}`
|
||||
const eintrag = quelle[d]
|
||||
if (fehltWert(eintrag)) {
|
||||
tage[d] = { modus: 'zu', fenster: [] }
|
||||
geprueft[d] = null
|
||||
} else if (!istObjekt(eintrag)) {
|
||||
fehl(tagPfad, 'Ungültiger Eintrag.')
|
||||
tage[d] = { modus: 'zu', fenster: [] }
|
||||
geprueft[d] = null
|
||||
} else {
|
||||
const r = modusUndFenster(fehl, tagPfad, tagPfad, eintrag.modus, eintrag.fenster)
|
||||
tage[d] = r ? { modus: r.modus, fenster: r.fenster } : { modus: 'zu', fenster: [] }
|
||||
geprueft[d] = r
|
||||
}
|
||||
}
|
||||
for (let d = 1; d <= 7; d += 1) {
|
||||
const heutiger = geprueft[d]
|
||||
const folgetag = geprueft[(d % 7) + 1]
|
||||
if (heutiger && heutiger.nachtBis !== null && folgetag && folgetag.ersteVon !== null && heutiger.nachtBis > folgetag.ersteVon) {
|
||||
fehl(`${pfad}.tage.${d}`, 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.')
|
||||
}
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
function normalisiereFeiertage(fehl, roh, grenze) {
|
||||
if (fehltWert(roh)) return {}
|
||||
if (!istObjekt(roh)) {
|
||||
fehl('feiertage', 'Ungültige Feiertage.')
|
||||
return {}
|
||||
}
|
||||
const aus = {}
|
||||
for (const tag of Object.keys(roh).sort()) {
|
||||
const pfad = `feiertage.${tag.slice(0, 20)}`
|
||||
if (!istTag(tag)) {
|
||||
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
|
||||
continue
|
||||
}
|
||||
const e = roh[tag]
|
||||
if (!istObjekt(e) || !MODI.includes(e.modus)) continue // nur Platzhalter ohne Abweichung: faellt weg
|
||||
const r = modusUndFenster(fehl, pfad, pfad, e.modus, e.fenster)
|
||||
if (r && tag >= grenze) aus[tag] = { modus: r.modus, fenster: r.fenster }
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
/** Liste aus der Eingabe: fehlend = leer; Nicht-Liste oder uebergrosse Rohliste = Fehler am Listenpfad. */
|
||||
function rohListe(fehl, pfad, wert, max, meldung) {
|
||||
if (fehltWert(wert)) return []
|
||||
if (!Array.isArray(wert)) {
|
||||
fehl(pfad, 'Ungültige Liste.')
|
||||
return []
|
||||
}
|
||||
if (wert.length > ROH_LISTE) {
|
||||
fehl(pfad, meldung)
|
||||
return []
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
function normalisiereAusnahmen(fehl, roh, planIds, grenze) {
|
||||
const meldung = `Es sind höchstens ${ZEITEN_LIMITS.ausnahmen} Ausnahmen möglich.`
|
||||
const eingang = rohListe(fehl, 'ausnahmen', roh, ZEITEN_LIMITS.ausnahmen, meldung)
|
||||
const idListe = ausweisen(eingang, 'x')
|
||||
const aus = []
|
||||
eingang.forEach((a, i) => {
|
||||
if (!istObjekt(a)) return fehl(`ausnahmen.${i}`, 'Ungültiger Eintrag.')
|
||||
const id = idListe[i]
|
||||
const pfad = `ausnahmen.${id}`
|
||||
const art = AUSNAHME_ARTEN.includes(a.art) ? a.art : null
|
||||
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Urlaub“ oder „Sondertag“.')
|
||||
const vonOk = istTag(a.von)
|
||||
if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.')
|
||||
// bis fehlt = ein Tag (bis = von)
|
||||
const bisFehlt = fehltWert(a.bis) || a.bis === ''
|
||||
const bisOk = bisFehlt ? vonOk : istTag(a.bis)
|
||||
if (!bisFehlt && !bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.')
|
||||
const von = vonOk ? a.von : ''
|
||||
const bis = bisOk ? (bisFehlt ? a.von : a.bis) : ''
|
||||
if (vonOk && bisOk) {
|
||||
if (von > bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
|
||||
else if (art === 'urlaub' && tageZwischen(von, bis) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Betriebsurlaub kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage dauern.`)
|
||||
}
|
||||
const eintragText = feldText(fehl, `${pfad}.text`, a.text, ZEITEN_MAX.ausnahmeText)
|
||||
let tag = { modus: 'zu', fenster: [] }
|
||||
if (art === 'sondertag') {
|
||||
const r = modusUndFenster(fehl, `${pfad}.modus`, `${pfad}.fenster`, a.modus, a.fenster)
|
||||
if (r) tag = { modus: r.modus, fenster: r.fenster }
|
||||
} // Urlaub ist immer geschlossen
|
||||
let plan = null
|
||||
if (!fehltWert(a.plan) && a.plan !== '') {
|
||||
if (typeof a.plan === 'string' && (planIds === null || planIds.has(a.plan))) plan = a.plan
|
||||
else fehl(`${pfad}.plan`, 'Dieser Zeitplan existiert nicht (mehr).')
|
||||
}
|
||||
if (bisOk && bis < grenze) return // vergangen (> 30 Tage): wird nicht mehr gespeichert
|
||||
aus.push({ id, art, von, bis, text: eintragText, modus: tag.modus, fenster: tag.fenster, plan })
|
||||
})
|
||||
if (aus.length > ZEITEN_LIMITS.ausnahmen) {
|
||||
fehl('ausnahmen', meldung)
|
||||
return []
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
function normalisiereKnopf(fehl, pfad, roh) {
|
||||
if (fehltWert(roh)) return null
|
||||
if (!istObjekt(roh)) {
|
||||
fehl(pfad, 'Ungültiger Knopf.')
|
||||
return null
|
||||
}
|
||||
const leer = (w) => fehltWert(w) || (typeof w === 'string' && text(w, ROH_TEXT) === '')
|
||||
if (leer(roh.ziel) && leer(roh.text)) return null // ein leerer Knopf ist kein Knopf
|
||||
const art = KNOPF_ARTEN.includes(roh.art) ? roh.art : null
|
||||
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Link“ oder „Anrufen“.')
|
||||
const beschriftung = feldText(fehl, `${pfad}.text`, roh.text, ZEITEN_MAX.knopfText, { pflicht: true, leer: 'Bitte gib dem Knopf eine Beschriftung.' })
|
||||
let ziel = ''
|
||||
if (art === 'link') ziel = linkZiel(fehl, `${pfad}.ziel`, roh.ziel)
|
||||
else if (art === 'anruf') ziel = anrufZiel(fehl, `${pfad}.ziel`, roh.ziel)
|
||||
return { art, ziel, text: beschriftung }
|
||||
}
|
||||
|
||||
function normalisiereHinweise(fehl, roh, grenze) {
|
||||
const meldung = `Es sind höchstens ${ZEITEN_LIMITS.hinweise} Hinweise möglich.`
|
||||
const eingang = rohListe(fehl, 'hinweise', roh, ZEITEN_LIMITS.hinweise, meldung)
|
||||
const idListe = ausweisen(eingang, 'h')
|
||||
const aus = []
|
||||
eingang.forEach((h, i) => {
|
||||
if (!istObjekt(h)) return fehl(`hinweise.${i}`, 'Ungültiger Eintrag.')
|
||||
const id = idListe[i]
|
||||
const pfad = `hinweise.${id}`
|
||||
const art = HINWEIS_ARTEN.includes(h.art) ? h.art : null
|
||||
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle Info, Urlaub, Aktion oder Wichtig.')
|
||||
const hinweisText = feldText(fehl, `${pfad}.text`, h.text, ZEITEN_MAX.hinweisText, { pflicht: true, leer: 'Bitte gib einen Text ein.' })
|
||||
const knopf = normalisiereKnopf(fehl, `${pfad}.knopf`, h.knopf)
|
||||
const von = tagOptional(fehl, `${pfad}.von`, h.von)
|
||||
const bisRoh = tagOptional(fehl, `${pfad}.bis`, h.bis)
|
||||
const bisKaputt = !fehltWert(h.bis) && h.bis !== '' && bisRoh === null
|
||||
if (von !== null && bisRoh !== null) {
|
||||
if (von > bisRoh) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
|
||||
else if (art === 'urlaub' && tageZwischen(von, bisRoh) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Urlaubshinweis kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage gelten.`)
|
||||
}
|
||||
if (art === 'aktion' && bisRoh === null && !bisKaputt) fehl(`${pfad}.bis`, 'Eine Aktion braucht ein Enddatum.')
|
||||
if (bisRoh !== null && bisRoh < grenze) return // vergangen (> 30 Tage)
|
||||
aus.push({ id, art, text: hinweisText, knopf, von, bis: bisRoh })
|
||||
})
|
||||
if (aus.length > ZEITEN_LIMITS.hinweise) {
|
||||
fehl('hinweise', meldung)
|
||||
return []
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
function normalisiereKontakt(fehl, roh) {
|
||||
const vorgabe = { an: false, telefon: '', adresse: '', whatsapp: null }
|
||||
if (fehltWert(roh)) return vorgabe
|
||||
if (!istObjekt(roh)) {
|
||||
fehl('kontakt', 'Ungültige Kontaktangaben.')
|
||||
return vorgabe
|
||||
}
|
||||
return {
|
||||
an: wahrheit(fehl, 'kontakt.an', roh.an, false),
|
||||
telefon: telefonFeld(fehl, 'kontakt.telefon', roh.telefon),
|
||||
adresse: feldText(fehl, 'kontakt.adresse', roh.adresse, ZEITEN_MAX.adresse),
|
||||
whatsapp: normalisiereWhatsapp(fehl, roh.whatsapp),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp: Nummer (Ziffern mit Vorwahl) und der Zeitpunkt der Datenschutz-Bestaetigung sind Pflicht. bestaetigtAt wird hier
|
||||
* nur geprueft und uebernommen (ISO-Zeitpunkt mit Zeitzone, gespeichert als toISOString, beim erneuten Speichern unveraendert);
|
||||
* ob die Kundenansicht ihn neu setzen oder aendern darf, entscheidet die Route (Vergleich mit bisher.kontakt.whatsapp.bestaetigtAt).
|
||||
*/
|
||||
function normalisiereWhatsapp(fehl, roh) {
|
||||
if (fehltWert(roh)) return null
|
||||
if (!istObjekt(roh)) {
|
||||
fehl('kontakt.whatsapp', 'Ungültige WhatsApp-Angaben.')
|
||||
return null
|
||||
}
|
||||
const nummer = telefonFeld(fehl, 'kontakt.whatsapp.nummer', roh.nummer, { pflicht: true })
|
||||
const starttext = feldText(fehl, 'kontakt.whatsapp.starttext', roh.starttext, ZEITEN_MAX.starttext)
|
||||
const bestaetigtAt = zeitpunkt(roh.bestaetigtAt)
|
||||
if (bestaetigtAt === null) fehl('kontakt.whatsapp.bestaetigtAt', 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.')
|
||||
return { nummer, starttext, bestaetigtAt }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Oeffentliche Funktionen
|
||||
|
||||
/**
|
||||
* Prueft und normalisiert die Zeiten-Daten (Eingabe vom Client). bisher = zuletzt gespeicherte Daten (nur fuer die
|
||||
* unveraenderlichen Kurznamen der Plaene); now = Pflicht (Vergangenes entfernen: Berliner Kalendertag).
|
||||
* => { daten } oder { fehler: { [pfad]: text } }. Nach dem Normalisieren darf JSON.stringify(daten) hoechstens
|
||||
* 32768 Zeichen lang sein (sonst fehler.daten).
|
||||
*/
|
||||
export function normalisiereZeiten(eingabe, { bisher = null, now } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereZeiten: now fehlt')
|
||||
const fehler = {}
|
||||
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
|
||||
if (!istObjekt(eingabe)) return { fehler: { daten: 'Die Zeiten sind ungültig.' } }
|
||||
const alt = istObjekt(bisher) ? bisher : null
|
||||
const grenze = plusTage(heute(now), -ZEITEN_LIMITS.vergangenTage)
|
||||
|
||||
let land = STANDARD_LAND
|
||||
if (!fehltWert(eingabe.land)) {
|
||||
if (typeof eingabe.land === 'string' && BUNDESLAENDER.includes(eingabe.land)) land = eingabe.land
|
||||
else fehl('land', 'Bitte wähle ein Bundesland.')
|
||||
}
|
||||
const { liste: plaene, ids } = normalisierePlaene(fehl, eingabe.plaene, alt)
|
||||
const daten = {
|
||||
land,
|
||||
plaene,
|
||||
feiertage: normalisiereFeiertage(fehl, eingabe.feiertage, grenze),
|
||||
ausnahmen: normalisiereAusnahmen(fehl, eingabe.ausnahmen, ids, grenze),
|
||||
hinweise: normalisiereHinweise(fehl, eingabe.hinweise, grenze),
|
||||
kontakt: normalisiereKontakt(fehl, eingabe.kontakt),
|
||||
}
|
||||
if (Object.keys(fehler).length === 0 && JSON.stringify(daten).length > ZEITEN_LIMITS.daten) fehl('daten', DATEN_ZU_GROSS)
|
||||
return Object.keys(fehler).length ? { fehler } : { daten }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft und normalisiert die Team-Einstellungen (nur ueber WOMS): { navSelektor: null|string, kontaktleisteAus, hinweisleisteAus }.
|
||||
* navSelektor: <= 120 Zeichen aus [A-Za-z0-9 #.\-_>:\[\]="'] (also nie < { } ;); leer = null.
|
||||
* => { team } oder { fehler: { [pfad]: text } }.
|
||||
*/
|
||||
export function normalisiereTeam(eingabe) {
|
||||
const fehler = {}
|
||||
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
|
||||
if (!istObjekt(eingabe)) return { fehler: { team: 'Die Team-Einstellungen sind ungültig.' } }
|
||||
|
||||
let navSelektor = null
|
||||
if (!fehltWert(eingabe.navSelektor)) {
|
||||
const s = typeof eingabe.navSelektor === 'string' ? eingabe.navSelektor.trim() : null
|
||||
if (s === null || s.length > ZEITEN_MAX.navSelektor || (s !== '' && !NAV_RE.test(s))) {
|
||||
fehl('navSelektor', `Der Selektor darf höchstens ${ZEITEN_MAX.navSelektor} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.`)
|
||||
} else if (s !== '') {
|
||||
navSelektor = s
|
||||
}
|
||||
}
|
||||
const team = {
|
||||
navSelektor,
|
||||
kontaktleisteAus: wahrheit(fehl, 'kontaktleisteAus', eingabe.kontaktleisteAus, false),
|
||||
hinweisleisteAus: wahrheit(fehl, 'hinweisleisteAus', eingabe.hinweisleisteAus, false),
|
||||
}
|
||||
return Object.keys(fehler).length ? { fehler } : { team }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten-Schalter (Funktionsschluessel 'zeiten'): ZEITEN_ENABLED = off (Vorgabe) | pilot | on.
|
||||
* Liest config.zeiten bei jedem Aufruf. Bei off laufen keine Zeiten-Jobs und keine Zeiten-Abfragen.
|
||||
*/
|
||||
|
||||
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
|
||||
export function zeitenModus() {
|
||||
const mode = config.zeiten.mode
|
||||
return mode === 'pilot' || mode === 'on' ? mode : 'off'
|
||||
}
|
||||
|
||||
export function zeitenAus() {
|
||||
return zeitenModus() === 'off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Kunde Oeffnungszeiten live nutzen? pilot = nur Adressen aus ZEITEN_PILOT_EMAILS ('*' = alle; eine
|
||||
* leere Liste laesst niemanden durch); on = alle Kunden; off = niemand.
|
||||
*/
|
||||
export function zeitenFuerKunde(email) {
|
||||
const modus = zeitenModus()
|
||||
if (modus === 'off') return false
|
||||
if (modus === 'on') return true
|
||||
const erlaubt = config.zeiten.pilotEmails
|
||||
if (erlaubt.includes('*')) return true
|
||||
const adresse = String(email || '').trim().toLowerCase()
|
||||
return adresse !== '' && erlaubt.includes(adresse)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Oeffnungszeiten live: Appwrite-Schema der beiden Collections und der neuen portalFeatures-Felder
|
||||
* (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitte 2.1 bis 2.3).
|
||||
* Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(),
|
||||
* ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in
|
||||
* config.collections.<Name>; scripts/zeiten-setup.mjs legt alles an (auch die portalFeatures-Felder, bewusst nicht
|
||||
* scripts/feature-setup.mjs: dessen Tests pinnen genau drei Attribute).
|
||||
*
|
||||
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
|
||||
* Index: { key, type: 'key', attributes: [...] }
|
||||
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen.
|
||||
*/
|
||||
|
||||
const str = (key, size) => ({ key, type: 'string', size, required: false })
|
||||
const int = (key) => ({ key, type: 'integer', required: false })
|
||||
const dt = (key) => ({ key, type: 'datetime', required: false })
|
||||
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
|
||||
|
||||
export const ZEITEN_COLLECTIONS = {
|
||||
// Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Nur der Server-Key schreibt.
|
||||
// daten = JSON der Kundeneingaben (hoechstens 32 KB, die Spalte ist groesser), team = JSON der Team-Einstellungen
|
||||
// (nur ueber WOMS aenderbar), ersatzHash = Hash des Hauptplan-Wochenplans beim letzten Ersatz-HTML,
|
||||
// updatedBy = 'kunde' | 'team:<staffId>'.
|
||||
oeffnungszeiten: {
|
||||
attributes: [
|
||||
str('customerId', 64),
|
||||
str('projectId', 64),
|
||||
str('featureId', 64),
|
||||
int('version'),
|
||||
str('daten', 40000),
|
||||
int('teamVersion'),
|
||||
str('team', 2000),
|
||||
str('ersatzHash', 64),
|
||||
dt('updatedAt'),
|
||||
str('updatedBy', 64),
|
||||
],
|
||||
// idx_customer: Loeschung beim Kunden-Loeschen
|
||||
indexes: [index('idx_customer', 'customerId')],
|
||||
},
|
||||
|
||||
// Erinnerungsmerker der Feiertags-Mail; Dokument-ID = sha1(featureId|datum) gekuerzt (anlegen mit 409 = schon
|
||||
// erinnert). datum = Kalendertag 'YYYY-MM-DD' (Berliner Zeit); der Tagesjob loescht Merker, deren Datum > 30 Tage
|
||||
// zurueckliegt (idx_datum); idx_feature: Loeschung der Merker einer Funktion (Kunden-Loeschen).
|
||||
zeitenErinnerungen: {
|
||||
attributes: [str('featureId', 64), str('datum', 10), dt('createdAt')],
|
||||
indexes: [index('idx_datum', 'datum'), index('idx_feature', 'featureId')],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Neue Felder von portalFeatures (alle optional), angelegt von scripts/zeiten-setup.mjs:
|
||||
* - unlockedBy: 'kauf' | 'abo' | 'team'
|
||||
* - aboEndeAt: Zeitpunkt, an dem der Abgleich das Ende des Wartungs-Abos erkannt hat
|
||||
* - einbau: 'angestossen' | 'manuell' (Ergebnis des Neubau-Anstosses)
|
||||
* - behaltenSessionId: offene Behalten-Zahlung (Stripe-Sitzung)
|
||||
* - einschaltMailAt: Marker der Mail K27 (Selbstheilung)
|
||||
*/
|
||||
export const ZEITEN_FEATURE_ATTRIBUTES = [
|
||||
str('unlockedBy', 8),
|
||||
dt('aboEndeAt'),
|
||||
str('einbau', 12),
|
||||
str('behaltenSessionId', 128),
|
||||
dt('einschaltMailAt'),
|
||||
]
|
||||
|
||||
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
|
||||
export const ZEITEN_DATETIMES = Object.fromEntries(
|
||||
Object.entries(ZEITEN_COLLECTIONS).map(([name, { attributes }]) => [
|
||||
name,
|
||||
attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key),
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
import { config } from '../../config.js'
|
||||
import { createDocument, deleteDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { vergissZeiten, zeitenCache } from './cache.js'
|
||||
import { normalisiereTeam, normalisiereZeiten } from './pruefen.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: Speicher in Appwrite (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1).
|
||||
* Ein Dokument je Funktion in config.collections.oeffnungszeiten, Dokument-ID = ID des portalFeatures-Dokuments (feature.$id).
|
||||
*
|
||||
* Zwei getrennte Versionen (optimistische Sperre): `version` zaehlt die Speichervorgaenge des Kunden (daten), `teamVersion` die
|
||||
* des Teams (team); ein Kunde im Editor und das Team in WOMS stoeren sich so nicht. 0 = noch nie gespeichert. Speichert das Team,
|
||||
* bevor der Kunde Zeiten eingetragen hat, entsteht das Dokument mit version 0 und ohne daten (ladeZeiten: daten null); der
|
||||
* Kunde speichert danach mit version 0 und bekommt 1.
|
||||
* Weicht die mitgebrachte Version von der gespeicherten ab (auch wenn sie fehlt oder keine ganze Zahl ist), passiert nichts und
|
||||
* es folgt { status: 409, code: 'ZEITEN_VERALTET' }; ungueltige Eingaben ergeben { status: 400, fehler }.
|
||||
*
|
||||
* Alle Schreibzugriffe (Zeiten, Team, Ersatz-Hash, Loeschen) laufen unter der Sperre `zeiten:<projectId>` (nicht wiederbetretbar:
|
||||
* Aufrufer duerfen sie nicht selbst halten); gelesen und verglichen wird INNERHALB der Sperre. Nach jedem Schreibversuch (auch
|
||||
* einem fehlgeschlagenen: unklar, was in der Datenbank steht) verwirft vergissZeiten die oeffentliche Antwort dieses siteKeys.
|
||||
*/
|
||||
|
||||
const veraltet = () =>
|
||||
Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' })
|
||||
|
||||
const ungueltig = (nachricht, fehler) => Object.assign(new Error(nachricht), { status: 400, fehler })
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
function pruefeText(wert, name) {
|
||||
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
|
||||
}
|
||||
|
||||
function pruefeFeature(feature) {
|
||||
pruefeText(feature?.$id, 'feature.$id')
|
||||
pruefeText(feature.projectId, 'feature.projectId')
|
||||
}
|
||||
|
||||
function pruefeJetzt(now) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum')
|
||||
return now
|
||||
}
|
||||
|
||||
/** updatedBy: 'kunde' oder 'team:<staffId>' (Spalte 64 Zeichen). */
|
||||
const vonWem = (by, standard) => String(by ?? '').trim().slice(0, 64) || standard
|
||||
|
||||
/** Sperre aller Schreibzugriffe auf die Zeiten eines Projekts. */
|
||||
const sperre = (projectId) => `zeiten:${projectId}`
|
||||
|
||||
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt, das ist ein Fehler und wird weitergereicht). */
|
||||
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
|
||||
|
||||
async function holeOderNull(collectionId, id) {
|
||||
try {
|
||||
return await getDocument(collectionId, id)
|
||||
} catch (err) {
|
||||
if (dokumentFehlt(err)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Dokument loeschen; war es schon weg, ist das kein Fehler. => true, wenn dieser Aufruf es geloescht hat. */
|
||||
async function loescheOderIgnoriere(collectionId, id) {
|
||||
try {
|
||||
await deleteDocument(collectionId, id)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (dokumentFehlt(err)) return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const versionVon = (doc, feld) => (doc && Number.isInteger(doc[feld]) ? doc[feld] : 0)
|
||||
|
||||
/** JSON-Objekt aus einem Textfeld; fehlt es: null (still), unlesbar: null mit einer Logzeile ohne Inhalt (der gehoert nicht ins Log). */
|
||||
function liesObjekt(doc, feld) {
|
||||
const roh = doc?.[feld]
|
||||
if (typeof roh !== 'string' || roh === '') return null
|
||||
try {
|
||||
const wert = JSON.parse(roh)
|
||||
if (istObjekt(wert)) return wert
|
||||
} catch {
|
||||
// unten gemeldet
|
||||
}
|
||||
console.error(`[zeiten-speicher] ${feld} von ${doc.$id} ist unlesbar`)
|
||||
return null
|
||||
}
|
||||
|
||||
const standardTeam = () => normalisiereTeam({}).team
|
||||
|
||||
/** Team-Einstellungen aus dem Dokument; fehlend, unlesbar oder nicht mehr gueltig (von Hand veraendert) ergibt die Vorgabe. */
|
||||
function liesTeam(doc) {
|
||||
const roh = liesObjekt(doc, 'team')
|
||||
if (!roh) return standardTeam()
|
||||
const erg = normalisiereTeam(roh)
|
||||
if (erg.fehler) {
|
||||
console.error(`[zeiten-speicher] team von ${doc.$id} ist ungueltig`)
|
||||
return standardTeam()
|
||||
}
|
||||
return erg.team
|
||||
}
|
||||
|
||||
function grundfelder(feature) {
|
||||
return { customerId: feature.customerId ?? '', projectId: feature.projectId, featureId: feature.$id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt felder in das Dokument der Funktion (aendern; gibt es keins, mit den Grundfeldern anlegen, beide Versionen 0, soweit
|
||||
* felder sie nicht setzt). Legt ein anderer Vorgang das Dokument zwischen Lesen und Anlegen an (Appwrite 409), ist der Stand veraltet.
|
||||
* Danach (auch bei Fehlern) ist die oeffentliche Antwort dieses siteKeys vergessen.
|
||||
*/
|
||||
async function schreibe(feature, vorhanden, felder) {
|
||||
const col = config.collections.oeffnungszeiten
|
||||
try {
|
||||
if (vorhanden) {
|
||||
await updateDocument(col, feature.$id, felder)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createDocument(col, { ...grundfelder(feature), version: 0, teamVersion: 0, ...felder }, feature.$id)
|
||||
} catch (err) {
|
||||
if (err?.status === 409) throw veraltet()
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
vergissZeiten(feature.siteKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gespeicherten Zeiten einer Funktion.
|
||||
* @param {string} featureId ID des portalFeatures-Dokuments (= ID des oeffnungszeiten-Dokuments)
|
||||
* @returns {Promise<{ daten: object|null, version: number, team: object, teamVersion: number, ersatzHash: string }|null>}
|
||||
* null, wenn es kein Dokument gibt. daten ist null, wenn der Kunde noch nichts gespeichert hat (nur Team-Einstellungen oder
|
||||
* Ersatz-Hash vorhanden) oder die Daten unlesbar sind (version bleibt die des Dokuments: wer sie ueberschreiben will, speichert
|
||||
* mit genau dieser Version). team ist immer vollstaendig (Vorgabe, wenn nichts oder nichts Gueltiges gespeichert ist),
|
||||
* ersatzHash '' ohne Eintrag.
|
||||
*/
|
||||
export async function ladeZeiten(featureId) {
|
||||
pruefeText(featureId, 'featureId')
|
||||
const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId)
|
||||
if (!doc) return null
|
||||
return {
|
||||
daten: liesObjekt(doc, 'daten'),
|
||||
version: versionVon(doc, 'version'),
|
||||
team: liesTeam(doc),
|
||||
teamVersion: versionVon(doc, 'teamVersion'),
|
||||
ersatzHash: typeof doc.ersatzHash === 'string' ? doc.ersatzHash : '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Zeiten des Kunden (oder des Teams im Namen des Kunden). Pruefen (normalisiereZeiten, mit den gespeicherten Daten als
|
||||
* bisher fuer die unveraenderlichen Kurznamen) und Schreiben laufen unter der Sperre, nach der Versionspruefung: ist die Version
|
||||
* veraltet, gewinnt 409, auch bei falscher Eingabe.
|
||||
* @param {{ feature: { $id: string, customerId: string, projectId: string, siteKey?: string },
|
||||
* eingabe: object, version: number, by?: string, now?: Date }} p
|
||||
* version = Version, auf der die Aenderung beruht (0 = noch nie gespeichert); by = 'kunde' (Vorgabe) | 'team:<staffId>'
|
||||
* @returns {Promise<{ daten: object, version: number }>} das Normalisierte, wie gespeichert, mit der neuen Version
|
||||
* @throws {Error} status 400 mit fehler (je Pfad eine Meldung) / status 409 mit code 'ZEITEN_VERALTET'
|
||||
*/
|
||||
export async function speichereZeiten({ feature, eingabe, version, by = 'kunde', now = new Date() } = {}) {
|
||||
pruefeFeature(feature)
|
||||
const jetzt = pruefeJetzt(now)
|
||||
|
||||
return withLocks([sperre(feature.projectId)], async () => {
|
||||
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
|
||||
const aktuell = versionVon(vorhanden, 'version')
|
||||
if (version !== aktuell) throw veraltet()
|
||||
|
||||
const erg = normalisiereZeiten(eingabe, { bisher: vorhanden ? liesObjekt(vorhanden, 'daten') : null, now: jetzt })
|
||||
if (erg.fehler) throw ungueltig('Bitte prüfe deine Eingaben.', erg.fehler)
|
||||
|
||||
await schreibe(feature, vorhanden, {
|
||||
daten: JSON.stringify(erg.daten),
|
||||
version: aktuell + 1,
|
||||
updatedAt: jetzt.toISOString(),
|
||||
updatedBy: vonWem(by, 'kunde'),
|
||||
})
|
||||
return { daten: erg.daten, version: aktuell + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Team-Einstellungen (nur ueber WOMS); eigene Version teamVersion, die Kundendaten und deren Version bleiben.
|
||||
* Gibt es noch kein Dokument, wird eins ohne daten angelegt (version 0).
|
||||
* @param {{ feature: object, eingabe: object, teamVersion: number, by?: string, now?: Date }} p by = 'team:<staffId>'
|
||||
* @returns {Promise<{ team: object, teamVersion: number }>}
|
||||
* @throws {Error} status 400 mit fehler / status 409 mit code 'ZEITEN_VERALTET'
|
||||
*/
|
||||
export async function speichereTeam({ feature, eingabe, teamVersion, by = 'team', now = new Date() } = {}) {
|
||||
pruefeFeature(feature)
|
||||
const jetzt = pruefeJetzt(now)
|
||||
|
||||
return withLocks([sperre(feature.projectId)], async () => {
|
||||
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
|
||||
const aktuell = versionVon(vorhanden, 'teamVersion')
|
||||
if (teamVersion !== aktuell) throw veraltet()
|
||||
|
||||
const erg = normalisiereTeam(eingabe)
|
||||
if (erg.fehler) throw ungueltig('Bitte prüfe die Team-Einstellungen.', erg.fehler)
|
||||
|
||||
await schreibe(feature, vorhanden, {
|
||||
team: JSON.stringify(erg.team),
|
||||
teamVersion: aktuell + 1,
|
||||
updatedAt: jetzt.toISOString(),
|
||||
updatedBy: vonWem(by, 'team'),
|
||||
})
|
||||
return { team: erg.team, teamVersion: aktuell + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt den Hash des Hauptplan-Wochenplans zum Zeitpunkt des Ersatz-HTMLs (wochenplanHash, '' = zuruecksetzen). Fasst weder
|
||||
* die Versionen noch updatedAt/updatedBy noch Daten und Team an: kein Kunde und kein Team-Editor wird dadurch veraltet.
|
||||
* Gibt es noch kein Dokument, entsteht eins ohne daten (version 0).
|
||||
*/
|
||||
export async function setzeErsatzHash(feature, hash) {
|
||||
pruefeFeature(feature)
|
||||
if (typeof hash !== 'string' || hash.length > 64) throw new TypeError('hash muss ein Text mit hoechstens 64 Zeichen sein')
|
||||
|
||||
await withLocks([sperre(feature.projectId)], async () => {
|
||||
const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id)
|
||||
await schreibe(feature, vorhanden, vorhanden ? { ersatzHash: hash } : { ersatzHash: hash, updatedAt: new Date().toISOString(), updatedBy: 'system' })
|
||||
})
|
||||
}
|
||||
|
||||
/** Loescht alle Erinnerungsmerker einer Funktion (mehrere Seiten moeglich). */
|
||||
async function loescheMerker(featureId) {
|
||||
const merker = await listAllDocuments(config.collections.zeitenErinnerungen, [Query.equal('featureId', featureId), Query.select(['featureId'])], { pageSize: 100 })
|
||||
for (const doc of merker) await loescheOderIgnoriere(config.collections.zeitenErinnerungen, doc.$id)
|
||||
}
|
||||
|
||||
/** Loescht Dokument (unter der Sperre seines Projekts) und Merker einer Funktion. => true, wenn dieser Aufruf das Dokument geloescht hat. */
|
||||
async function loescheFunktion(featureId, projectId) {
|
||||
const dokumentGeloescht = await withLocks([sperre(projectId)], () => loescheOderIgnoriere(config.collections.oeffnungszeiten, featureId))
|
||||
await loescheMerker(featureId)
|
||||
return dokumentGeloescht
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht die Zeiten einer Funktion samt Erinnerungsmerkern (Abo-Ende nach der Frist). Ein fehlendes Dokument ist kein Fehler.
|
||||
* Die oeffentliche Antwort wird verworfen (der siteKey ist hier nicht bekannt: der ganze Speicher wird geleert, Loeschen ist selten).
|
||||
* @returns {Promise<boolean>} true, wenn es ein Dokument gab und es geloescht wurde
|
||||
*/
|
||||
export async function loescheZeiten(featureId) {
|
||||
pruefeText(featureId, 'featureId')
|
||||
try {
|
||||
const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId)
|
||||
if (!doc) {
|
||||
await loescheMerker(featureId)
|
||||
return false
|
||||
}
|
||||
return await loescheFunktion(featureId, doc.projectId)
|
||||
} finally {
|
||||
zeitenCache.leere()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht alle Zeiten-Dokumente eines Kunden samt Erinnerungsmerkern (Kunden-Loeschen). Jedes Dokument unter der Sperre seines
|
||||
* Projekts; ein Fehler bricht nicht ab: alle uebrigen werden trotzdem geloescht und der erste Fehler kommt am Ende (ein neuer
|
||||
* Aufruf raeumt den Rest).
|
||||
* @returns {Promise<number>} Anzahl der geloeschten Dokumente
|
||||
*/
|
||||
export async function loescheZeitenVonKunde(customerId) {
|
||||
pruefeText(customerId, 'customerId')
|
||||
let geloescht = 0
|
||||
let ersterFehler = null
|
||||
try {
|
||||
const docs = await listAllDocuments(config.collections.oeffnungszeiten, [Query.equal('customerId', customerId), Query.select(['projectId'])], { pageSize: 100 })
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
if (await loescheFunktion(doc.$id, doc.projectId)) geloescht += 1
|
||||
} catch (err) {
|
||||
ersterFehler ??= err
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
zeitenCache.leere()
|
||||
}
|
||||
if (ersterFehler) throw ersterFehler
|
||||
return geloescht
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { kuerze, minimaleTicketDetails, TICKET_DETAILS_MAX, TICKET_GEKUERZT } from '../featureRegistry.js'
|
||||
import { ladeZeiten } from './speicher.js'
|
||||
|
||||
/**
|
||||
* Details des Einbau-Tickets von Oeffnungszeiten live (Spec 4.6, config.features.zeiten.ticket.details).
|
||||
* Kopfzeilen und Verweis auf das Kundenpanel im WOMS (Notfall-Details des Registers), darunter die Einbau-Zeile mit
|
||||
* siteKey, das Ergebnis des Neubau-Anstosses (doc.einbau), Beispiele der Platzhalter mit den Kurznamen der Plaene,
|
||||
* der Hinweis auf das Ersatz-HTML und die Pruefliste. ctx = { feature, doc, project, customer }.
|
||||
* Wirft nie wegen der gespeicherten Zeiten: ohne Dokument-ID, ohne Zeiten oder bei einem Lesefehler heisst es
|
||||
* "Plaene folgen". Der Text bleibt hoechstens TICKET_DETAILS_MAX Zeichen lang.
|
||||
*/
|
||||
|
||||
const EMBED_URL = 'https://project.webklar.com/embed/zeiten.js'
|
||||
const PLATZHALTER_MODI = Object.freeze(['tabelle', 'status', 'badge'])
|
||||
|
||||
const EINBAU_ERGEBNIS = Object.freeze({
|
||||
angestossen: 'automatisch eingesetzt (nach dem nächsten Veröffentlichen prüfen)',
|
||||
manuell: 'bitte von Hand einsetzen',
|
||||
})
|
||||
|
||||
const PRUEFLISTE = Object.freeze([
|
||||
'Feste Zeiten im Repo suchen und ersetzen (grep -ril "öffnungszeit" u. Ä.), auch Footer, Kontaktseite und eigene Status-Skripte.',
|
||||
'Header: Überlappt die Hinweis-Leiste den Kopf der Seite? Sonst im WOMS den navSelektor setzen.',
|
||||
'Hat die Seite schon eine eigene Anruf-Leiste? Dann nicht doppelt.',
|
||||
'Vorhandenes JSON-LD (Öffnungszeiten der Seite) prüfen, damit nichts doppelt oder widersprüchlich steht.',
|
||||
'Datenschutz-Absatz zu Öffnungszeiten live auf der Website ergänzt?',
|
||||
])
|
||||
|
||||
/** Kurznamen der gespeicherten Plaene, Hauptplan zuerst: [{ kurz, haupt }]; leer ohne Zeiten oder bei jedem Fehler. */
|
||||
async function planKurznamen(doc) {
|
||||
if (typeof doc?.$id !== 'string' || doc.$id === '') return []
|
||||
try {
|
||||
const gespeichert = await ladeZeiten(doc.$id)
|
||||
const plaene = Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : []
|
||||
const liste = plaene
|
||||
.filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '')
|
||||
.map((p) => ({ kurz: p.kurz, haupt: p.haupt === true }))
|
||||
return [...liste.filter((p) => p.haupt), ...liste.filter((p) => !p.haupt)]
|
||||
} catch (err) {
|
||||
console.warn(`[zeiten] Pläne für das Ticket von ${doc.$id} nicht lesbar: ${err?.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function zeitenTicketDetails(ctx) {
|
||||
const doc = ctx?.doc || {}
|
||||
const siteKey = typeof doc.siteKey === 'string' ? doc.siteKey : ''
|
||||
const plaene = await planKurznamen(doc)
|
||||
const beispielKurz = plaene[0]?.kurz ?? '<kurz>'
|
||||
// einbau fehlt oder ist unbekannt: nichts wurde automatisch eingesetzt
|
||||
const ergebnis = EINBAU_ERGEBNIS[doc.einbau] ?? EINBAU_ERGEBNIS.manuell
|
||||
|
||||
const text = [
|
||||
minimaleTicketDetails(ctx),
|
||||
'',
|
||||
'Einbau-Zeile (im <head> jeder HTML-Seite):',
|
||||
`<script data-wk-zeiten src="${EMBED_URL}" data-site="${siteKey}" defer></script>`,
|
||||
`Einbau: ${ergebnis}`,
|
||||
'',
|
||||
'Platzhalter (dort, wo die Zeiten stehen sollen; innen bleibt die statische Ersatz-Tabelle):',
|
||||
...PLATZHALTER_MODI.map((modus) => `<div data-webklar-zeiten data-modus="${modus}" data-plan="${beispielKurz}">Ersatz-HTML</div>`),
|
||||
plaene.length > 0
|
||||
? `Pläne (Kurznamen für data-plan): ${plaene.map((p) => (p.haupt ? `${p.kurz} (Hauptplan)` : p.kurz)).join(', ')}`
|
||||
: 'Pläne folgen, sobald der Kunde gespeichert hat',
|
||||
'Ersatz-HTML: WOMS → Funktionen → Ersatz-HTML kopieren (erst nach dem ersten Speichern des Kunden)',
|
||||
'',
|
||||
'Prüfliste:',
|
||||
...PRUEFLISTE.map((punkt) => `- ${punkt}`),
|
||||
].join('\n')
|
||||
|
||||
return kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT })
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import { zuUtc, ausUtc, wochentag, plusTage } from '../termine/zeit.js'
|
||||
import { BUNDESLAENDER, feiertageImZeitraum } from '../termine/feiertage.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: Zeitlogik (rein, Europe/Berlin).
|
||||
* Kein Appwrite, keine Config, kein eigener Zeitgeber: Aufrufer geben Daten und 'now' hinein.
|
||||
* Alles rechnet in Berliner Wanduhrzeit ueber termine/zeit.js; Kalendertage nur mit plusTage, nie als Vielfache von Tages-Millisekunden.
|
||||
*
|
||||
* Datenform (Spec 2.1): daten = { land, plaene: [{ id, kurz, name, haupt, tage: { 1..7: { modus, fenster } } }],
|
||||
* feiertage: { 'YYYY-MM-DD': { modus, fenster } }, ausnahmen: [{ id, art: 'urlaub'|'sondertag', von, bis, text,
|
||||
* modus, fenster, plan: null|planId }], ... }. modus: 'zu' | 'zeiten' | 'rund' | 'vereinbarung';
|
||||
* fenster: [['09:00','12:30'], ...] (nur bei 'zeiten'; 'bis' vor 'von' = bis zum Folgetag).
|
||||
*
|
||||
* Alle Funktionen sind tolerant gegenueber kaputten gespeicherten Daten (unbekannter Modus, fehlende Tage,
|
||||
* unlesbare Fenster => zu), werfen aber bei einem ungueltigen Tag-Argument (Programmierfehler).
|
||||
*/
|
||||
|
||||
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const ZEIT_RE = /^\d{2}:\d{2}$/
|
||||
const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
|
||||
const STANDARD_LAND = 'RP'
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */
|
||||
function gueltigerTag(t) {
|
||||
return typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t
|
||||
}
|
||||
|
||||
function pruefeTag(t, name) {
|
||||
if (!gueltigerTag(t)) throw new RangeError(`${name}: ungueltiger Tag ${String(t)}`)
|
||||
}
|
||||
|
||||
/** 'HH:MM' (auch '24:00') -> Minuten seit 00:00, sonst null. */
|
||||
function minuten(hhmm) {
|
||||
if (typeof hhmm !== 'string' || !ZEIT_RE.test(hhmm)) return null
|
||||
const h = Number(hhmm.slice(0, 2))
|
||||
const m = Number(hhmm.slice(3, 5))
|
||||
if (m > 59 || h > 24 || (h === 24 && m !== 0)) return null
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
const textOderNull = (t) => (typeof t === 'string' && t.trim() !== '' ? t : null)
|
||||
|
||||
// ------------------------------------------------------------------ Plaene
|
||||
|
||||
/** Der Hauptplan (haupt: true) oder null. */
|
||||
export function hauptplan(daten) {
|
||||
const plaene = daten?.plaene
|
||||
if (!Array.isArray(plaene)) return null
|
||||
return plaene.find((p) => p && p.haupt === true) ?? null
|
||||
}
|
||||
|
||||
/** Der Plan mit diesem Kurznamen ([a-z0-9-], exakter Vergleich) oder null. */
|
||||
export function planNachKurz(daten, kurz) {
|
||||
const plaene = daten?.plaene
|
||||
if (!Array.isArray(plaene) || typeof kurz !== 'string' || kurz === '') return null
|
||||
return plaene.find((p) => p && p.kurz === kurz) ?? null
|
||||
}
|
||||
|
||||
/** Bundesland der Daten; fehlend oder unbekannt = Vorgabe RP (Spec 2.1). */
|
||||
export function landVon(daten) {
|
||||
return BUNDESLAENDER.includes(daten?.land) ? daten.land : STANDARD_LAND
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Wanduhr -> UTC
|
||||
|
||||
/**
|
||||
* Berliner Wanduhr -> UTC-Zeitpunkt wie zuUtc, aber ohne null: Faellt die Zeit in die Luecke der
|
||||
* Maerz-Umstellung (02:00-02:59), gilt die erste existierende Minute danach (03:00). Doppelte Zeiten
|
||||
* (Oktober) ergeben das erste Vorkommen, '24:00' den Folgetag 00:00. Ungueltige Eingabe wirft RangeError.
|
||||
*/
|
||||
export function zuUtcOderDanach(tag, hhmm) {
|
||||
const direkt = zuUtc(tag, hhmm)
|
||||
if (direkt) return direkt
|
||||
const min = minuten(hhmm)
|
||||
if (!gueltigerTag(tag) || min === null) throw new RangeError(`ungueltige Wanduhrzeit ${String(tag)} ${String(hhmm)}`)
|
||||
for (let m = min + 1; m <= 1440; m += 1) {
|
||||
const kandidat = zuUtc(tag, `${pad(Math.floor(m / 60))}:${pad(m % 60)}`)
|
||||
if (kandidat) return kandidat
|
||||
}
|
||||
throw new RangeError(`keine existierende Wanduhrzeit nach ${tag} ${hhmm}`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Tagesbild
|
||||
|
||||
/** Gespeicherte Fenster -> nur brauchbare, nach Beginn sortiert, als Kopie ('24:00' nur als Ende, von != bis). */
|
||||
function sauberFenster(roh) {
|
||||
if (!Array.isArray(roh)) return []
|
||||
const fenster = []
|
||||
for (const e of roh) {
|
||||
if (!Array.isArray(e) || e.length !== 2) continue
|
||||
const von = minuten(e[0])
|
||||
const bis = minuten(e[1])
|
||||
if (von === null || bis === null || von >= 1440 || von === bis) continue
|
||||
fenster.push([e[0], e[1]])
|
||||
}
|
||||
return fenster.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
}
|
||||
|
||||
/** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */
|
||||
export function modusUndFenster(eintrag) {
|
||||
const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu'
|
||||
if (modus !== 'zeiten') return { modus, fenster: [] }
|
||||
const fenster = sauberFenster(eintrag.fenster)
|
||||
return fenster.length ? { modus, fenster } : { modus: 'zu', fenster: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Die geltende Ausnahme dieser Art fuer Plan und Tag: planbezogene schlagen globale (plan null),
|
||||
* bei Gleichstand gewinnt die spaeter angelegte (hoeherer Index). Ausnahmen mit unlesbarem Zeitraum,
|
||||
* von > bis oder fuer einen anderen Plan zaehlen nicht; fehlendes 'bis' = ein Tag.
|
||||
*/
|
||||
function ausnahmeFuer(daten, planId, tag, art) {
|
||||
const liste = Array.isArray(daten?.ausnahmen) ? daten.ausnahmen : []
|
||||
let treffer = null
|
||||
let trefferRang = -1
|
||||
for (const a of liste) {
|
||||
if (!a || a.art !== art) continue
|
||||
const von = a.von
|
||||
const bis = a.bis ?? a.von
|
||||
if (typeof von !== 'string' || typeof bis !== 'string' || !TAG_RE.test(von) || !TAG_RE.test(bis)) continue
|
||||
if (tag < von || tag > bis) continue
|
||||
const global = a.plan === null || a.plan === undefined
|
||||
if (!global && a.plan !== planId) continue
|
||||
const rang = global ? 0 : 1
|
||||
// Liste in Anlegereihenfolge: bei gleichem Rang ersetzt der spaetere (>=)
|
||||
if (rang >= trefferRang) {
|
||||
treffer = a
|
||||
trefferRang = rang
|
||||
}
|
||||
}
|
||||
return treffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage je (Jahr, Land), einmal berechnet: Map tag -> { name, art: 'feiertag'|'pruefen' }.
|
||||
* Begrenzt, damit beliebige Jahre den Speicher nicht fuellen.
|
||||
*/
|
||||
const FEIERTAGE_CACHE = new Map()
|
||||
function feiertageDesJahres(jahr, land) {
|
||||
const schluessel = `${jahr}|${land}`
|
||||
let karte = FEIERTAGE_CACHE.get(schluessel)
|
||||
if (!karte) {
|
||||
karte = new Map()
|
||||
for (const e of feiertageImZeitraum(`${jahr}-01-01`, `${jahr}-12-31`, land)) {
|
||||
karte.set(e.tag, Object.freeze({ name: e.name, art: e.art }))
|
||||
}
|
||||
if (FEIERTAGE_CACHE.size >= 200) FEIERTAGE_CACHE.clear()
|
||||
FEIERTAGE_CACHE.set(schluessel, karte)
|
||||
}
|
||||
return karte
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Tagesbild eines Plans an einem Berliner Kalendertag (Spec 6, spezifisch vor allgemein):
|
||||
* Sondertag > Betriebsurlaub > Feiertag (Abweichung oder Vorgabe) > Wochenplan.
|
||||
*
|
||||
* Rueckgabe { tag, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name, modus, fenster }:
|
||||
* - name: Feiertagsname, bei Urlaub/Sondertag der Text der Ausnahme, sonst null.
|
||||
* - Urlaub ist immer zu. Gesetzlicher Feiertag ohne Abweichung ist zu; ein „bitte pruefen“-Tag
|
||||
* (Heiligabend, Silvester, ...) folgt dem Wochenplan (art 'regulaer'), ausser der Kunde hat unter
|
||||
* daten.feiertage[tag] eine Abweichung gespeichert (dann art 'feiertag' mit dem Namen des Tages).
|
||||
* - fenster ist immer ein neues Array (bei 'zu', 'rund', 'vereinbarung' leer); 'bis' kann '24:00' sein.
|
||||
*/
|
||||
export function tagesbild(daten, plan, tag) {
|
||||
pruefeTag(tag, 'tag')
|
||||
const planId = plan?.id
|
||||
|
||||
const sonder = ausnahmeFuer(daten, planId, tag, 'sondertag')
|
||||
if (sonder) return { tag, art: 'sondertag', name: textOderNull(sonder.text), ...modusUndFenster(sonder) }
|
||||
|
||||
const urlaub = ausnahmeFuer(daten, planId, tag, 'urlaub')
|
||||
if (urlaub) return { tag, art: 'urlaub', name: textOderNull(urlaub.text), modus: 'zu', fenster: [] }
|
||||
|
||||
const feiertag = feiertageDesJahres(tag.slice(0, 4), landVon(daten)).get(tag)
|
||||
if (feiertag) {
|
||||
const abweichung = daten?.feiertage?.[tag]
|
||||
if (abweichung && MODI.includes(abweichung.modus)) {
|
||||
return { tag, art: 'feiertag', name: feiertag.name, ...modusUndFenster(abweichung) }
|
||||
}
|
||||
if (feiertag.art === 'feiertag') return { tag, art: 'feiertag', name: feiertag.name, modus: 'zu', fenster: [] }
|
||||
}
|
||||
|
||||
return { tag, art: 'regulaer', name: null, ...modusUndFenster(plan?.tage?.[wochentag(tag)]) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Oeffnungsintervalle
|
||||
|
||||
/**
|
||||
* Zusammengefuegte Oeffnungsintervalle [{ vonMs, bisMs }] (UTC-Millisekunden, sortiert) fuer die
|
||||
* Berliner Kalendertage vonTag bis bisTag (inklusive).
|
||||
* - Fenster gehoeren zum Starttag; 'bis' vor 'von' laeuft bis zum Folgetag und folgt der Regel des Starttags
|
||||
* (auch wenn der Folgetag Feiertag, Urlaub oder zu ist). Ein am letzten Tag beginnendes Fenster wird
|
||||
* bis zu seinem Ende geliefert.
|
||||
* - Enthalten ist auch das vom Vortag in vonTag hineinlaufende Fenster, ungekuerzt (es beginnt vor vonTag 00:00).
|
||||
* - 'rund' = 00:00 bis Folgetag 00:00; 'zu' und 'vereinbarung' ergeben nichts.
|
||||
* - Ueberlappende und aneinander grenzende Intervalle (auch ueber Tage) sind zusammengefuegt.
|
||||
* - Fenster in der Maerz-Luecke beginnen/enden um 03:00, Fenster ohne Dauer fallen weg.
|
||||
* vonTag nach bisTag => []. Ungueltige Tage werfen RangeError.
|
||||
*/
|
||||
export function oeffnungsIntervalle(daten, plan, vonTag, bisTag) {
|
||||
pruefeTag(vonTag, 'vonTag')
|
||||
pruefeTag(bisTag, 'bisTag')
|
||||
if (vonTag > bisTag) return []
|
||||
|
||||
const bereichStart = zuUtcOderDanach(vonTag, '00:00').getTime()
|
||||
const roh = []
|
||||
const nimm = (vonMs, bisMs) => {
|
||||
if (bisMs > vonMs && bisMs > bereichStart) roh.push({ vonMs, bisMs })
|
||||
}
|
||||
|
||||
for (let tag = plusTage(vonTag, -1); tag <= bisTag; tag = plusTage(tag, 1)) {
|
||||
const bild = tagesbild(daten, plan, tag)
|
||||
if (bild.modus === 'rund') {
|
||||
nimm(zuUtcOderDanach(tag, '00:00').getTime(), zuUtcOderDanach(tag, '24:00').getTime())
|
||||
} else if (bild.modus === 'zeiten') {
|
||||
for (const [von, bis] of bild.fenster) {
|
||||
const ueberMitternacht = bis <= von
|
||||
nimm(
|
||||
zuUtcOderDanach(tag, von).getTime(),
|
||||
zuUtcOderDanach(ueberMitternacht ? plusTage(tag, 1) : tag, bis).getTime(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roh.sort((a, b) => a.vonMs - b.vonMs || a.bisMs - b.bisMs)
|
||||
const zusammen = []
|
||||
for (const i of roh) {
|
||||
const letztes = zusammen[zusammen.length - 1]
|
||||
if (letztes && i.vonMs <= letztes.bisMs) letztes.bisMs = Math.max(letztes.bisMs, i.bisMs)
|
||||
else zusammen.push({ vonMs: i.vonMs, bisMs: i.bisMs })
|
||||
}
|
||||
return zusammen
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Texte, Status und Wechsel
|
||||
|
||||
const WOCHENTAGE = Object.freeze(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const STUNDE_MS = 60 * MINUTE_MS
|
||||
const SUCH_TAGE = 60 // Spec 6: keine Oeffnung in 60 Tagen => „Derzeit geschlossen“
|
||||
const RUND_TAGE = 7 // durchgehend mindestens 7 Tage => „Rund um die Uhr geoeffnet“
|
||||
const WECHSEL_TAGE = 8 // Spec 4.1: wechsel deckt 8 Tage ab
|
||||
const WOCHEN_TAGE = 6 // bis hierhin nennt der Text den Wochentag, danach das Datum
|
||||
const MAX_TAGE_LISTE = 366
|
||||
|
||||
const KURZ_OFFEN = 'Geöffnet'
|
||||
const KURZ_ZU = 'Geschlossen'
|
||||
const KURZ_VEREINBARUNG = 'Nach Vereinbarung'
|
||||
|
||||
function pruefeJetzt(now) {
|
||||
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw new RangeError('now: ungueltiger Zeitpunkt')
|
||||
return now.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uhrzeit im Fliesstext: '09:00' => '9 Uhr', '18:30' => '18:30 Uhr', '09:05' => '9:05 Uhr';
|
||||
* '00:00' und '24:00' => 'Mitternacht' (nie „24 Uhr“). Ungueltige Eingabe wirft RangeError.
|
||||
*/
|
||||
export function uhrText(hhmm) {
|
||||
const min = minuten(hhmm)
|
||||
if (min === null) throw new RangeError(`uhrText: ungueltige Uhrzeit ${String(hhmm)}`)
|
||||
if (min === 0 || min === 1440) return 'Mitternacht'
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m === 0 ? `${h} Uhr` : `${h}:${pad(m)} Uhr`
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' -> 'TT.MM.' */
|
||||
const datumText = (tag) => `${tag.slice(8, 10)}.${tag.slice(5, 7)}.`
|
||||
|
||||
const wochentagsName = (tag) => WOCHENTAGE[wochentag(tag) - 1]
|
||||
|
||||
/** Kalendertage von bis nach (0..max) oder null, wenn weiter weg (nur ueber plusTage gezaehlt). */
|
||||
function tagAbstand(von, bis, max) {
|
||||
let tag = von
|
||||
for (let n = 0; n <= max; n += 1) {
|
||||
if (tag === bis) return n
|
||||
tag = plusTage(tag, 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Letzter Tag der zusammenhaengenden Urlaubstage ab tag (tag selbst ist Urlaub). */
|
||||
function urlaubBis(daten, plan, tag) {
|
||||
let letzter = tag
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
const naechster = plusTage(letzter, 1)
|
||||
if (tagesbild(daten, plan, naechster).art !== 'urlaub') break
|
||||
letzter = naechster
|
||||
}
|
||||
return letzter
|
||||
}
|
||||
|
||||
/** Text fuer ein laufendes Intervall, das um bisMs endet (jetzt = Berliner Wanduhr von nowMs). */
|
||||
function offenText(bisMs, nowMs, jetzt) {
|
||||
const siebenTage = zuUtcOderDanach(plusTage(jetzt.tag, RUND_TAGE), jetzt.uhrzeit).getTime()
|
||||
if (bisMs >= siebenTage) return 'Rund um die Uhr geöffnet'
|
||||
const ende = ausUtc(new Date(bisMs))
|
||||
if (bisMs - nowMs <= 24 * STUNDE_MS) return `Jetzt geöffnet · schließt um ${uhrText(ende.uhrzeit)}`
|
||||
// Schluss genau um Mitternacht: der Tag, der endet („bis Dienstag Mitternacht“), nicht der Folgetag
|
||||
const tag = ende.uhrzeit === '00:00' ? plusTage(ende.tag, -1) : ende.tag
|
||||
return tagAbstand(jetzt.tag, tag, WOCHEN_TAGE) === null
|
||||
? `Jetzt geöffnet · bis ${datumText(tag)} um ${uhrText(ende.uhrzeit)}`
|
||||
: `Jetzt geöffnet · bis ${wochentagsName(tag)} ${uhrText(ende.uhrzeit)}`
|
||||
}
|
||||
|
||||
/** Text fuer die naechste Oeffnung (vonMs) von heute (Berliner Kalendertag) aus gesehen. */
|
||||
function oeffnetText(vonMs, heute) {
|
||||
const start = ausUtc(new Date(vonMs))
|
||||
// Beginn um 00:00 heisst „0 Uhr“: „um Mitternacht“ wuerde als Tagesende gelesen
|
||||
const zeit = start.uhrzeit === '00:00' ? '0 Uhr' : uhrText(start.uhrzeit)
|
||||
const abstand = tagAbstand(heute, start.tag, WOCHEN_TAGE)
|
||||
if (abstand === 0) return `Geschlossen · öffnet heute um ${zeit}`
|
||||
if (abstand === 1) return `Geschlossen · öffnet morgen um ${zeit}`
|
||||
if (abstand === null) return `Geschlossen · öffnet am ${datumText(start.tag)} um ${zeit}`
|
||||
return `Geschlossen · öffnet ${wochentagsName(start.tag)} um ${zeit}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Status zum Zeitpunkt nowMs auf Basis fertiger Intervalle (muessen den Tag des Zeitpunkts minus 1 bis
|
||||
* mindestens 60 Tage danach abdecken, siehe oeffnungsIntervalle).
|
||||
*/
|
||||
function statusAus(daten, plan, nowMs, intervalle) {
|
||||
const jetzt = ausUtc(new Date(nowMs))
|
||||
const heute = jetzt.tag
|
||||
|
||||
const laufend = intervalle.find((i) => i.vonMs <= nowMs && nowMs < i.bisMs)
|
||||
if (laufend) return { offen: true, text: offenText(laufend.bisMs, nowMs, jetzt), kurz: KURZ_OFFEN }
|
||||
|
||||
const bild = tagesbild(daten, plan, heute)
|
||||
if (bild.modus === 'vereinbarung') return { offen: null, text: 'Heute nach Vereinbarung', kurz: KURZ_VEREINBARUNG }
|
||||
if (bild.modus === 'zu' && bild.art === 'urlaub') {
|
||||
const bis = datumText(urlaubBis(daten, plan, heute))
|
||||
return { offen: false, text: `Heute geschlossen (Betriebsurlaub bis ${bis})`, kurz: KURZ_ZU }
|
||||
}
|
||||
if (bild.modus === 'zu' && bild.art === 'feiertag') {
|
||||
const grund = bild.name ? `Feiertag: ${bild.name}` : 'Feiertag'
|
||||
return { offen: false, text: `Heute geschlossen (${grund})`, kurz: KURZ_ZU }
|
||||
}
|
||||
|
||||
const grenze = plusTage(heute, SUCH_TAGE)
|
||||
const naechste = intervalle.find((i) => i.vonMs > nowMs)
|
||||
if (naechste && ausUtc(new Date(naechste.vonMs)).tag <= grenze) {
|
||||
return { offen: false, text: oeffnetText(naechste.vonMs, heute), kurz: KURZ_ZU }
|
||||
}
|
||||
return { offen: false, text: 'Derzeit geschlossen', kurz: KURZ_ZU }
|
||||
}
|
||||
|
||||
/**
|
||||
* Status eines Plans zum Zeitpunkt now (Date, immer Parameter): { offen: true|false|null, text, kurz }.
|
||||
* Texte nach Spec 6 (ohne Anrede, nie „24 Uhr“); offen null = nach Vereinbarung. Wirft RangeError bei
|
||||
* ungueltigem now.
|
||||
*/
|
||||
export function statusZu(daten, plan, now) {
|
||||
const nowMs = pruefeJetzt(now)
|
||||
const heute = ausUtc(now).tag
|
||||
// 60 Tage Vorschau fuer die naechste Oeffnung; ein laenger laufendes Intervall ist ohnehin >= 7 Tage
|
||||
return statusAus(daten, plan, nowMs, oeffnungsIntervalle(daten, plan, heute, plusTage(heute, SUCH_TAGE)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Statuswechsel der naechsten 8 Tage: [{ ab (UTC-ms), tag (Berliner Datum bei ab), offen, text, kurz }].
|
||||
* Der erste Eintrag gilt ab Beginn der aktuellen Minute; danach ein Eintrag je Aenderung von offen/text/kurz
|
||||
* an einer Intervallgrenze (Oeffnen/Schliessen) und an jeder Berliner Mitternacht (z. B. „morgen“ -> „heute“).
|
||||
* Nicht enthalten sind Textwechsel mitten am Tag, die nur die Restdauer betreffen (bis Dienstag -> schliesst um).
|
||||
*/
|
||||
export function wechselListe(daten, plan, now) {
|
||||
const startMs = Math.floor(pruefeJetzt(now) / MINUTE_MS) * MINUTE_MS
|
||||
const start = ausUtc(new Date(startMs))
|
||||
const endeMs = zuUtcOderDanach(plusTage(start.tag, WECHSEL_TAGE), start.uhrzeit).getTime()
|
||||
// 8 Tage Wechsel + 60 Tage Vorschau fuer den letzten Eintrag
|
||||
const intervalle = oeffnungsIntervalle(daten, plan, start.tag, plusTage(start.tag, WECHSEL_TAGE + SUCH_TAGE))
|
||||
|
||||
const kandidaten = new Set([startMs])
|
||||
const vormerken = (ms) => {
|
||||
if (ms > startMs && ms < endeMs) kandidaten.add(ms)
|
||||
}
|
||||
for (const i of intervalle) {
|
||||
vormerken(i.vonMs)
|
||||
vormerken(i.bisMs)
|
||||
}
|
||||
for (let n = 1; n <= WECHSEL_TAGE; n += 1) vormerken(zuUtcOderDanach(plusTage(start.tag, n), '00:00').getTime())
|
||||
|
||||
const liste = []
|
||||
let vorher = null
|
||||
for (const ms of [...kandidaten].sort((a, b) => a - b)) {
|
||||
const s = statusAus(daten, plan, ms, intervalle)
|
||||
if (vorher && vorher.offen === s.offen && vorher.text === s.text && vorher.kurz === s.kurz) continue
|
||||
vorher = s
|
||||
liste.push({ ab: ms, tag: ausUtc(new Date(ms)).tag, offen: s.offen, text: s.text, kurz: s.kurz })
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagesbilder fuer die API: anzahl aufeinanderfolgende Tage ab vonTag als
|
||||
* [{ datum, art, name, modus, fenster }] (wie tagesbild, nur heisst der Tag „datum“).
|
||||
* Ungueltiger Tag oder anzahl ausserhalb 0..366 wirft RangeError.
|
||||
*/
|
||||
export function tageListe(daten, plan, vonTag, anzahl) {
|
||||
pruefeTag(vonTag, 'vonTag')
|
||||
if (!Number.isInteger(anzahl) || anzahl < 0 || anzahl > MAX_TAGE_LISTE) {
|
||||
throw new RangeError(`anzahl: ungueltig ${String(anzahl)}`)
|
||||
}
|
||||
const liste = []
|
||||
let tag = vonTag
|
||||
for (let i = 0; i < anzahl; i += 1) {
|
||||
const b = tagesbild(daten, plan, tag)
|
||||
liste.push({ datum: b.tag, art: b.art, name: b.name, modus: b.modus, fenster: b.fenster })
|
||||
tag = plusTage(tag, 1)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Hinweise
|
||||
|
||||
/** Rang der Hinweisarten (Spec 4.1): der Browser zeigt den gueltigen Hinweis mit dem hoechsten Rang. */
|
||||
export const HINWEIS_RANG = Object.freeze({ wichtig: 4, urlaub: 3, aktion: 2, info: 1 })
|
||||
|
||||
const HINWEIS_VORLAUF_TAGE = 8 // Spec 4.1: aktuell gueltige Hinweise + Beginn in 8 Tagen
|
||||
const ENDLOS_MS = 8.64e15 // letzter darstellbarer Date-Wert: „ohne Ende“ bleibt eine vergleichbare Zahl (JSON-tauglich)
|
||||
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
|
||||
|
||||
const fehlt = (v) => v === undefined || v === null || v === ''
|
||||
|
||||
/**
|
||||
* Zeitraum eines Hinweises als UTC-Millisekunden { ab, bis }: von = Beginn 00:00, bis = Ende 24:00 (also der
|
||||
* Beginn des Folgetags), beides in Berliner Zeit. Fehlt von, beginnt der Hinweis „schon immer“ (ab 0), fehlt bis,
|
||||
* endet er nie (bis 8.64e15, der letzte darstellbare Date-Wert). Ein vorhandener, aber unlesbarer Tag
|
||||
* (oder ein Hinweis, der kein Objekt ist) ergibt null.
|
||||
*/
|
||||
export function hinweisZeitraum(h) {
|
||||
if (!h || typeof h !== 'object') return null
|
||||
const { von, bis } = h
|
||||
if ((!fehlt(von) && !gueltigerTag(von)) || (!fehlt(bis) && !gueltigerTag(bis))) return null
|
||||
return {
|
||||
ab: fehlt(von) ? 0 : zuUtcOderDanach(von, '00:00').getTime(),
|
||||
bis: fehlt(bis) ? ENDLOS_MS : zuUtcOderDanach(bis, '24:00').getTime(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Knopf eines Hinweises als Kopie { art, ziel, text } oder null, wenn er unbrauchbar ist (Task 8 prueft beim Speichern). */
|
||||
function knopfKopie(k) {
|
||||
if (!k || typeof k !== 'object' || !KNOPF_ARTEN.includes(k.art) || typeof k.ziel !== 'string' || k.ziel === '') return null
|
||||
return { art: k.art, ziel: k.ziel, text: typeof k.text === 'string' ? k.text : '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Die zum Zeitpunkt now gueltigen Hinweise sowie die, die in hoechstens 8 Tagen beginnen:
|
||||
* [{ art, text, knopf, ab, bis, rang }] in gespeicherter Reihenfolge, ohne interne IDs. Die Auswahl des
|
||||
* anzuzeigenden Hinweises trifft der Browser (hoechster Rang, bei Gleichstand spaeterer Beginn).
|
||||
* Uebersprungen werden Hinweise ohne Text, mit unbekannter Art, unlesbaren Tagen oder bis <= von.
|
||||
* „8 Tage“ = dieselbe Wanduhrzeit 8 Kalendertage spaeter (minutengenau, wie wechselListe).
|
||||
*/
|
||||
export function gueltigeHinweise(daten, now) {
|
||||
const nowMs = pruefeJetzt(now)
|
||||
const jetzt = ausUtc(now)
|
||||
const grenzeMs = zuUtcOderDanach(plusTage(jetzt.tag, HINWEIS_VORLAUF_TAGE), jetzt.uhrzeit).getTime()
|
||||
const liste = Array.isArray(daten?.hinweise) ? daten.hinweise : []
|
||||
|
||||
const ergebnis = []
|
||||
for (const h of liste) {
|
||||
if (!h || typeof h.text !== 'string' || h.text.trim() === '') continue
|
||||
if (typeof h.art !== 'string' || !Object.hasOwn(HINWEIS_RANG, h.art)) continue
|
||||
const z = hinweisZeitraum(h)
|
||||
if (!z || z.bis <= z.ab || z.bis <= nowMs || z.ab > grenzeMs) continue
|
||||
ergebnis.push({ art: h.art, text: h.text, knopf: knopfKopie(h.knopf), ab: z.ab, bis: z.bis, rang: HINWEIS_RANG[h.art] })
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ JSON-LD und Google-Text
|
||||
|
||||
const WOCHENTAGE_EN = Object.freeze(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'])
|
||||
const SONDER_TAGE = 60 // Spec 4.3/3.4: Sonderzeiten der naechsten 60 Tage (heute inklusive)
|
||||
|
||||
/** Ist das einzige/gemeinte Fenster 00:00–24:00 (= rund um die Uhr)? */
|
||||
const ganztags = (f) => f[0] === '00:00' && f[1] === '24:00'
|
||||
|
||||
/** Ende fuer Anzeige und JSON-LD: nie '24:00', sondern '00:00' (closes < opens = Folgetag). */
|
||||
const endeAnzeige = (bis) => (bis === '24:00' ? '00:00' : bis)
|
||||
|
||||
/**
|
||||
* Oeffnungspaare [opens, closes] eines Tagesbildes fuer JSON-LD: 'rund' und ein Fenster 00:00–24:00 =
|
||||
* 00:00–23:59, Fenster ueber Mitternacht mit closes < opens, Ende 24:00 als 00:00; 'zu' und 'vereinbarung' = [].
|
||||
*/
|
||||
function ldPaare(modus, fenster) {
|
||||
if (modus === 'rund') return [['00:00', '23:59']]
|
||||
if (modus !== 'zeiten') return []
|
||||
return fenster.map((f) => (ganztags(f) ? ['00:00', '23:59'] : [f[0], endeAnzeige(f[1])]))
|
||||
}
|
||||
|
||||
const ldSpezifikation = (extra) => ({ '@type': 'OpeningHoursSpecification', ...extra })
|
||||
|
||||
/**
|
||||
* Sonderzeiten der naechsten 60 Tage (heute bis heute + 59) als zusammenhaengende Laeufe gleicher Zeiten:
|
||||
* [{ von, bis, text, name, zeiten }] mit zeiten = JSON-String der Tagesfenster; gleiche aufeinanderfolgende
|
||||
* Sondertage (Art 'feiertag'/'urlaub'/'sondertag', nicht 'regulaer') sind ein Lauf. Mit gruppeNachGrund
|
||||
* trennen auch unterschiedliche Namen/Arten die Laeufe (Google-Text), sonst nicht (JSON-LD).
|
||||
*/
|
||||
function sonderLaeufe(daten, plan, heute, gruppeNachGrund, zeitenVon) {
|
||||
const laeufe = []
|
||||
let lauf = null
|
||||
for (const t of tageListe(daten, plan, heute, SONDER_TAGE)) {
|
||||
if (t.art === 'regulaer') {
|
||||
lauf = null
|
||||
continue
|
||||
}
|
||||
const zeiten = zeitenVon(t)
|
||||
const grund = gruppeNachGrund ? `${t.art}|${t.name ?? ''}` : ''
|
||||
if (lauf && lauf.zeiten === zeiten && lauf.grund === grund && plusTage(lauf.bis, 1) === t.datum) {
|
||||
lauf.bis = t.datum
|
||||
} else {
|
||||
lauf = { von: t.datum, bis: t.datum, zeiten, grund, tag: t }
|
||||
laeufe.push(lauf)
|
||||
}
|
||||
}
|
||||
return laeufe
|
||||
}
|
||||
|
||||
/**
|
||||
* Daten fuer schema.org: { openingHoursSpecification, specialOpeningHoursSpecification, neuerBlock }.
|
||||
* - openingHoursSpecification: je Wochentag und Fenster ein Eintrag { '@type', dayOfWeek, opens, closes };
|
||||
* Fenster ueber Mitternacht mit closes < opens, Ende 24:00 als '00:00', „rund“ 00:00–23:59,
|
||||
* „nach Vereinbarung“ und „zu“ ausgelassen.
|
||||
* - specialOpeningHoursSpecification: Feiertage mit Abweichung oder Vorgabe, Urlaub und Sondertage der naechsten
|
||||
* 60 Tage (heute bis heute + 59, ein laufender Zeitraum beginnt heute) als { '@type', validFrom, validThrough,
|
||||
* opens, closes }; aufeinanderfolgende Tage mit gleichen Zeiten sind ein Zeitraum, geschlossen = 00:00–00:00.
|
||||
* „Nach Vereinbarung“ kennt JSON-LD nicht: an Sondertagen zaehlt es als geschlossen (sonst blieben die
|
||||
* Wochenzeiten des Tages stehen).
|
||||
* - neuerBlock: vollstaendiger Block { '@context': 'https://schema.org', '@type': 'LocalBusiness', ... } ohne
|
||||
* name/address/url (die setzt das Website-Skript; das https-Literal liefert deshalb der Server).
|
||||
* Ohne Plan (null) sind beide Listen leer.
|
||||
*/
|
||||
export function jsonLdFuer(daten, plan, now) {
|
||||
pruefeJetzt(now)
|
||||
const heute = ausUtc(now).tag
|
||||
|
||||
const woche = []
|
||||
const sonder = []
|
||||
if (plan && typeof plan === 'object') {
|
||||
for (let tag = 1; tag <= 7; tag += 1) {
|
||||
const { modus, fenster } = modusUndFenster(plan.tage?.[tag])
|
||||
for (const [opens, closes] of ldPaare(modus, fenster)) {
|
||||
woche.push(ldSpezifikation({ dayOfWeek: WOCHENTAGE_EN[tag - 1], opens, closes }))
|
||||
}
|
||||
}
|
||||
const geschlossen = [['00:00', '00:00']]
|
||||
const zeitenVon = (t) => JSON.stringify(ldPaare(t.modus, t.fenster))
|
||||
for (const lauf of sonderLaeufe(daten, plan, heute, false, zeitenVon)) {
|
||||
const paare = ldPaare(lauf.tag.modus, lauf.tag.fenster)
|
||||
for (const [opens, closes] of paare.length ? paare : geschlossen) {
|
||||
sonder.push(ldSpezifikation({ validFrom: lauf.von, validThrough: lauf.bis, opens, closes }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openingHoursSpecification: woche,
|
||||
specialOpeningHoursSpecification: sonder,
|
||||
neuerBlock: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
openingHoursSpecification: woche.map((e) => ({ ...e })),
|
||||
specialOpeningHoursSpecification: sonder.map((e) => ({ ...e })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Tageszeiten als Text: „09:00–12:30, 13:30–18:00“, „geschlossen“, „rund um die Uhr“, „nach Vereinbarung“ (nie '24:00'). */
|
||||
function tagText(modus, fenster) {
|
||||
if (modus === 'rund' || (modus === 'zeiten' && fenster.length === 1 && ganztags(fenster[0]))) return 'rund um die Uhr'
|
||||
if (modus === 'vereinbarung') return 'nach Vereinbarung'
|
||||
if (modus !== 'zeiten') return 'geschlossen'
|
||||
return fenster.map((f) => `${f[0]}–${endeAnzeige(f[1])}`).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Text zum Kopieren in das Google-Profil (Spec 3.4 Bereich 6): sieben Zeilen Montag bis Sonntag
|
||||
* („Montag: 09:00–12:30, 13:30–18:00“, „Sonntag: geschlossen“), danach, durch eine Leerzeile getrennt, die
|
||||
* Sonderzeiten der naechsten 60 Tage („12.10.–16.10.: geschlossen (Betriebsurlaub)“, „03.10.: geschlossen
|
||||
* (Tag der Deutschen Einheit)“; aufeinanderfolgende Tage mit gleichen Zeiten und gleichem Grund in einer Zeile).
|
||||
* Zeilen mit \n getrennt, kein abschliessender Zeilenumbruch. Ohne Plan ''.
|
||||
*/
|
||||
export function googleText(daten, plan, now) {
|
||||
pruefeJetzt(now)
|
||||
const heute = ausUtc(now).tag
|
||||
if (!plan || typeof plan !== 'object') return ''
|
||||
|
||||
const zeilen = []
|
||||
for (let tag = 1; tag <= 7; tag += 1) {
|
||||
const { modus, fenster } = modusUndFenster(plan.tage?.[tag])
|
||||
zeilen.push(`${WOCHENTAGE[tag - 1]}: ${tagText(modus, fenster)}`)
|
||||
}
|
||||
|
||||
const laeufe = sonderLaeufe(daten, plan, heute, true, (t) => tagText(t.modus, t.fenster))
|
||||
if (laeufe.length) {
|
||||
zeilen.push('', `Sonderzeiten (nächste ${SONDER_TAGE} Tage):`)
|
||||
for (const lauf of laeufe) {
|
||||
const datum = lauf.von === lauf.bis ? datumText(lauf.von) : `${datumText(lauf.von)}–${datumText(lauf.bis)}`
|
||||
const grund = lauf.tag.name ?? (lauf.tag.art === 'urlaub' ? 'Betriebsurlaub' : null)
|
||||
zeilen.push(`${datum}: ${lauf.zeiten}${grund ? ` (${grund})` : ''}`)
|
||||
}
|
||||
}
|
||||
return zeilen.join('\n')
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Erinnerung
|
||||
|
||||
const ERINNERUNG_TAGE = 7 // Spec 7: Tage in 1–7 Tagen
|
||||
const ERINNERUNG_VON = '08:00' // Berliner Zeit, einschliesslich
|
||||
const ERINNERUNG_BIS = '20:00' // Berliner Zeit, ausschliesslich
|
||||
|
||||
/** Hat der Kunde fuer diesen Tag unter daten.feiertage schon eine Abweichung gespeichert? */
|
||||
function abweichungGespeichert(daten, tag) {
|
||||
const f = daten?.feiertage
|
||||
return !!f && typeof f === 'object' && Object.hasOwn(f, tag) && !!f[tag] && typeof f[tag] === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage und „bitte pruefen“-Tage des Landes in 1–7 Berliner Tagen ab heute, zu denen der Kunde erinnert werden
|
||||
* soll: [{ tag, name, art: 'feiertag'|'pruefen' }]. Nicht dabei sind Tage, an denen der Hauptplan an diesem
|
||||
* Wochentag ohnehin zu ist, eine Ausnahme (Urlaub/Sondertag, global oder fuer den Hauptplan) den Tag abdeckt oder
|
||||
* der Kunde unter daten.feiertage[tag] schon eine Abweichung gespeichert hat. Ohne Hauptplan [].
|
||||
*/
|
||||
export function erinnerungsTage(daten, now) {
|
||||
pruefeJetzt(now)
|
||||
const haupt = hauptplan(daten)
|
||||
if (!haupt) return []
|
||||
const heute = ausUtc(now).tag
|
||||
|
||||
const ergebnis = []
|
||||
for (const e of feiertageImZeitraum(plusTage(heute, 1), plusTage(heute, ERINNERUNG_TAGE), landVon(daten))) {
|
||||
if (modusUndFenster(haupt.tage?.[wochentag(e.tag)]).modus === 'zu') continue
|
||||
if (ausnahmeFuer(daten, haupt.id, e.tag, 'urlaub') || ausnahmeFuer(daten, haupt.id, e.tag, 'sondertag')) continue
|
||||
if (abweichungGespeichert(daten, e.tag)) continue
|
||||
ergebnis.push({ tag: e.tag, name: e.name, art: e.art })
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Liegt now zwischen 08:00 (einschliesslich) und 20:00 (ausschliesslich) Berliner Zeit? */
|
||||
export function imErinnerungsfenster(now) {
|
||||
pruefeJetzt(now)
|
||||
const { uhrzeit } = ausUtc(now)
|
||||
return uhrzeit >= ERINNERUNG_VON && uhrzeit < ERINNERUNG_BIS
|
||||
}
|
||||
@@ -17,10 +17,10 @@ const entries = () => [...aw.docs('customerViewLog').values()]
|
||||
const seedLog = (id, customerId, createdAt, action = 'geoeffnet') =>
|
||||
aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt })
|
||||
|
||||
test('VIEW_ACTIONS: genau die elf Aktionen', () => {
|
||||
test('VIEW_ACTIONS: genau die zwölf Aktionen', () => {
|
||||
assert.deepEqual(VIEW_ACTIONS, [
|
||||
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung',
|
||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'shop_aenderung', 'zeiten_gespeichert',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -85,10 +85,21 @@ test('speichert den Zeitpunkt aus einem Date-Objekt als ISO-Text', async () => {
|
||||
assert.equal(doc.acceptedAt, '2026-10-03T12:34:56.789Z')
|
||||
})
|
||||
|
||||
test('append-only: das Modul exportiert genau die sieben Lese- und Anfügefunktionen', () => {
|
||||
// Dazu seit Aufgabe 16 der Nachweis aus einer Anfrage (speichereNachweisAusAnfrage, Checkout und Gratisweg) und die handelnde Person (actingPerson)
|
||||
test('append-only: das Modul exportiert genau die Lese- und Anfügefunktionen', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(consents).sort(),
|
||||
['leseZustimmung', 'letzteZustimmung', 'nachweisTexte', 'speichereZustimmung', 'texteHash', 'zustimmungFuerSitzung', 'zustimmungsfassungen']
|
||||
[
|
||||
'actingPerson',
|
||||
'leseZustimmung',
|
||||
'letzteZustimmung',
|
||||
'nachweisTexte',
|
||||
'speichereNachweisAusAnfrage',
|
||||
'speichereZustimmung',
|
||||
'texteHash',
|
||||
'zustimmungFuerSitzung',
|
||||
'zustimmungsfassungen',
|
||||
]
|
||||
)
|
||||
assert.equal(Object.keys(consents).filter((name) => /update|delete|remove|aender|loesch/i.test(name)).length, 0)
|
||||
})
|
||||
|
||||
@@ -103,15 +103,15 @@ afterEach(() => {
|
||||
|
||||
// ---------------------------------------------------------------------------------------- Register ---
|
||||
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
|
||||
NEWS.ticketFreischaltung = false
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop'])
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'])
|
||||
NEWS.ticketFreischaltung = 'ja'
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop'], 'nur true zaehlt')
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'], 'nur true zaehlt')
|
||||
NEWS.ticketFreischaltung = true
|
||||
TERMINE.ticketFreischaltung = false
|
||||
try {
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop'])
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'zeiten'])
|
||||
} finally {
|
||||
TERMINE.ticketFreischaltung = true
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
|
||||
'widerruf_url',
|
||||
'zustimmungen_text',
|
||||
])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
|
||||
const k16 = loadTemplate('K16')
|
||||
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
|
||||
@@ -629,7 +629,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
|
||||
const t16 = loadTemplate('T16')
|
||||
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
|
||||
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
|
||||
const t19 = loadTemplate('T19')
|
||||
assert.equal(t19.file, 'T19 Erstattung faellig.html')
|
||||
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
|
||||
@@ -649,8 +649,8 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
|
||||
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
|
||||
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
|
||||
// ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus
|
||||
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false })
|
||||
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false })
|
||||
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
|
||||
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
|
||||
for (const nachricht of [k15, k16]) {
|
||||
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
|
||||
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
|
||||
|
||||
@@ -122,10 +122,11 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet'
|
||||
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
||||
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
|
||||
shop: { name: 'Webshop', keine: 'keinen Webshop' },
|
||||
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
|
||||
})
|
||||
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
|
||||
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
|
||||
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
|
||||
@@ -156,8 +157,8 @@ test('Haken überschreibbar: config.features.<key>.<haken> gewinnt gegen den Sta
|
||||
}
|
||||
assert.deepEqual(reg.featureHaken('termine'), vorherTermine)
|
||||
|
||||
// jeder der fünf Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht
|
||||
const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
|
||||
// jeder der sechs Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht
|
||||
const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar', 'nachFreischaltung']
|
||||
const news = config.features.news
|
||||
try {
|
||||
for (const name of namen) news[name] = () => name
|
||||
|
||||
@@ -69,9 +69,13 @@ test('liste_zustaende', async () => {
|
||||
assert.deepEqual(body.termine, { verfuegbar: false })
|
||||
// Webshop: Schalter SHOP_ENABLED (Vorgabe off) ⇒ nur das Flag, keine weiteren Felder
|
||||
assert.deepEqual(body.shop, { verfuegbar: false })
|
||||
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (Vorgabe off): genau { verfuegbar: false } und je Projekt ohne Status, leer
|
||||
// (Angebot, Ansicht und Status: test/zeitenEinschalten.test.js)
|
||||
assert.deepEqual(body.zeiten, { verfuegbar: false })
|
||||
for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true])
|
||||
|
||||
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
|
||||
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine'])
|
||||
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'zeiten'])
|
||||
assert.equal(body.features.shop.priceLabel, '299 €')
|
||||
assert.equal(body.features.news.priceLabel, '30 €')
|
||||
assert.equal(body.features.termine.priceLabel, '149 €')
|
||||
@@ -114,8 +118,10 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be
|
||||
const { legalPaths } = await import('../server/services/legalDocs.js')
|
||||
const V = config.termine.avvVersion
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine'])
|
||||
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'zeiten'])
|
||||
assert.equal(body.legal.news, null)
|
||||
// die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen (avv und widerruf null): kein Eintrag wie beim Terminplaner
|
||||
assert.equal(body.legal.zeiten, null)
|
||||
const termine = body.legal.termine
|
||||
assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version'])
|
||||
assert.equal(termine.version, V)
|
||||
|
||||
@@ -295,7 +295,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher'
|
||||
assert.equal(pt1.features.news.state, 'disabled')
|
||||
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
|
||||
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
|
||||
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'zeiten'])
|
||||
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
|
||||
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
|
||||
const aus = pt1.features.news
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Kachel „Öffnungszeiten live“ in public/funktionen.js (Spec 3.1, Ruling R20). Die Kachel steht inline in funktionen.js (keine neuen Importe,
|
||||
// test/termineView.test.js und test/termineFragebogenView.test.js zählen sie) und macht keine Abfrage über /api/funktionen hinaus.
|
||||
// Eigener Loader: ALLE Importe von funktionen.js (absolut /x.js oder relativ ./x.js) zeigen auf die Dateien in public/, damit ein künftiger
|
||||
// Import die Tests nicht stillschweigend bricht.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-zeiten-tile-'))
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const quelle = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${fileUrl(name)}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen } = await import(pathToFileURL(file).href)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Hilfen
|
||||
const zeitenFeature = (state, extra = {}) => ({
|
||||
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
|
||||
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
|
||||
})
|
||||
const projekt = (id, state = 'off', feature = {}, extra = {}) => ({
|
||||
projectId: id,
|
||||
projectName: `Website ${id}`,
|
||||
liveUrl: '',
|
||||
features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) },
|
||||
analyse: { comingSoon: true },
|
||||
zeitenStatus: null,
|
||||
zeitenLeer: true,
|
||||
...extra,
|
||||
})
|
||||
const ANGEBOT = (inklusive = true, grund = null) => ({
|
||||
verfuegbar: true,
|
||||
inklusive,
|
||||
grund,
|
||||
preis: '30 €',
|
||||
einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Haken', haken: true }] }, leistungsbeschreibung: { version: 'v', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
|
||||
})
|
||||
|
||||
async function render(projects, extra = {}) {
|
||||
const aufrufe = []
|
||||
globalThis.fetch = async (url) => {
|
||||
aufrufe.push(String(url))
|
||||
return { ok: true, status: 200, json: async () => ({ projects, ...extra }) }
|
||||
}
|
||||
const root = { innerHTML: '', querySelector: () => null }
|
||||
await initFunktionen(root)
|
||||
return { html: root.innerHTML, aufrufe }
|
||||
}
|
||||
const kachel = (html) => {
|
||||
const von = html.indexOf('data-fn-tile="zeiten"')
|
||||
assert.ok(von >= 0, 'die Kachel fehlt')
|
||||
return html.slice(von, html.indexOf('</article>', von))
|
||||
}
|
||||
const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const knoepfe = (html) => [...html.matchAll(/<button\b[^>]*>([^<]*)<\/button>/g)].map((m) => m[1].trim())
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Tests
|
||||
test('ohne verfuegbar keine Kachel', async () => {
|
||||
const P = [projekt('a')]
|
||||
const ohne = (await render(P)).html
|
||||
assert.doesNotMatch(ohne, /data-fn-tile="zeiten"/)
|
||||
assert.doesNotMatch(ohne, /Öffnungszeiten/)
|
||||
const aus = (await render(P, { zeiten: { verfuegbar: false } })).html
|
||||
assert.equal(aus, ohne, 'verfuegbar: false sieht aus wie ein Server ohne Zeiten')
|
||||
assert.equal(ohne.match(/Kommt bald/g).length, 2, 'Terminplaner und Analyse, wie bisher')
|
||||
// auch ein beschädigtes Angebot macht keine Kachel
|
||||
for (const kaputt of [null, 'ja', { verfuegbar: 'ja' }, { inklusive: true }]) assert.doesNotMatch((await render(P, { zeiten: kaputt })).html, /data-fn-tile="zeiten"/, JSON.stringify(kaputt))
|
||||
})
|
||||
|
||||
test('Kachel steht zwischen Terminplaner und Analyse, mit Angebot genau eine zusätzliche Kachel', async () => {
|
||||
const { html } = await render([projekt('a')], { zeiten: ANGEBOT(true) })
|
||||
const stellen = ['data-fn-tile="news"', 'Terminplaner', 'data-fn-tile="zeiten"', 'Analyse'].map((s) => html.indexOf(s))
|
||||
assert.ok(stellen.every((s) => s >= 0), stellen.join())
|
||||
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
|
||||
assert.equal(html.match(/class="fn-tile[ "]/g).length, 4, 'News, Terminplaner (Kommt bald), Zeiten, Analyse (Kommt bald)')
|
||||
assert.equal(html.match(/Kommt bald/g).length, 2)
|
||||
assert.match(kachel(html), /<h3>Öffnungszeiten live<\/h3>/)
|
||||
assert.match(kachel(html), /<svg class="fn-ico"/)
|
||||
})
|
||||
|
||||
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.match(nurText(k), /Im Wartungs-Abo enthalten/)
|
||||
assert.match(k, /fn-pill fn-pill-off">Aus</)
|
||||
assert.deepEqual(knoepfe(k), ['Einschalten'])
|
||||
assert.match(k, /<button type="button" class="kb-btn-primary fn-tile-cta" data-view="zeiten" data-project="p1">Einschalten<\/button>/)
|
||||
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig/)
|
||||
})
|
||||
|
||||
test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, 'nicht_aktiv') })).html)
|
||||
assert.match(nurText(k), /Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist/)
|
||||
assert.deepEqual(knoepfe(k), [])
|
||||
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig|<button/)
|
||||
})
|
||||
|
||||
test('ohne Abo: „30 € einmalig“', async () => {
|
||||
for (const grund of ['kein_abo', 'gekuendigt', null]) {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
assert.match(nurText(k), /30 € einmalig/, String(grund))
|
||||
assert.deepEqual(knoepfe(k), ['Öffnungszeiten einrichten · 30 €'], String(grund))
|
||||
assert.match(k, /data-view="zeiten" data-project="p1"/)
|
||||
assert.doesNotMatch(k, /Im Wartungs-Abo enthalten/, 'nichts verspricht, was nicht gilt')
|
||||
}
|
||||
// der Preis kommt aus dem Register, maskiert
|
||||
const anderer = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '31 €' } })).html)
|
||||
assert.match(nurText(anderer), /31 € einmalig/)
|
||||
assert.deepEqual(knoepfe(anderer), ['Öffnungszeiten einrichten · 31 €'])
|
||||
assert.doesNotMatch(anderer, /30 €/)
|
||||
const bose = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '<b>31 €</b>' } })).html)
|
||||
assert.doesNotMatch(bose, /<b>/)
|
||||
// ohne Preis im Angebot gilt der Rückfall 30 €
|
||||
assert.match(nurText(kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '' } })).html)), /30 € einmalig/)
|
||||
})
|
||||
|
||||
test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(null, null) })).html)
|
||||
assert.match(nurText(k), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
assert.deepEqual(knoepfe(k), [])
|
||||
assert.doesNotMatch(k, /30 €|Im Wartungs-Abo enthalten/)
|
||||
})
|
||||
|
||||
test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', async () => {
|
||||
const zeigen = async (feature, extra) => kachel((await render([projekt('p1', 'setup', feature, extra)], { zeiten: ANGEBOT(true) })).html)
|
||||
|
||||
const angestossen = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: false })
|
||||
assert.match(angestossen, /fn-pill fn-pill-setup">In Einrichtung</)
|
||||
assert.match(nurText(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen \(meist in wenigen Minuten\)/)
|
||||
assert.doesNotMatch(nurText(angestossen), /Trag jetzt deine Zeiten ein/)
|
||||
assert.deepEqual(knoepfe(angestossen), ['Zeiten bearbeiten'])
|
||||
assert.match(angestossen, /data-view="zeiten" data-project="p1"/)
|
||||
|
||||
const manuell = await zeigen({ einbau: 'manuell', ticketWoid: '4711' }, { zeitenLeer: false })
|
||||
assert.match(nurText(manuell), /Unser Team baut die Funktion ein \(Ticket #4711\)/)
|
||||
assert.doesNotMatch(nurText(manuell), /nach dem nächsten Veröffentlichen/)
|
||||
|
||||
const ohneTicket = await zeigen({ einbau: 'manuell' }, { zeitenLeer: false })
|
||||
assert.match(nurText(ohneTicket), /Unser Team baut die Funktion ein\./)
|
||||
assert.doesNotMatch(ohneTicket, /Ticket #/)
|
||||
|
||||
const leer = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: true })
|
||||
assert.match(nurText(leer), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen/)
|
||||
const leerManuell = await zeigen({ einbau: 'manuell', ticketWoid: '1' }, { zeitenLeer: true })
|
||||
assert.match(nurText(leerManuell), /Trag jetzt deine Zeiten ein/)
|
||||
|
||||
// Kauf: der Neubau steht noch aus (einbau leer): der Text nennt weder Veröffentlichen noch ein Ticket, das es nicht gibt
|
||||
const kauf = await zeigen({ unlockedBy: 'kauf', einbau: null }, { zeitenLeer: false })
|
||||
assert.match(nurText(kauf), /Unser Team baut die Funktion ein\./)
|
||||
})
|
||||
|
||||
test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', async () => {
|
||||
const status = { offen: true, kurz: 'Geöffnet', text: 'Jetzt geöffnet · schließt um 18 Uhr' }
|
||||
const { html, aufrufe } = await render([projekt('p1', 'active', { einbau: 'angestossen' }, { zeitenStatus: status, zeitenLeer: false })], { zeiten: ANGEBOT(true) })
|
||||
assert.deepEqual(aufrufe, ['/api/funktionen'], 'der Status kommt in der Liste, die Kachel fragt nichts nach')
|
||||
const k = kachel(html)
|
||||
assert.match(k, /fn-pill fn-pill-on">Aktiv</)
|
||||
assert.match(nurText(k), /Jetzt geöffnet · schließt um 18 Uhr/)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'])
|
||||
// ohne gespeicherte Zeiten kein Status; stattdessen der Hinweis
|
||||
const ohne = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: null, zeitenLeer: true })], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(ohne, /Jetzt geöffnet/)
|
||||
assert.match(nurText(ohne), /Trag jetzt deine Zeiten ein/)
|
||||
// Fremdtext nur maskiert
|
||||
const bose = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: { offen: true, kurz: 'Geöffnet', text: '<img src=x onerror=1>' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(bose, /<img/)
|
||||
assert.match(bose, /<img/)
|
||||
})
|
||||
|
||||
test('mehrere Websites: Zielprojekt ist die aktive, dann die in Einrichtung, dann die abgeschaltete, sonst die erste; „aktiv für 1 von 2 Websites“', async () => {
|
||||
const wahl = async (liste, angebot = ANGEBOT(true)) => kachel((await render(liste, { zeiten: angebot })).html).match(/data-project="([^"]+)"/)?.[1]
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })]), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'setup'), projekt('c', 'disabled')]), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], ANGEBOT(false, 'kein_abo')), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b')]), 'a')
|
||||
assert.match(nurText(kachel((await render([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)), /aktiv für 1 von 2 Websites/)
|
||||
// ohne Website: ein Satz statt eines Knopfs
|
||||
const keine = kachel((await render([], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.deepEqual(knoepfe(keine), [])
|
||||
assert.match(nurText(keine), /Sobald deine Website angelegt ist/)
|
||||
})
|
||||
|
||||
test('aboEndeAt: Behalten-Hinweis mit Datum', async () => {
|
||||
const zeigen = async (feature, angebot = ANGEBOT(false, 'kein_abo'), state = 'active') => kachel((await render([projekt('p1', state, feature, { zeitenLeer: false })], { zeiten: angebot })).html)
|
||||
|
||||
const k = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' })
|
||||
assert.match(nurText(k), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €/)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten', 'Für 30 € behalten'])
|
||||
assert.match(k, /<button type="button" class="kb-btn-outline" data-view="zeiten" data-project="p1" data-zeiten-weg="behalten">Für 30 € behalten<\/button>/)
|
||||
|
||||
// Berliner Kalendertag, nicht UTC: 01.10. 23:30 UTC ist schon der 02.10. in Berlin ⇒ Frist 01.11.
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T23:30:00.000Z' })), /bis 01\.11\.2026/)
|
||||
// Appwrite liefert +00:00
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000+00:00' })), /bis 31\.10\.2026/)
|
||||
// auch in der Einrichtung
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(false, 'kein_abo'), 'setup')), /behalte die Funktion bis 31\.10\.2026/)
|
||||
// läuft das Abo wieder (inklusive true), gibt es nichts zu bezahlen: der Abgleich nimmt das Ende gleich zurück
|
||||
const wieder = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(true))
|
||||
assert.doesNotMatch(wieder, /behalten|Für 30 €/)
|
||||
assert.deepEqual(knoepfe(wieder), ['Zeiten bearbeiten'])
|
||||
// Abo nicht lesbar: kein Bezahlknopf auf Verdacht
|
||||
const unklar = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(null, null))
|
||||
assert.deepEqual(knoepfe(unklar), ['Zeiten bearbeiten'])
|
||||
// ohne Datum kein Hinweis
|
||||
assert.doesNotMatch(await zeigen({ aboEndeAt: null }), /Wartungs-Abo ist beendet/)
|
||||
// ein unlesbares Datum erfindet kein Datum
|
||||
const kaputt = await zeigen({ aboEndeAt: 'kein-datum' })
|
||||
assert.doesNotMatch(nurText(kaputt), /bis NaN|Invalid|undefined/)
|
||||
})
|
||||
|
||||
// Fix Runde 1: Behalten (30 €) gibt es nur ohne Abo (inklusive false UND grund kein_abo). nicht_aktiv und gekuendigt heissen, dass das Abo läuft (R20/R25).
|
||||
test('aboEndeAt bei laufendem Abo (nicht_aktiv, gekuendigt): „Abo läuft wieder“, nie ein Bezahlknopf', async () => {
|
||||
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
|
||||
for (const state of ['active', 'setup']) {
|
||||
const k = kachel((await render([projekt('p1', state, { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
const was = `${grund}/${state}`
|
||||
assert.match(nurText(k), /Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten\./, was)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'], was)
|
||||
assert.doesNotMatch(nurText(k), /ist beendet|behalte die Funktion|für einmalig|Für 30 €|30 €/, was)
|
||||
assert.doesNotMatch(k, /data-zeiten-weg/, was)
|
||||
}
|
||||
}
|
||||
// unbekannter Grund oder kein Grund (false, null): nicht auf Verdacht bezahlen lassen
|
||||
for (const grund of [null, 'unbekannt']) {
|
||||
const k = kachel((await render([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'], String(grund))
|
||||
assert.doesNotMatch(nurText(k), /Für 30 €|behalte die Funktion/, String(grund))
|
||||
}
|
||||
// Gegenprobe: ohne Abo (kein_abo) bleibt es beim Angebot
|
||||
const ohne = kachel((await render([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
|
||||
assert.deepEqual(knoepfe(ohne), ['Zeiten bearbeiten', 'Für 30 € behalten'])
|
||||
})
|
||||
|
||||
test('disabled abo_ende mit Daten bei laufendem Abo (nicht_aktiv, gekuendigt): kein „Für 30 € wieder einschalten“, stattdessen „Abo läuft wieder“', async () => {
|
||||
const feature = { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }
|
||||
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
|
||||
const k = kachel((await render([projekt('p1', 'disabled', feature, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
assert.doesNotMatch(k, /Für 30 €|data-zeiten-weg|<button/, grund)
|
||||
assert.match(nurText(k), /Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein/, grund)
|
||||
assert.match(nurText(k), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert/, grund)
|
||||
}
|
||||
for (const grund of [null, 'unbekannt']) {
|
||||
const k = kachel((await render([projekt('p1', 'disabled', feature, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
assert.doesNotMatch(k, /Für 30 €|<button/, String(grund))
|
||||
assert.match(nurText(k), /Gerade nicht prüfbar/, String(grund))
|
||||
}
|
||||
// Gegenprobe: kein_abo bietet es an
|
||||
const ohne = kachel((await render([projekt('p1', 'disabled', feature, { zeitenLeer: false })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
|
||||
assert.deepEqual(knoepfe(ohne), ['Für 30 € wieder einschalten'])
|
||||
})
|
||||
|
||||
test('ordered: „Zahlung wird bestätigt …“', async () => {
|
||||
const k = kachel((await render([projekt('p1', 'ordered', { unlockedBy: null })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
|
||||
assert.match(k, /fn-pill fn-pill-setup">Zahlung offen</)
|
||||
assert.match(nurText(k), /Zahlung wird bestätigt …/)
|
||||
assert.deepEqual(knoepfe(k), ['Status ansehen'])
|
||||
assert.match(k, /data-view="zeiten" data-project="p1"/)
|
||||
})
|
||||
|
||||
test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', async () => {
|
||||
const zeigen = async (feature, extra, angebot = ANGEBOT(false, 'kein_abo')) => kachel((await render([projekt('p1', 'disabled', feature, extra)], { zeiten: angebot })).html)
|
||||
|
||||
const k = await zeigen({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })
|
||||
assert.match(k, /fn-pill fn-pill-off">Abgeschaltet</)
|
||||
assert.match(nurText(k), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
|
||||
assert.match(nurText(k), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert/)
|
||||
assert.deepEqual(knoepfe(k), ['Für 30 € wieder einschalten'])
|
||||
assert.match(k, /data-view="zeiten" data-project="p1" data-zeiten-weg="behalten"/)
|
||||
|
||||
// keine Daten mehr (gelöscht oder nie eingetragen): nichts zu behalten
|
||||
const leer = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: true })
|
||||
assert.doesNotMatch(leer, /Für 30 €/)
|
||||
assert.deepEqual(knoepfe(leer), ['Chat öffnen'])
|
||||
// die Frist der Löschung ist vorbei
|
||||
const zuSpaet = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2000-01-01' }, { zeitenLeer: false })
|
||||
assert.doesNotMatch(zuSpaet, /Für 30 €/)
|
||||
// Abschaltung durch das Team: Grundtext aus der Abschaltung, kein Behalten
|
||||
const team = await zeigen({ disabledReason: 'team' }, { zeitenLeer: false })
|
||||
assert.doesNotMatch(team, /Für 30 €/)
|
||||
assert.match(nurText(team), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
|
||||
assert.deepEqual(knoepfe(team), ['Chat öffnen'])
|
||||
// Altbestand ohne Grund
|
||||
assert.deepEqual(knoepfe(await zeigen({}, { zeitenLeer: false })), ['Chat öffnen'])
|
||||
// das Abo läuft wieder: der Abgleich schaltet selbst wieder ein, 30 € wären doppelt bezahlt
|
||||
const wieder = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false }, ANGEBOT(true))
|
||||
assert.doesNotMatch(wieder, /Für 30 €/)
|
||||
assert.match(nurText(wieder), /Dein Wartungs-Abo läuft wieder/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Klick
|
||||
test('Knopf: das Projekt (und beim Behalten der Weg) geht an #zeiten-body; nur Knöpfe der Zeiten-Kachel', async () => {
|
||||
const gesetzt = []
|
||||
const gehoert = {}
|
||||
const artikel = { addEventListener: (typ, fn) => { gehoert[typ] = fn } }
|
||||
const body = { dataset: {} }
|
||||
const vorher = globalThis.document
|
||||
globalThis.document = { getElementById: (id) => { gesetzt.push(id); return id === 'zeiten-body' ? body : null } }
|
||||
try {
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], zeiten: ANGEBOT(false, 'kein_abo') }) })
|
||||
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="zeiten"]' ? artikel : null) }
|
||||
await initFunktionen(root)
|
||||
assert.equal(typeof gehoert.click, 'function', 'ein Klick-Handler an der Kachel')
|
||||
const knopf = (dataset) => ({ closest: (sel) => (sel === 'button[data-view="zeiten"]' ? { dataset } : null) })
|
||||
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
|
||||
assert.equal(body.dataset.wunschProjekt, 'p1')
|
||||
assert.equal(body.dataset.wunschWeg, undefined, 'ohne Weg keiner')
|
||||
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
|
||||
assert.equal(body.dataset.wunschWeg, 'behalten')
|
||||
// der nächste Klick ohne Weg nimmt den alten zurück
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
|
||||
assert.equal(body.dataset.wunschWeg, undefined)
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
|
||||
assert.equal(body.dataset.wunschWeg, 'behalten')
|
||||
|
||||
// ein Klick auf etwas anderes in der Kachel (Text) legt nichts ab
|
||||
body.dataset = {}
|
||||
gehoert.click({ target: { closest: () => null } })
|
||||
gehoert.click({ target: {} })
|
||||
assert.deepEqual(body.dataset, {})
|
||||
// ein unbekannter Weg wird nicht übernommen
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p2', zeitenWeg: 'alles-frei' }) })
|
||||
assert.equal(body.dataset.wunschProjekt, 'p2')
|
||||
assert.equal(body.dataset.wunschWeg, undefined)
|
||||
} finally {
|
||||
if (vorher === undefined) delete globalThis.document
|
||||
else globalThis.document = vorher
|
||||
}
|
||||
})
|
||||
|
||||
test('Projekt-IDs und Namen kommen nur maskiert in die Kachel', async () => {
|
||||
const k = kachel((await render([projekt('"><script>x</script>', 'off')], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(k, /<script>/)
|
||||
assert.match(k, /data-project=""><script>x<\/script>"/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Quelltext
|
||||
test('funktionen.js: keine neuen Importe für die Zeiten (die Kachel steht inline), keine „24 Uhr“, Du-Form', () => {
|
||||
assert.deepEqual(quelle.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
|
||||
assert.deepEqual(quelle.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
|
||||
assert.doesNotMatch(quelle, /import\s*\(/)
|
||||
assert.doesNotMatch(quelle, /from '[^']*zeiten/, 'kein Zeiten-Modul in funktionen.js')
|
||||
const von = quelle.indexOf('Öffnungszeiten live')
|
||||
assert.ok(von > 0)
|
||||
assert.doesNotMatch(quelle.slice(von), /24 Uhr/)
|
||||
assert.doesNotMatch(quelle, /\bSie\b/)
|
||||
})
|
||||
@@ -27,6 +27,7 @@ const { createStaffPreviewToken } = await import('../server/services/staffPrevie
|
||||
|
||||
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
|
||||
const termineRouter = (await import('../server/routes/termine.js')).default
|
||||
const zeitenRouter = (await import('../server/routes/zeiten.js')).default
|
||||
const MOUNTS = [
|
||||
['/api/auth', (await import('../server/routes/auth.js')).default],
|
||||
['/api/projects', (await import('../server/routes/projects.js')).default],
|
||||
@@ -39,6 +40,7 @@ const MOUNTS = [
|
||||
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
|
||||
['/api/termine', termineRouter],
|
||||
['/api/shop', (await import('../server/routes/shop.js')).default],
|
||||
['/api/zeiten', zeitenRouter],
|
||||
]
|
||||
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
||||
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
||||
@@ -119,7 +121,32 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a
|
||||
}
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link, Ende, Logout und Login', () => {
|
||||
// Gratisweg "Einschalten" der Oeffnungszeiten (POST /api/funktionen/zeiten/einschalten): eine Schreib-Route, die nicht auf der Erlaubnisliste
|
||||
// steht; Mitarbeiter schalten nicht fuer den Kunden ein. Der Waechter sperrt sie vor dem Router (die Route prueft zusaetzlich selbst viewAs).
|
||||
test('Einschalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => {
|
||||
const routes = writeRoutes(MOUNTS)
|
||||
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/einschalten'), 'Route gefunden')
|
||||
const p = '/api/funktionen/zeiten/einschalten'
|
||||
assert.ok(!allowed('POST', p), p)
|
||||
assert.ok(!allowed('POST', `${p}/`), `${p}/`)
|
||||
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung'] } } })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(res.body.code, 'view_blocked')
|
||||
})
|
||||
|
||||
// "Fuer 30 EUR behalten" (POST /api/funktionen/zeiten/behalten): ebenfalls eine Bestellung im Namen des Kunden, nicht auf der Erlaubnisliste.
|
||||
test('Behalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefunden, nicht erlaubt, 403 view_blocked)', async () => {
|
||||
const routes = writeRoutes(MOUNTS)
|
||||
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/zeiten/behalten'), 'Route gefunden')
|
||||
const p = '/api/funktionen/zeiten/behalten'
|
||||
assert.ok(!allowed('POST', p), p)
|
||||
assert.ok(!allowed('POST', `${p}/`), `${p}/`)
|
||||
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', zustimmung: { version: '2026-10-04', haken: ['leistung', 'beginn'] } } })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(res.body.code, 'view_blocked')
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
|
||||
const ok = [
|
||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
|
||||
@@ -129,9 +156,10 @@ test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link
|
||||
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
|
||||
['POST', '/api/shop/produkte'], ['POST', '/api/shop/produkte/'], ['POST', '/api/shop/produkte/sortierung'], ['PUT', '/api/shop/produkte/abc'],
|
||||
['DELETE', '/api/shop/produkte/abc'], ['PUT', '/api/shop/einstellungen'], ['PUT', '/api/shop/rechtstexte'], ['POST', '/api/shop/bild'],
|
||||
['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'],
|
||||
]
|
||||
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7, 'genau sechs Termine- und sieben Webshop-Eintraege dazu, keine Wildcard')
|
||||
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 7 + 1, 'genau sechs Termine-, sieben Webshop- und ein Zeiten-Eintrag dazu, keine Wildcard')
|
||||
for (const [m, p] of [
|
||||
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
|
||||
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
|
||||
@@ -151,6 +179,9 @@ test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Analyse-Link
|
||||
['POST', '/api/shop/rechtstexte'], ['POST', '/api/shop/einstellungen'], ['PATCH', '/api/shop/produkte/abc'], ['POST', '/api/shop/produkte/abc'],
|
||||
['PUT', '/api/shop/produkte'], ['DELETE', '/api/shop/produkte'], ['PUT', '/api/shop/bild'], ['POST', '/api/shop/bild/x'],
|
||||
['POST', '/api/shopX/produkte'], ['PUT', '/api/shop/produkte/abc/def'], ['DELETE', '/api/shop/einstellungen'],
|
||||
// Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts
|
||||
['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'],
|
||||
['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'],
|
||||
]) {
|
||||
assert.ok(!allowed(m, p), `${m} ${p}`)
|
||||
}
|
||||
@@ -226,6 +257,31 @@ test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf d
|
||||
}
|
||||
})
|
||||
|
||||
// Oeffnungszeiten: die Erlaubnisliste und die echten Schreib-Routen decken sich genau (eine Route, ein Eintrag); jedes andere
|
||||
// Schreiben unter /api/zeiten (auch ohne Route, auch Gross-/Kleinschreibung) sperrt der Waechter, normale Kunden beruehrt er nicht.
|
||||
test('Router-Abgleich: genau eine Schreib-Route der Öffnungszeiten (PUT /:projectId) steht auf der Erlaubnisliste', async () => {
|
||||
const routen = writeRoutes([['/api/zeiten', zeitenRouter]])
|
||||
assert.deepEqual(routen.map(({ method, path: p }) => `${method} ${p}`), ['PUT /api/zeiten/x'], 'der Router schreibt nur per PUT /:projectId')
|
||||
const eintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('zeiten'))
|
||||
assert.equal(eintraege.length, 1)
|
||||
assert.ok(allowed('PUT', '/api/zeiten/x'))
|
||||
|
||||
for (const [method, p] of [['POST', '/api/zeiten/p1'], ['PATCH', '/api/zeiten/p1'], ['DELETE', '/api/zeiten/p1'], ['PUT', '/api/zeiten/p1/vorschau'], ['PUT', '/api/zeiten'], ['POST', '/API/Zeiten/p1']]) {
|
||||
const res = await call(method, p, { ...kv(), body: {} })
|
||||
assert.equal(res.status, 403, `${method} ${p}`)
|
||||
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
|
||||
}
|
||||
// ohne Kennung im Header gilt auch der erlaubte Pfad nicht (wie bei allen Schreib-Routen)
|
||||
const ohneKennung = await call('PUT', '/api/zeiten/p1', { cookie: kundenansichtCookie(), body: {} })
|
||||
assert.equal(ohneKennung.status, 409)
|
||||
assert.equal(ohneKennung.body.code, 'view_replaced')
|
||||
// der erlaubte Pfad erreicht den Router (hier: das Projekt gehoert c1, die Funktion gibt es nicht => 403 der Route, nicht des Waechters)
|
||||
const durchgelassen = await call('PUT', '/api/zeiten/p1', { ...kv(), body: { version: 0, daten: {} } })
|
||||
assert.equal(durchgelassen.status, 403)
|
||||
assert.notEqual(durchgelassen.body.code, 'view_blocked')
|
||||
assert.match(durchgelassen.body.error, /nicht eingeschaltet/)
|
||||
})
|
||||
|
||||
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
|
||||
const res = await call('POST', '/api/chat/read', kv())
|
||||
assert.equal(res.status, 403)
|
||||
|
||||
+10
-10
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); 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); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 15), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21']
|
||||
const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]
|
||||
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
|
||||
const GUEST = ids('G', 8)
|
||||
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
|
||||
@@ -92,14 +92,14 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
|
||||
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
|
||||
// F8: fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen (mit anhang) und zustimmungen für Funktionen mit Rechtsdokumenten
|
||||
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url', 'fassung', 'lb_url', 'avv_url', 'widerruf_url', 'zustimmungen_text'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
const k16 = loadTemplate('K16')
|
||||
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
|
||||
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
|
||||
assert.deepEqual(k16.blocks, [])
|
||||
for (const tpl of [k15, k16]) assert.doesNotMatch(tpl.body, /News|Beitr|#news|#termine|Terminplaner/, `${tpl.id}: keine feste Funktion`)
|
||||
|
||||
const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true })
|
||||
const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true, neu: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
assert.equal(mail.subject, 'Deine Bestellung: Wert-funktion')
|
||||
for (const part of ['Wir richten Wert-funktion_akk jetzt für Dein Projekt ein.', 'Sobald Wert-funktion_nom auf Deiner Website eingebaut ist, schalten wir Wert-funktion_pron im Kundenportal frei', 'Wert-danach', 'Wert-preis (Wert-preis_hinweis)', 'Rechnung ansehen: https://rechnung.example/1', 'Zum Kundenportal: https://portal.example/#x']) {
|
||||
@@ -133,7 +133,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
|
||||
test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
|
||||
const t16 = loadTemplate('T16')
|
||||
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
|
||||
const t19 = loadTemplate('T19')
|
||||
assert.equal(t19.file, 'T19 Erstattung faellig.html')
|
||||
assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)
|
||||
@@ -543,8 +543,8 @@ test('T02 und T07 erinnern an UptimeRobot', () => {
|
||||
assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text)
|
||||
})
|
||||
|
||||
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => {
|
||||
for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
|
||||
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14, T12 und K29 (Abo beendet, Öffnungszeiten live)', () => {
|
||||
for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K29'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
|
||||
})
|
||||
|
||||
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
|
||||
@@ -984,9 +984,9 @@ test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und na
|
||||
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
|
||||
})
|
||||
|
||||
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest)
|
||||
test('K22, K23: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
|
||||
for (const id of ['K22', 'K23']) {
|
||||
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest); Öffnungszeiten live: K27–K30 ebenso
|
||||
test('K22, K23, K27–K30: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
|
||||
for (const id of ['K22', 'K23', 'K27', 'K28', 'K29', 'K30']) {
|
||||
const tpl = loadTemplate(id)
|
||||
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`)
|
||||
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
|
||||
|
||||
@@ -138,7 +138,7 @@ test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis
|
||||
assert.equal(aw.get('websiteProjects', id).previewState, 'building')
|
||||
const [sub] = submits()
|
||||
assert.equal(sub.auth, 'Bearer sekret')
|
||||
assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' }, analytics: null })
|
||||
assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' }, analytics: null, zeiten: null })
|
||||
await pd.waitForRunFollowers()
|
||||
const d = aw.get('websiteProjects', id)
|
||||
assert.equal(d.previewState, 'online')
|
||||
@@ -475,6 +475,55 @@ test('Analyse: Dev-PUT nach erfolgreichem Build (Modus dev) enthält kein analyt
|
||||
for (const c of puts) assert.equal('analytics' in c.body, false)
|
||||
})
|
||||
|
||||
// ---- Zeiten-Feld im Build-Auftrag (Oeffnungszeiten live, Spec 4.6) ----
|
||||
async function zeitenProject({ state = 'active', project: pextra = {}, noFeature = false } = {}) {
|
||||
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const cid = `cust-ze-${n + 1}`
|
||||
const p = await project({ customerId: cid, subdomain: `ze-${n + 1}`, ...pextra })
|
||||
if (!noFeature) {
|
||||
await createDocument('portalFeatures', { customerId: cid, projectId: p.id, featureKey: 'zeiten', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop' }, `f_${p.id}_zeiten`)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
test('Zeiten: setup und active ⇒ zeiten mit siteKey, auch ohne isPublic', async () => {
|
||||
for (const state of ['setup', 'active']) {
|
||||
assert.deepEqual((await submitFor(await zeitenProject({ state }))).zeiten, { siteKey: 'abcdefghijklmnop' }, state)
|
||||
}
|
||||
})
|
||||
|
||||
test('Zeiten: off, ordered, disabled und ohne Funktion ⇒ null', async () => {
|
||||
for (const state of ['off', 'ordered', 'disabled']) {
|
||||
assert.equal((await submitFor(await zeitenProject({ state }))).zeiten, null, state)
|
||||
}
|
||||
assert.equal((await submitFor(await zeitenProject({ noFeature: true }))).zeiten, null)
|
||||
})
|
||||
|
||||
test('Zeiten: Sperrliste ⇒ null, Analyse bleibt unberührt', async () => {
|
||||
const body = await submitFor(await zeitenProject({ project: { subdomain: 'emmas-schatzkiste' } }))
|
||||
assert.equal(body.zeiten, null)
|
||||
assert.equal(body.analytics, null)
|
||||
})
|
||||
|
||||
test('Zeiten: Fehler bei der Funktions-Abfrage ⇒ null, Auftrag wird trotzdem gesendet', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
const p = await zeitenProject()
|
||||
aw.failNext({ method: 'GET', pathIncludes: 'portalFeatures', status: 500 })
|
||||
const before = submits().length
|
||||
const body = await submitFor(p)
|
||||
assert.equal(submits().length, before + 1)
|
||||
assert.equal(body.zeiten, null)
|
||||
})
|
||||
|
||||
test('Zeiten: Dev-PUT nach erfolgreichem Build (Modus dev) enthält kein zeiten', async () => {
|
||||
rs.script = done({ result: 'dev' })
|
||||
const body = await submitFor(await zeitenProject({ project: { previewMode: 'dev' } }))
|
||||
assert.deepEqual(body.zeiten, { siteKey: 'abcdefghijklmnop' })
|
||||
const puts = rs.calls.filter((c) => c.method === 'PUT' && c.url.startsWith('/dev/'))
|
||||
assert.ok(puts.length >= 1)
|
||||
for (const c of puts) assert.equal('zeiten' in c.body, false)
|
||||
})
|
||||
|
||||
test('Analyse: überlange Angaben, Steuerzeichen, schlechte Farbe ⇒ Grenzen eingehalten, Auftrag geht raus', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
const p = await analyseProject({
|
||||
|
||||
@@ -1147,6 +1147,40 @@ test('widerruf_shop: Webshop (Einrichtung) rechnet mit der eigenen Frist, K04 un
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_zeiten: „Öffnungszeiten live“ gehört zu den Zeiten (nicht zur News), Frist und Produkt in K04 und T09 stimmen', async () => {
|
||||
const newsAt = daysAgo(20)
|
||||
const zeitenAt = daysAgo(3)
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
|
||||
seedBezahlt({ projectId: 'pz1', projektName: 'Praxis Zeiten', featureKey: 'zeiten', orderedAt: zeitenAt })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
const before = st.calls.length
|
||||
assert.equal((await withdraw({ order: 'zeiten', customerNumber: 'KD-9' })).status, 200)
|
||||
const kunde = k04()
|
||||
assert.match(kunde.subject, /WEBklar Öffnungszeiten live$/)
|
||||
assert.match(kunde.text, /Widerruf der Bestellung „Öffnungszeiten live“/)
|
||||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||||
assert.doesNotMatch(kunde.text, /News-Pinnwand|Es erfolgen keine weiteren Abbuchungen/)
|
||||
const team = t09().text
|
||||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.zeiten.label.name}, ${config.features.zeiten.priceLabel} einmalig)`), team)
|
||||
assert.match(team, /Öffnungszeiten live/)
|
||||
assert.match(team, /RECHTZEITIG/, 'Frist der Zeiten (vor 3 Tagen), nicht die der News (vor 20 Tagen)')
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(zeitenAt).endsAt)), 'Fristende der Zeiten')
|
||||
assert.match(team, /Praxis Zeiten: in Einrichtung/)
|
||||
assert.doesNotMatch(team, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
|
||||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||||
assert.equal(aw.docs('portalFeatures').get('f_pz1_zeiten').state, 'setup', 'nichts automatisch geändert')
|
||||
|
||||
// „Funktion“ bleibt die News: ihre Frist ist abgelaufen, die jungen Zeiten retten sie nicht
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.match(t09().text, /NACH Fristende/)
|
||||
assert.doesNotMatch(t09().text, /Praxis Zeiten/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pz1_zeiten')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
// Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss)
|
||||
@@ -1420,6 +1454,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||||
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/)
|
||||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// Oeffentliche Schnittstelle der Oeffnungszeiten (/api/public/zeiten/v1, Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
|
||||
// Abschnitt 4.1): Router im Nachbau von Appwrite, feste Uhr (setzeUhrFuerTests), je Test eine eigene Client-Adresse (X-Forwarded-For).
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||
},
|
||||
datetimes: ZEITEN_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { kundenansichtCookie } = await import('./support/fixtures.js')
|
||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
||||
const { speichereZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||
const { darfZeitenAnfrage } = await import('../server/services/zeiten/limits.js')
|
||||
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||||
const { default: router, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../server/routes/publicZeiten.js')
|
||||
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/zeiten/v1': router })
|
||||
after(() => Promise.all([app.close(), aw.close()]))
|
||||
|
||||
const BASIS = '/api/public/zeiten/v1'
|
||||
const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00
|
||||
const SITE_KEY = 'abcdabcdabcdabcd'
|
||||
const KUNDE = 'c_kunde01'
|
||||
const PROJEKT = 'p_projekt01'
|
||||
const FEATURE_ID = 'f_p_projekt01_zeiten'
|
||||
const P_STANDARD = 'p_aaaaaaa1'
|
||||
const MODUS_VORHER = config.zeiten.mode
|
||||
|
||||
const FEATURE = { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, featureKey: 'zeiten', state: 'active', enabled: true }
|
||||
|
||||
const tage = (woche = {}) => {
|
||||
const t = {}
|
||||
for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
|
||||
return t
|
||||
}
|
||||
const eingabe = (extra = {}) => ({
|
||||
land: 'RP',
|
||||
plaene: [
|
||||
{ id: P_STANDARD, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'] }) },
|
||||
],
|
||||
feiertage: {},
|
||||
ausnahmen: [],
|
||||
hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Wir sind für Euch da.', knopf: null }],
|
||||
kontakt: { an: true, telefon: '0171 1234567', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: null },
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** Funktion, Kunde und Projekt in den Nachbau legen; mit zeiten = false bleibt es beim leeren Dokument. */
|
||||
async function legeAn({ feature = {}, kunde = { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, zeiten = true, extra = {} } = {}) {
|
||||
aw.seed('portalFeatures', { ...FEATURE, ...feature })
|
||||
if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde })
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' })
|
||||
if (zeiten) await speichereZeiten({ feature: FEATURE, eingabe: eingabe(extra), version: 0, by: 'kunde', now: JETZT })
|
||||
}
|
||||
|
||||
let clientIp = 0
|
||||
let ip
|
||||
/** Anfrage an die Schnittstelle: Pfad ab /v1, eigene Client-Adresse je Test. */
|
||||
const api = (method, pfad, { ip: adresse = ip, headers = {}, ...opts } = {}) =>
|
||||
send(method, BASIS + pfad, { ...opts, headers: { 'x-forwarded-for': adresse, ...headers } })
|
||||
const lies = (pfad = `/${SITE_KEY}`, opts) => api('GET', pfad, opts)
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
config.zeiten.mode = 'on'
|
||||
resetZeitenCacheForTests()
|
||||
resetTermineLimitsForTests()
|
||||
resetPublicZeitenForTests()
|
||||
setzeUhrFuerTests(() => JETZT)
|
||||
mock.method(console, 'error', () => {})
|
||||
mock.method(console, 'warn', () => {})
|
||||
clientIp += 1
|
||||
ip = `198.51.100.${clientIp}`
|
||||
})
|
||||
afterEach(() => {
|
||||
config.zeiten.mode = MODUS_VORHER
|
||||
mock.restoreAll()
|
||||
resetPublicZeitenForTests()
|
||||
})
|
||||
|
||||
const AKTIV_FALSE = { aktiv: false }
|
||||
const NICHT_ERREICHBAR = { error: 'Vorübergehend nicht erreichbar.' }
|
||||
|
||||
// ---------------------------------------------------------------- CORS, Methoden
|
||||
|
||||
test('CORS * und OPTIONS 204', async () => {
|
||||
const vorab = await api('OPTIONS', `/${SITE_KEY}`, { headers: { origin: 'https://baeckerei.example', 'access-control-request-method': 'GET' } })
|
||||
assert.equal(vorab.status, 204)
|
||||
assert.equal(vorab.headers.get('access-control-allow-origin'), '*')
|
||||
assert.equal(vorab.headers.get('access-control-allow-methods'), 'GET, HEAD, OPTIONS')
|
||||
assert.match(vorab.headers.get('access-control-allow-headers'), /If-None-Match/)
|
||||
assert.equal(vorab.headers.get('access-control-max-age'), '86400')
|
||||
assert.equal(vorab.body, null)
|
||||
|
||||
await legeAn()
|
||||
const antwort = await lies(undefined, { headers: { origin: 'https://baeckerei.example' } })
|
||||
assert.equal(antwort.status, 200)
|
||||
assert.equal(antwort.headers.get('access-control-allow-origin'), '*')
|
||||
assert.equal(antwort.headers.get('set-cookie'), null)
|
||||
assert.equal(antwort.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.match(antwort.headers.get('content-type'), /^application\/json/)
|
||||
|
||||
// der Preflight zaehlt nicht gegen die Grenze und ist auch auf unbekannten Pfaden ein 204
|
||||
const anderswo = await api('OPTIONS', '/irgendwas/anderes')
|
||||
assert.equal(anderswo.status, 204)
|
||||
assert.equal(anderswo.headers.get('access-control-allow-origin'), '*')
|
||||
})
|
||||
|
||||
test('nur GET/HEAD, sonst 405', async () => {
|
||||
await legeAn()
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
const res = await api(method, `/${SITE_KEY}`, { body: { a: 1 } })
|
||||
assert.equal(res.status, 405, method)
|
||||
assert.deepEqual(res.body, { error: 'Methode nicht erlaubt' }, method)
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), '*', `${method}: die Meldung bleibt fuer das Skript lesbar`)
|
||||
}
|
||||
const head = await api('HEAD', `/${SITE_KEY}`)
|
||||
assert.equal(head.status, 200)
|
||||
assert.equal(head.body, null, 'HEAD ohne Rumpf')
|
||||
assert.match(head.headers.get('etag'), /^W\/"[A-Za-z0-9_-]{27}"$/)
|
||||
assert.equal(head.headers.get('cache-control'), 'public, max-age=60')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Kopfzeilen, ETag
|
||||
|
||||
test('Cache-Control public, max-age=60 und Expose-Headers Date', async () => {
|
||||
await legeAn()
|
||||
const res = await lies()
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('cache-control'), 'public, max-age=60')
|
||||
assert.equal(res.headers.get('access-control-expose-headers'), 'Date')
|
||||
assert.equal(res.body.aktiv, true)
|
||||
assert.equal(res.body.stand, JETZT.toISOString())
|
||||
assert.equal(res.body.plaene.length, 1)
|
||||
assert.equal(res.body.firma.name, 'Bäckerei Sonnenschein')
|
||||
|
||||
// auch die kurze Antwort { aktiv: false } und die Fehlerantworten tragen die Date-Freigabe, damit das Skript die Serverzeit lesen kann
|
||||
const leer = await lies('/zzzzzzzzzzzzzzzz')
|
||||
assert.equal(leer.headers.get('access-control-expose-headers'), 'Date')
|
||||
assert.equal(leer.headers.get('cache-control'), 'public, max-age=60')
|
||||
const fremd = await api('POST', `/${SITE_KEY}`)
|
||||
assert.equal(fremd.headers.get('access-control-expose-headers'), 'Date')
|
||||
})
|
||||
|
||||
test('Antwort ohne interne IDs, Kundendaten und Zugangswerte', async () => {
|
||||
await legeAn({ kunde: { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein', email: 'sonne@example.com', phone: '0699' } })
|
||||
const res = await lies()
|
||||
const text = JSON.stringify(res.body)
|
||||
for (const intern of [FEATURE_ID, KUNDE, PROJEKT, SITE_KEY, P_STANDARD, 'h_dauer001', 'Frau Sonne', 'sonne@example.com', 'Sonnenschein-Website']) {
|
||||
assert.ok(!text.includes(intern), `Wert ${intern}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('ETag bleibt gleich, obwohl stand sich ändert ⇒ 304', async () => {
|
||||
await legeAn()
|
||||
const erste = await lies()
|
||||
assert.equal(erste.status, 200)
|
||||
const etag = erste.headers.get('etag')
|
||||
assert.match(etag, /^W\/"[A-Za-z0-9_-]{27}"$/, 'weiches ETag, sha1 base64url')
|
||||
|
||||
// der Speicher laeuft nach 30 s ab und die Antwort wird neu gebaut: gleiche Daten, neuer Zeitstempel (noch dieselbe Minute:
|
||||
// der erste Eintrag von wechsel beginnt mit der aktuellen Minute und gehoert zum Body)
|
||||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 30 * 1000))
|
||||
const neu = await lies()
|
||||
assert.equal(neu.status, 200)
|
||||
assert.notEqual(neu.body.stand, erste.body.stand, 'stand ist neu')
|
||||
assert.equal(neu.headers.get('etag'), etag, 'das ETag haengt nicht an stand')
|
||||
|
||||
const bedingt = await lies(undefined, { headers: { 'if-none-match': etag } })
|
||||
assert.equal(bedingt.status, 304)
|
||||
assert.equal(bedingt.body, null)
|
||||
assert.equal(bedingt.headers.get('etag'), etag)
|
||||
assert.equal(bedingt.headers.get('cache-control'), 'public, max-age=60')
|
||||
assert.equal(bedingt.headers.get('access-control-allow-origin'), '*')
|
||||
|
||||
for (const wert of [etag.slice(2), `"abc", ${etag}`, '*']) {
|
||||
const res = await lies(undefined, { headers: { 'if-none-match': wert } })
|
||||
assert.equal(res.status, 304, wert)
|
||||
}
|
||||
const veraltet = await lies(undefined, { headers: { 'if-none-match': 'W/"nicht-mehr-aktuell"' } })
|
||||
assert.equal(veraltet.status, 200)
|
||||
})
|
||||
|
||||
test('ETag ändert sich mit dem Inhalt (Hinweis geändert) und ist für { aktiv: false } stabil', async () => {
|
||||
await legeAn()
|
||||
const vorher = (await lies()).headers.get('etag')
|
||||
await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Neuer Text', knopf: null }] }), version: 1, by: 'kunde', now: JETZT })
|
||||
const nachher = await lies()
|
||||
assert.equal(nachher.status, 200)
|
||||
assert.equal(nachher.body.hinweise[0].text, 'Neuer Text')
|
||||
assert.notEqual(nachher.headers.get('etag'), vorher)
|
||||
|
||||
const leer1 = await lies('/zzzzzzzzzzzzzzzz')
|
||||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 5 * 60 * 1000))
|
||||
const leer2 = await lies('/zzzzzzzzzzzzzzzz')
|
||||
assert.deepEqual(leer1.body, AKTIV_FALSE)
|
||||
assert.equal(leer1.headers.get('etag'), leer2.headers.get('etag'))
|
||||
assert.equal((await lies('/zzzzzzzzzzzzzzzz', { headers: { 'if-none-match': leer1.headers.get('etag') } })).status, 304)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Grenze
|
||||
|
||||
test('121. Anfrage in einer Minute ⇒ 429 mit Retry-After', async () => {
|
||||
await legeAn()
|
||||
for (let i = 1; i <= 120; i += 1) {
|
||||
const res = await lies()
|
||||
assert.equal(res.status, 200, `Anfrage ${i}`)
|
||||
}
|
||||
const zuViele = await lies()
|
||||
assert.equal(zuViele.status, 429)
|
||||
assert.equal(zuViele.headers.get('retry-after'), '60')
|
||||
assert.equal(zuViele.headers.get('access-control-allow-origin'), '*')
|
||||
assert.equal(zuViele.headers.get('cache-control'), 'no-store')
|
||||
assert.deepEqual(zuViele.body, { error: 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' })
|
||||
|
||||
// der Preflight zaehlt nicht, eine andere Adresse hat ihre eigene Grenze, nach einer Minute geht es wieder
|
||||
assert.equal((await api('OPTIONS', `/${SITE_KEY}`)).status, 204)
|
||||
assert.equal((await lies(undefined, { ip: '198.51.100.250' })).status, 200)
|
||||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 60 * 1000))
|
||||
assert.equal((await lies()).status, 200)
|
||||
})
|
||||
|
||||
test('darfZeitenAnfrage: 120 je Minute und IP, IPv6 je /64, getrennt von den Zählern des Terminplaners', async () => {
|
||||
const jetzt = new Date('2026-10-05T10:00:00.000Z')
|
||||
for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), true, `Anfrage ${i + 1}`)
|
||||
assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), false)
|
||||
assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 59999)), false, 'das Fenster dauert 60 s')
|
||||
assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 60000)), true)
|
||||
assert.equal(darfZeitenAnfrage('203.0.113.10', jetzt), true, 'andere IP, anderer Zähler')
|
||||
|
||||
for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('2001:db8:1:2:aaaa::1', jetzt), true)
|
||||
assert.equal(darfZeitenAnfrage('2001:db8:1:2:bbbb::7', jetzt), false, 'dasselbe /64 teilt sich den Zähler')
|
||||
assert.equal(darfZeitenAnfrage('2001:db8:1:3::1', jetzt), true)
|
||||
|
||||
const { darfAnfrage } = await import('../server/services/termine/limits.js')
|
||||
assert.equal(darfAnfrage('203.0.113.9', jetzt), true, 'der Terminplaner zählt für dieselbe IP für sich')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Fehler
|
||||
|
||||
test('Appwrite-Fehler ⇒ 503 ohne Details', async () => {
|
||||
await legeAn()
|
||||
const RAW = 'Nachbau kaputt: geheimer Rohtext'
|
||||
aw.failNext({ collection: 'portalFeatures', status: 500, message: RAW })
|
||||
const res = await api('GET', `/${SITE_KEY}`)
|
||||
assert.equal(res.status, 503)
|
||||
assert.deepEqual(res.body, NICHT_ERREICHBAR)
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), '*')
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.ok(!JSON.stringify(res.body).includes('Nachbau'))
|
||||
assert.ok(console.error.mock.calls.length > 0, 'der Fehler steht im Log, nicht in der Antwort')
|
||||
|
||||
// nichts wurde gemerkt: danach geht es wieder
|
||||
assert.equal((await lies()).status, 200)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- aktiv:false
|
||||
|
||||
test('aktiv:false bei Schalter aus, unbekanntem Schlüssel, Zustand off/ordered/disabled', async () => {
|
||||
await legeAn()
|
||||
|
||||
config.zeiten.mode = 'off'
|
||||
const aus = await lies()
|
||||
assert.equal(aus.status, 200)
|
||||
assert.deepEqual(aus.body, AKTIV_FALSE)
|
||||
assert.equal(aus.headers.get('access-control-allow-origin'), '*')
|
||||
assert.equal(aus.headers.get('cache-control'), 'public, max-age=60')
|
||||
config.zeiten.mode = 'on'
|
||||
assert.equal((await lies()).body.aktiv, true)
|
||||
|
||||
assert.deepEqual((await lies('/zzzzzzzzzzzzzzzz')).body, AKTIV_FALSE, 'unbekannter Schluessel')
|
||||
for (const kaputt of ['/ABCDABCDABCDABCD', '/abc', '/abcdabcdabcdabc1', `/${'a'.repeat(200)}`]) {
|
||||
const res = await lies(kaputt)
|
||||
assert.equal(res.status, 200, kaputt)
|
||||
assert.deepEqual(res.body, AKTIV_FALSE, kaputt)
|
||||
}
|
||||
|
||||
for (const state of ['off', 'ordered', 'disabled']) {
|
||||
await legeAn({ feature: { state, enabled: false }, zeiten: false })
|
||||
resetZeitenCacheForTests()
|
||||
const res = await lies()
|
||||
assert.equal(res.status, 200, state)
|
||||
assert.deepEqual(res.body, AKTIV_FALSE, state)
|
||||
}
|
||||
for (const state of ['setup', 'active']) {
|
||||
await legeAn({ feature: { state }, zeiten: false })
|
||||
resetZeitenCacheForTests()
|
||||
assert.equal((await lies()).body.aktiv, true, state)
|
||||
}
|
||||
})
|
||||
|
||||
test('Kunde gelöscht ⇒ aktiv:false', async () => {
|
||||
await legeAn({ kunde: null })
|
||||
assert.deepEqual((await lies()).body, AKTIV_FALSE)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Rest
|
||||
|
||||
test('unbekannter Pfad ⇒ 404 mit CORS', async () => {
|
||||
for (const pfad of ['', '/', `/${SITE_KEY}/mehr`, '/a/b/c']) {
|
||||
const res = await api('GET', pfad)
|
||||
assert.equal(res.status, 404, pfad)
|
||||
assert.deepEqual(res.body, { error: 'Nicht gefunden' }, pfad)
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), '*', pfad)
|
||||
}
|
||||
})
|
||||
|
||||
test('Kundenansicht-Cookie (Mitarbeiter im Portal eines Kunden) stört den öffentlichen Abruf nicht', async () => {
|
||||
await legeAn()
|
||||
const cookie = kundenansichtCookie()
|
||||
const res = await lies(undefined, { cookie, headers: { 'x-kundenansicht': 'b'.repeat(32) } })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.aktiv, true)
|
||||
const abgelaufen = await lies(undefined, { cookie: kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) })
|
||||
assert.equal(abgelaufen.status, 200)
|
||||
})
|
||||
|
||||
test('Reihenfolge in server/index.js: direkt nach dem Mount der News, hinter kundenansichtGuard, Termine unberührt', () => {
|
||||
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
|
||||
const guard = quelle.indexOf('app.use(kundenansichtGuard)')
|
||||
const news = quelle.indexOf("app.use('/api/public/news', publicNewsRoutes)")
|
||||
const zeiten = quelle.indexOf("app.use('/api/public/zeiten/v1', publicZeitenRoutes)")
|
||||
const termine = quelle.indexOf("app.use('/api/public/termine/v1', publicTermineRoutes)")
|
||||
assert.ok(guard >= 0 && news >= 0 && zeiten >= 0 && termine >= 0, 'alle Mounts gefunden')
|
||||
assert.equal(quelle.indexOf("app.use('/api/public/zeiten/v1'", zeiten + 1), -1, 'nur einmal eingehaengt')
|
||||
assert.ok(guard < zeiten, 'erst der Waechter der Kundenansicht')
|
||||
assert.equal(quelle.slice(news, zeiten).split('\n').length, 2, 'unmittelbar die naechste Zeile')
|
||||
assert.ok(termine < quelle.indexOf('app.use(express.json('), 'Termine bleibt vor dem globalen Parser')
|
||||
assert.match(quelle, /import publicZeitenRoutes from '\.\/routes\/publicZeiten\.js'/)
|
||||
})
|
||||
@@ -18,9 +18,9 @@ test('app.js: VIEW_HASHES, viewRefreshers, FEATURE_LABELS und APP_PARENT kennen
|
||||
assert.match(app, /VIEW_HASHES = \{[^}]*webshop: 'webshop'/)
|
||||
assert.match(app, /FEATURE_LABELS = \{[^}]*shop: 'Webshop'/)
|
||||
assert.match(app, /webshop: \(\) => import\('\/shop\.js'\)\.then\(\(m\) => m\.initShop\(document\.getElementById\('webshop-body'\)\)\)/)
|
||||
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop steht am Ende
|
||||
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop folgt, zeiten (Öffnungszeiten live) steht am Ende
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', /)
|
||||
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen' \}/)
|
||||
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' \}/)
|
||||
// die Ansicht ist vom Hash aus erreichbar: kein zweiter Schlüssel mit demselben Hash
|
||||
const hashes = app.match(/VIEW_HASHES = \{([^}]*)\}/)[1]
|
||||
assert.equal([...hashes.matchAll(/'webshop'/g)].length, 1)
|
||||
|
||||
@@ -185,18 +185,19 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
|
||||
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
|
||||
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
|
||||
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
|
||||
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
|
||||
assert.deepEqual(sf.FEATURE_LABELS, {
|
||||
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
|
||||
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
|
||||
shop: { name: 'Webshop', keine: 'keinen Webshop' },
|
||||
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
|
||||
})
|
||||
})
|
||||
|
||||
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
|
||||
const list = await sf.listCustomerFeatures('c1')
|
||||
const p = list.find((x) => x.projectId === 'pt1')
|
||||
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'zeiten'])
|
||||
assert.equal(p.features.termine.state, 'off')
|
||||
assert.equal(p.features.termine.siteKey, null)
|
||||
assert.equal(p.features.termine.ticketWoid, null)
|
||||
@@ -262,7 +263,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s
|
||||
const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
try {
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop'])
|
||||
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
|
||||
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
|
||||
const done = await sf.activateClosedTickets({ customerId: 'c1' })
|
||||
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
|
||||
|
||||
@@ -31,13 +31,14 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () =>
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('KEYS enthaelt die Katalogschluessel der Einrichtungen', () => {
|
||||
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup'])
|
||||
test('KEYS enthaelt die Katalogschluessel der Einrichtungen (News, Terminplaner, Webshop, Oeffnungszeiten live)', () => {
|
||||
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'])
|
||||
})
|
||||
|
||||
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
|
||||
assert.equal(script.catalogEntry('termineSetup'), config.termineSetup)
|
||||
assert.equal(script.catalogEntry('shopSetup'), config.shopSetup)
|
||||
assert.equal(script.catalogEntry('zeitenSetup'), config.zeitenSetup)
|
||||
assert.equal(script.catalogEntry('newsSetup'), config.newsSetup)
|
||||
assert.equal(script.catalogEntry('mailStorage'), config.mailStorage)
|
||||
assert.equal(script.catalogEntry('wartung'), config.products.wartung)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
|
||||
import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js'
|
||||
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
|
||||
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
|
||||
|
||||
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
|
||||
@@ -11,13 +12,16 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
||||
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
|
||||
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
|
||||
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs),
|
||||
* die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs);
|
||||
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES);
|
||||
* die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs),
|
||||
* die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs);
|
||||
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
|
||||
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
|
||||
* featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES).
|
||||
*/
|
||||
export function schemaKeys(collection) {
|
||||
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
|
||||
for (const script of SETUP_SCRIPTS) {
|
||||
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
|
||||
@@ -25,7 +29,9 @@ export function schemaKeys(collection) {
|
||||
if (start < 0) continue
|
||||
const block = src.slice(start, src.indexOf('\n ],', start))
|
||||
const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
|
||||
return collection === 'portalFeatures' ? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key)] : keys
|
||||
return collection === 'portalFeatures'
|
||||
? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)]
|
||||
: keys
|
||||
}
|
||||
throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// Playwright-Checks des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 26: Hinweis-Leiste, Kontakt-Leiste,
|
||||
// JSON-LD, Platzhalter) gegen den UI-Testserver (test/ui/zeitenTestServer.mjs): echte oeffentliche Schnittstelle, Nachbau von Appwrite.
|
||||
// Nicht Teil von npm test.
|
||||
// Aufruf: free -m && nice node test/ui/zeiten-embed.ui.mjs (es laeuft nur ein Browser)
|
||||
// Screenshots: /root/reports/oeffnungszeiten-ui/embed-*.png (bei Fehlern fehler-embed-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
|
||||
//
|
||||
// Seiten: Sie laufen auf srv.pageOrigin (http://localhost:PORT) und sprechen die Schnittstelle unter srv.url (http://127.0.0.1:PORT)
|
||||
// an: echte Cross-Origin-Anfragen wie auf Kundenseiten. Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (fest, srv.setzeUhr);
|
||||
// Pruefungen mit Zeitsprung installieren dieselbe Zeit im Browser (context.clock.install) und spulen mit fastForward vor.
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { startZeitenUiServer, zeitenEingabe, hinweis, FIRMA, ADRESSE } from './zeitenTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const SHOTS = '/root/reports/oeffnungszeiten-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(embed|fehler-embed)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
|
||||
|
||||
const srv = await startZeitenUiServer()
|
||||
const KEY = srv.SITE_KEY
|
||||
const API = `${srv.url}/api/public/zeiten/v1/${KEY}`
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
const nahe = (a, b, tol = 1) => Math.abs(a - b) <= tol
|
||||
const fmt = (z) => (typeof z === 'number' ? z.toFixed(1) : String(z))
|
||||
|
||||
const SKRIPT = `<script src="/embed/zeiten.js" data-site="${KEY}" data-api="${srv.url}" defer></script>`
|
||||
const STATUS = '<p>Status: <span id="status" data-webklar-zeiten data-modus="status">Ersatz-Status</span></p>'
|
||||
const lang = (n = 40) => Array.from({ length: n }, (_, i) => `<p>Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.</p>`).join('\n')
|
||||
|
||||
/** Testseite: kopf = <head>-Zusatz, stil = CSS, anfang = Anfang von <body> (z. B. Kopfzeile), inhalt = <main>. */
|
||||
function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}<p id="ende">Ende der Seite</p>`, skript = SKRIPT } = {}) {
|
||||
return `<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bäckerei Sonnenschein</title>
|
||||
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#1f2937}main{max-width:760px;margin:0 auto;padding:16px}${stil}</style>${kopf}</head>
|
||||
<body>${anfang}<main>${inhalt}</main>${skript}</body></html>`
|
||||
}
|
||||
|
||||
const KOPF_FEST = '<header id="kopf" style="position:fixed;top:0;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
|
||||
const KOPF_KLEBT = '<header id="kopf" style="position:sticky;top:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
|
||||
|
||||
const HOST = (art) => `[data-wkz-leiste="${art}"]`
|
||||
const anzahl = (page, sel) => page.locator(sel).count()
|
||||
const hoehe = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().height, sel)
|
||||
const oben = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().top, sel)
|
||||
const scrolle = (page, y) => page.evaluate((v) => window.scrollTo(0, v), y)
|
||||
const shot = async (page, name) => {
|
||||
await page.waitForTimeout(450) // die Einblendung der Leiste (0,3 s) ist fertig
|
||||
await page.screenshot({ path: `${SHOTS}/embed-${name}.png` })
|
||||
}
|
||||
|
||||
/** Wartet, bis das Skript die Platzhalter gefuellt hat (und damit in derselben Runde die Leisten gezeichnet). */
|
||||
const warteBereit = (page) => page.waitForFunction(() => { const s = document.getElementById('status'); return s && !s.textContent.includes('Ersatz') })
|
||||
|
||||
/** Wartet auf die Antwort der Schnittstelle und ein paar Bilder danach (fuer Seiten, auf denen nichts gefuellt wird). */
|
||||
async function warteAufSchnittstelle(page, aktion) {
|
||||
const antwort = page.waitForResponse((r) => r.url().includes('/api/public/zeiten/v1/'))
|
||||
await aktion()
|
||||
await antwort
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
const STATUS_OFFEN = 'Jetzt geöffnet · schließt um 18 Uhr'
|
||||
const STATUS_ZU = 'Geschlossen · öffnet morgen um 9 Uhr'
|
||||
const statusText = (page) => page.locator('#status').innerText()
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
async function check(name, fn, { eingabe, team, viewport = { width: 1280, height: 800 }, uhr, context = {}, initScript } = {}) {
|
||||
await srv.zuruecksetzen({ eingabe, team })
|
||||
if (uhr) srv.setzeUhr(uhr)
|
||||
const ctx = await browser.newContext({ viewport, ...context })
|
||||
if (uhr) await ctx.clock.install({ time: new Date(uhr) })
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
page.on('pageerror', (e) => fehler.push(e.message))
|
||||
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
if (initScript) await page.addInitScript(initScript)
|
||||
try {
|
||||
await fn(page, ctx)
|
||||
assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`)
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-embed-${name}.png`, fullPage: true }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
const INFO = hinweis('info', 'Heute frische Brezeln aus dem Ofen.')
|
||||
const HANDY = { width: 390, height: 844 }
|
||||
|
||||
// ---------------------------------------------------------------- Pruefungen
|
||||
|
||||
async function pruefungen() {
|
||||
await check('hinweisleiste_zeigt_gueltigen_hinweis', async (page) => {
|
||||
await page.goto(srv.seite('hinweis.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const host = page.locator(HOST('hinweis'))
|
||||
await host.waitFor({ state: 'attached' })
|
||||
const region = page.getByRole('region', { name: 'Hinweis' })
|
||||
assert(await region.isVisible(), 'Region "Hinweis" nicht sichtbar')
|
||||
assert((await region.innerText()).includes('Heute frische Brezeln aus dem Ofen.'), 'Text fehlt')
|
||||
eq(await region.locator('button, [role=button]').count(), 0, 'Schliessen-Knopf vorhanden')
|
||||
assert(!(await region.innerText()).includes('Laden schließt'), 'der Hinweis von morgen gilt noch nicht')
|
||||
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
|
||||
eq(farben, ['rgb(224, 242, 254)', 'rgb(12, 74, 110)'], 'Farben der Art Info')
|
||||
// erstes Kind von body, im Fluss (schiebt den Inhalt)
|
||||
assert(await page.evaluate(() => document.body.firstElementChild.hasAttribute('data-wkz-leiste')), 'Host ist nicht das erste Kind von body')
|
||||
assert(await page.evaluate(() => ['static', 'relative'].includes(getComputedStyle(document.querySelector('[data-wkz-leiste="hinweis"]')).position)), 'Host muss im Fluss stehen')
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(h > 20, `Hoehe der Leiste ${fmt(h)}`)
|
||||
assert(nahe(await oben(page, 'main'), h, 1.5), `main steht bei ${fmt(await oben(page, 'main'))} statt unter der Leiste (${fmt(h)})`)
|
||||
await shot(page, 'hinweis-desktop')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Nur heute: Laden schließt um 14 Uhr.', { von: '2026-10-06', bis: '2026-10-06' })] }) })
|
||||
|
||||
await check('hinweis_wichtig_mit_knopf', async (page) => {
|
||||
await page.goto(srv.seite('hinweis-wichtig.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const region = page.getByRole('region', { name: 'Hinweis' })
|
||||
await region.waitFor()
|
||||
assert((await region.innerText()).includes('Heute Betriebsfeier'), 'der Hinweis mit dem hoechsten Rang gewinnt')
|
||||
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
|
||||
eq(farben, ['rgb(185, 28, 28)', 'rgb(255, 255, 255)'], 'Farben der Art Wichtig')
|
||||
const knopf = region.getByRole('link', { name: 'Zur Aktion' })
|
||||
eq(await knopf.getAttribute('href'), 'https://baeckerei.example/aktion?x=1', 'href')
|
||||
eq(await knopf.getAttribute('rel'), 'noopener noreferrer', 'rel')
|
||||
const box = await knopf.boundingBox()
|
||||
assert(box.height >= 44 && box.width >= 44, `Tippflaeche ${box.width}x${box.height}`)
|
||||
// sichtbarer Fokus
|
||||
await knopf.focus()
|
||||
const fokus = await knopf.evaluate((e) => getComputedStyle(e).outlineStyle)
|
||||
assert(fokus !== 'none', 'kein sichtbarer Fokus')
|
||||
await shot(page, 'hinweis-wichtig')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Heute Betriebsfeier: wir schließen um 14 Uhr.', { knopf: { art: 'link', ziel: 'https://baeckerei.example/aktion?x=1', text: 'Zur Aktion' } })] }) })
|
||||
|
||||
await check('hinweisleiste_aus', async (page) => {
|
||||
await page.goto(srv.seite('hinweis-aus.html', seitenHtml({ anfang: KOPF_FEST })))
|
||||
await warteBereit(page) // das Skript hat gelaufen und die Platzhalter gefuellt ...
|
||||
eq(await anzahl(page, HOST('hinweis')), 0, 'Leiste trotz Team-Schalter')
|
||||
// ... und nichts an der Seite veraendert
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Kopf angefasst')
|
||||
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top angefasst')
|
||||
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nicht bei 0')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { hinweisleisteAus: true } })
|
||||
|
||||
await check('fester_header_rueckt', async (page) => {
|
||||
await page.goto(srv.seite('fest.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang()}` })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), 'important', 'Prioritaet des Inline-top')
|
||||
// scroll-padding-top um die Hoehe erhoeht
|
||||
const sp = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
|
||||
assert(nahe(sp, h, 1), `scroll-padding-top ${fmt(sp)} statt ${fmt(h)}`)
|
||||
// halb gescrollt: der Kopf folgt dem unteren Rand der Leiste
|
||||
const halb = Math.round(h / 2)
|
||||
await scrolle(page, halb)
|
||||
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
|
||||
// weiter gescrollt: wieder bei 0
|
||||
await scrolle(page, 400)
|
||||
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
|
||||
// zurueck nach oben: wieder unter der Leiste
|
||||
await scrolle(page, 0)
|
||||
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
|
||||
await shot(page, 'fester-header')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('fester_header_mit_polster_bleibt', async (page) => {
|
||||
// Die Seite haelt Platz fuer ihren festen Kopf per body-Polster frei: die Leiste steht erst darunter, der Kopf muss bleiben, wo er ist
|
||||
await page.goto(srv.seite('polster.html', seitenHtml({ anfang: KOPF_FEST, stil: 'body{padding-top:60px}' })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
assert(nahe(await oben(page, HOST('hinweis')), 60, 1), `Leiste steht bei ${fmt(await oben(page, HOST('hinweis')))} statt unter dem Polster`)
|
||||
assert(nahe(await oben(page, '#kopf'), 0, 0.5), `der Kopf wurde gerueckt (${fmt(await oben(page, '#kopf'))})`)
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Inline-top des Kopfes angefasst')
|
||||
await shot(page, 'fester-header-polster')
|
||||
await scrolle(page, 200)
|
||||
await page.waitForTimeout(100)
|
||||
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nach dem Scrollen nicht bei 0')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('klebender_header', async (page) => {
|
||||
await page.goto(srv.seite('klebt.html', seitenHtml({ anfang: KOPF_KLEBT })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '#kopf'), h, 1), `klebender Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
|
||||
const halb = Math.round(h / 2)
|
||||
await scrolle(page, halb)
|
||||
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
|
||||
await scrolle(page, 500)
|
||||
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
|
||||
await scrolle(page, 0)
|
||||
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('fester_header_versteckt_beim_scrollen', async (page) => {
|
||||
// Kopf, der per Klasse beim Scrollen wegfaehrt (top: -100px), mit dem eigenen Scroll-Skript der Seite. Solange die Hinweis-Leiste sichtbar ist,
|
||||
// rueckt das Skript ihn; ist sie weggescrollt, muss die Klasse der Seite wieder gelten (kein dauerhaftes top: 0 !important).
|
||||
const kopf = '<header id="kopf" style="position:fixed;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
|
||||
+ '<script>addEventListener("scroll",function(){document.getElementById("kopf").classList.toggle("weg",scrollY>300)},{passive:true})</script>'
|
||||
await page.goto(srv.seite('kopf-weg.html', seitenHtml({ anfang: kopf, stil: '#kopf{top:0}#kopf.weg{top:-100px}' })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))} statt unter der Leiste (${fmt(h)})`)
|
||||
// weit gescrollt: die Seite blendet den Kopf aus, das Skript haelt ihn nicht fest
|
||||
await scrolle(page, 700)
|
||||
await page.waitForFunction(() => document.getElementById('kopf').getBoundingClientRect().top < -99)
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyValue('top')), '', 'Inline-top des Kopfes nach dem Wegscrollen der Leiste')
|
||||
// zurueck nach oben: die Klasse faellt ab, der Kopf steht wieder unter der Leiste
|
||||
await scrolle(page, 0)
|
||||
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('team_navselektor', async (page) => {
|
||||
// der Schwebende hat top: 10px (nicht top: 0): nur der Selektor des Teams findet ihn
|
||||
const seite = seitenHtml({
|
||||
stil: '.schweber{position:fixed;top:10px;left:16px;z-index:100;background:#222;color:#fff;padding:8px 12px;border-radius:8px}',
|
||||
anfang: '<nav class="schweber">Menü</nav>',
|
||||
})
|
||||
await page.goto(srv.seite('nav.html', seite))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '.schweber'), 10 + h, 1), `Schweber steht bei ${fmt(await oben(page, '.schweber'))} statt ${fmt(10 + h)}`)
|
||||
await scrolle(page, 400)
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector('.schweber').getBoundingClientRect().top - 10) < 0.5)
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '.schweber' } })
|
||||
|
||||
await check('ungueltiger_selektor', async (page) => {
|
||||
await page.goto(srv.seite('selektor.html', seitenHtml({ anfang: KOPF_FEST })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leiste ist trotzdem da
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '#kopf'), h, 1), 'der feste Kopf wird auch ohne gueltigen Selektor erkannt und gerueckt')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '[[kaputt' } })
|
||||
|
||||
await check('hinweis_endet_und_stellt_wieder_her', async (page, ctx) => {
|
||||
await page.goto(srv.seite('ende.html', seitenHtml({ anfang: KOPF_FEST })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
const h = await hoehe(page, HOST('hinweis'))
|
||||
assert(nahe(await oben(page, '#kopf'), h, 1), 'Kopf unter der Leiste')
|
||||
// 23:55 Berlin: der Hinweis gilt bis 24:00 und verschwindet dann von selbst
|
||||
await ctx.clock.fastForward(6 * 60 * 1000)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'detached' })
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Inline-top des Kopfes nicht zurueckgesetzt')
|
||||
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Prioritaet nicht zurueckgesetzt')
|
||||
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top nicht zurueckgesetzt')
|
||||
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf wieder bei 0')
|
||||
assert(nahe(await oben(page, 'main'), 0, 0.5), 'Inhalt rueckt wieder nach oben')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [hinweis('info', 'Nur heute: Kuchen zum halben Preis.', { von: '2026-10-05', bis: '2026-10-05' })] }), uhr: '2026-10-05T21:55:00.000Z' })
|
||||
|
||||
await check('kontaktleiste_nur_handy', async (page) => {
|
||||
await page.goto(srv.seite('handy.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const host = page.locator(HOST('kontakt'))
|
||||
await host.waitFor({ state: 'attached' })
|
||||
const stil = await host.evaluate((e) => { const c = getComputedStyle(e); return [c.position, c.zIndex, c.bottom] })
|
||||
eq(stil, ['fixed', '40', '0px'], 'position, z-index, bottom der Kontakt-Leiste')
|
||||
const knoepfe = host.locator('a')
|
||||
eq(await knoepfe.count(), 3, 'Anzahl Knoepfe')
|
||||
eq(await knoepfe.allInnerTexts(), ['Anrufen', 'Route', 'WhatsApp'], 'Beschriftungen')
|
||||
const hrefs = await knoepfe.evaluateAll((l) => l.map((a) => a.getAttribute('href')))
|
||||
eq(hrefs[0], 'tel:+491711234567', 'tel:')
|
||||
assert(hrefs[1].startsWith('https://www.google.com/maps/dir/'), `Route: ${hrefs[1]}`)
|
||||
assert(hrefs[2].startsWith('https://wa.me/491711234567'), `WhatsApp: ${hrefs[2]}`)
|
||||
for (const a of await knoepfe.elementHandles()) {
|
||||
const b = await a.boundingBox()
|
||||
assert(b.height >= 44 && b.width >= 44, `Tippflaeche ${b.width}x${b.height}`)
|
||||
eq(await a.getAttribute('rel'), 'noopener noreferrer', 'rel')
|
||||
}
|
||||
// Abstandhalter am Ende von body in Hoehe der Leiste, scroll-padding-bottom
|
||||
const h = await hoehe(page, HOST('kontakt'))
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter')
|
||||
assert(await page.evaluate(() => document.body.lastElementChild.hasAttribute('data-wkz-abstand')), 'Abstandhalter nicht am Ende von body')
|
||||
assert(nahe(await hoehe(page, '[data-wkz-abstand]'), h, 1), 'Abstandhalter nicht so hoch wie die Leiste')
|
||||
assert(nahe(await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom) || 0), h, 1), 'scroll-padding-bottom')
|
||||
// ganz unten: der letzte Absatz liegt ueber der Leiste
|
||||
await scrolle(page, 1e6)
|
||||
await page.waitForFunction(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)
|
||||
const unten = await page.evaluate(() => document.getElementById('ende').getBoundingClientRect().bottom)
|
||||
const leisteOben = await oben(page, HOST('kontakt'))
|
||||
assert(unten <= leisteOben + 0.5, `der letzte Absatz endet bei ${fmt(unten)}, die Leiste beginnt bei ${fmt(leisteOben)}`)
|
||||
await shot(page, 'kontakt-handy')
|
||||
// auf breitem Schirm weg (und wieder da: die Abfrage wird beobachtet)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await host.waitFor({ state: 'detached' })
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter blieb stehen')
|
||||
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom), '', 'scroll-padding-bottom nicht zurueckgesetzt')
|
||||
await page.setViewportSize(HANDY)
|
||||
await host.waitFor({ state: 'attached' })
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter nach der Rueckkehr')
|
||||
}, { viewport: HANDY })
|
||||
|
||||
await check('kontaktleiste_nicht_auf_dem_desktop', async (page) => {
|
||||
await page.goto(srv.seite('desktop.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste auf dem Desktop')
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter auf dem Desktop')
|
||||
})
|
||||
|
||||
await check('kontaktleiste_aus_per_team', async (page) => {
|
||||
await page.goto(srv.seite('kontakt-aus.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste trotz Team-Schalter')
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter trotz Team-Schalter')
|
||||
}, { viewport: HANDY, team: { kontaktleisteAus: true } })
|
||||
|
||||
await check('kontaktleiste_nur_mit_daten', async (page) => {
|
||||
await page.goto(srv.seite('kontakt-nur-route.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const host = page.locator(HOST('kontakt'))
|
||||
await host.waitFor({ state: 'attached' })
|
||||
eq(await host.locator('a').allInnerTexts(), ['Route'], 'nur Route (kein Telefon, kein WhatsApp)')
|
||||
}, { viewport: HANDY, eingabe: zeitenEingabe({ kontakt: { an: true, telefon: '', adresse: ADRESSE } }) })
|
||||
|
||||
await check('kontaktleiste_ohne_daten', async (page) => {
|
||||
srv.ueberschreibe((a) => ({ ...a, kontakt: null })) // der Browser hat die Antwort eines frueheren Abrufs sonst 60 Sekunden im Zwischenspeicher
|
||||
await page.goto(srv.seite('kontakt-leer.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
eq(await anzahl(page, HOST('kontakt')), 0, 'Leiste ohne Daten')
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter ohne Daten')
|
||||
}, { viewport: HANDY })
|
||||
|
||||
await check('eigene_anrufleiste', async (page) => {
|
||||
const eigene = '<div id="eigene" style="position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #ccc;z-index:50"><a href="tel:+496301123456" style="display:block;line-height:56px;text-align:center">Jetzt anrufen</a></div>'
|
||||
await page.goto(srv.seite('eigene-leiste.html', seitenHtml({ anfang: eigene })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gezeichnet ...
|
||||
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste neben einer eigenen Anruf-Leiste')
|
||||
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter neben einer eigenen Anruf-Leiste')
|
||||
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('anruf_im_kopf_stoert_nicht', async (page) => {
|
||||
// ein tel:-Link in einem festen Kopf OBEN ist keine Anruf-Leiste unten
|
||||
const kopf = '<header style="position:fixed;top:0;left:0;right:0;height:48px;background:#222;z-index:100"><a href="tel:+496301123456" style="color:#fff;line-height:48px">06301 123456</a></header>'
|
||||
await page.goto(srv.seite('anruf-kopf.html', seitenHtml({ anfang: kopf })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
|
||||
}, { viewport: HANDY })
|
||||
|
||||
await check('unsichere_daten_werden_nicht_verwendet', async (page) => {
|
||||
srv.ueberschreibe((a) => ({
|
||||
...a,
|
||||
hinweise: [{ art: 'wichtig', text: '<img src=x onerror="window.__x=1">Achtung', knopf: { art: 'link', ziel: 'javascript:window.__y=1', text: 'Los' }, ab: 0, bis: 8.64e15, rang: 4 }],
|
||||
kontakt: { telefon: '491711234567', routeUrl: 'javascript:window.__z=1', whatsappUrl: 'http://wa.me/491711234567' },
|
||||
}))
|
||||
await page.goto(srv.seite('boese.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const region = page.getByRole('region', { name: 'Hinweis' })
|
||||
await region.waitFor()
|
||||
assert((await region.innerText()).includes('<img src=x onerror="window.__x=1">Achtung'), 'Text nicht als Text gezeigt')
|
||||
eq(await region.locator('img').count(), 0, 'img eingeschleust')
|
||||
eq(await region.locator('a').count(), 0, 'javascript:-Knopf wurde ein Link')
|
||||
const host = page.locator(HOST('kontakt'))
|
||||
await host.waitFor({ state: 'attached' })
|
||||
eq(await host.locator('a').allInnerTexts(), ['Anrufen'], 'nur der Anruf-Knopf bleibt (Route: javascript:, WhatsApp: http:)')
|
||||
eq(await page.evaluate(() => [window.__x, window.__y, window.__z]), [undefined, undefined, undefined], 'Skript ausgefuehrt')
|
||||
}, { viewport: HANDY })
|
||||
|
||||
await check('jsonld_ergaenzt', async (page) => {
|
||||
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, telephone: '+49 171 1234567', address: ADRESSE, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
|
||||
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA, url: 'https://baeckerei.example/' }
|
||||
const kaputt = '{ "@type": "Bakery", kaputt'
|
||||
const kopf = `<script type="application/ld+json">${JSON.stringify(web)}</script><script type="application/ld+json">${kaputt}</script><script type="application/ld+json">${JSON.stringify(bekannt)}</script>`
|
||||
await page.goto(srv.seite('jsonld.html', seitenHtml({ kopf })))
|
||||
await warteBereit(page)
|
||||
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
|
||||
eq(texte.length, 3, 'Anzahl der JSON-LD-Bloecke (kein zweiter Block fuer ein vorhandenes Unternehmen)')
|
||||
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
|
||||
eq(texte[1], kaputt, 'unparsbarer Block veraendert')
|
||||
const api = await (await fetch(API)).json()
|
||||
const neu = JSON.parse(texte[2])
|
||||
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten nicht aus der Schnittstelle')
|
||||
eq(neu.specialOpeningHoursSpecification ?? [], api.jsonld.specialOpeningHoursSpecification, 'Sonderzeiten')
|
||||
assert(neu.openingHoursSpecification.length === 6, `Mo-Sa = 6 Eintraege (statt ${neu.openingHoursSpecification.length})`)
|
||||
assert(!JSON.stringify(neu).includes('"Sunday"'), 'alter Sonntag-Eintrag blieb stehen')
|
||||
eq([neu['@type'], neu.name, neu.telephone, neu.address], ['Bakery', FIRMA, '+49 171 1234567', ADRESSE], 'uebrige Felder des Blocks')
|
||||
})
|
||||
|
||||
await check('jsonld_neuer_block', async (page) => {
|
||||
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA }
|
||||
await page.goto(srv.seite('jsonld-neu.html', seitenHtml({ kopf: `<script type="application/ld+json">${JSON.stringify(web)}</script>` })))
|
||||
await warteBereit(page)
|
||||
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
|
||||
eq(texte.length, 2, 'Anzahl der Bloecke (WebSite + neuer Block)')
|
||||
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
|
||||
const api = await (await fetch(API)).json()
|
||||
const neu = JSON.parse(texte[1])
|
||||
eq(neu['@type'], 'LocalBusiness', '@type')
|
||||
eq(neu['@context'], api.jsonld.neuerBlock['@context'], '@context kommt vom Server')
|
||||
eq([neu.name, neu.address], [FIRMA, ADRESSE], 'name/address')
|
||||
eq(neu.url, `${srv.pageOrigin}/`, 'url')
|
||||
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten')
|
||||
})
|
||||
|
||||
await check('jsonld_ohne_zeiten_bleibt_unberuehrt', async (page) => {
|
||||
srv.ueberschreibe((a) => ({ ...a, jsonld: null }))
|
||||
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
|
||||
const text = JSON.stringify(bekannt)
|
||||
await page.goto(srv.seite('jsonld-null.html', seitenHtml({ kopf: `<script type="application/ld+json">${text}</script>` })))
|
||||
await warteBereit(page)
|
||||
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
|
||||
eq(texte, [text], 'ohne Zeiten bleibt das JSON-LD wie es ist (und es kommt kein Block dazu)')
|
||||
})
|
||||
|
||||
await check('aktiv_false_ersatz_bleibt', async (page) => {
|
||||
srv.setzeFunktion('disabled')
|
||||
const ld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [] })
|
||||
const html = seitenHtml({
|
||||
kopf: `<script type="application/ld+json">${ld}</script>`,
|
||||
inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="tabelle"><table><caption>Öffnungszeiten</caption><tr><th>Montag</th><td>09:00–18:00</td></tr></table></div>${lang(5)}`,
|
||||
})
|
||||
await warteAufSchnittstelle(page, () => page.goto(srv.seite('aus.html', html)))
|
||||
assert((await page.locator('#ersatz').innerText()).includes('09:00–18:00'), 'Ersatz-HTML ist weg')
|
||||
eq(await anzahl(page, '#ersatz table'), 1, 'Ersatz-Tabelle')
|
||||
eq(await anzahl(page, '#ersatz .wkz-tabelle'), 0, 'Tabelle des Skripts trotz aktiv: false')
|
||||
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste trotz aktiv: false')
|
||||
eq(await page.evaluate(() => document.querySelector('script[type="application/ld+json"]').textContent), ld, 'JSON-LD veraendert')
|
||||
eq(await anzahl(page, 'script[type="application/ld+json"]'), 1, 'neuer JSON-LD-Block trotz aktiv: false')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('spaeter_platzhalter_wird_gefuellt', async (page) => {
|
||||
// Einzelseiten-App: der Platzhalter kommt erst nach einem Seitenwechsel auf die Seite (der erste Abruf ist dann laengst durch)
|
||||
const html = seitenHtml({ inhalt: `<div id="spa"></div>${lang(5)}`, skript: SKRIPT })
|
||||
await page.goto(srv.seite('spa.html', html))
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gelaufen, es gibt keinen Platzhalter
|
||||
await page.evaluate(() => {
|
||||
const d = document.createElement('div')
|
||||
d.id = 'neu'
|
||||
d.setAttribute('data-webklar-zeiten', '')
|
||||
d.setAttribute('data-modus', 'status')
|
||||
d.textContent = 'Ersatz-Status'
|
||||
document.getElementById('spa').appendChild(d)
|
||||
})
|
||||
await page.waitForFunction(() => { const s = document.getElementById('neu'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
|
||||
assert((await page.locator('#neu').innerText()).includes('geöffnet'), `Status: ${await page.locator('#neu').innerText()}`)
|
||||
// ein zweiter Seitenwechsel: der alte Knoten geht, ein neuer kommt
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('neu').remove()
|
||||
const d = document.createElement('span')
|
||||
d.id = 'neu2'
|
||||
d.setAttribute('data-webklar-zeiten', '')
|
||||
d.setAttribute('data-modus', 'badge')
|
||||
d.textContent = 'Ersatz-Badge'
|
||||
document.getElementById('spa').appendChild(d)
|
||||
})
|
||||
await page.waitForFunction(() => { const s = document.getElementById('neu2'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
|
||||
eq(await page.locator('#neu2').innerText(), 'Geöffnet', 'Badge')
|
||||
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('wieder_eingehaengter_platzhalter_wird_neu_gezeichnet', async (page) => {
|
||||
await page.goto(srv.seite('spa-zurueck.html', seitenHtml({ inhalt: `<div id="spa"></div>${STATUS}${lang(5)}` })))
|
||||
await warteBereit(page)
|
||||
// der Knoten verlaesst die Seite, ein anderer Platzhalter loest ein Zeichnen aus (der alte wird dabei vergessen) ...
|
||||
await page.evaluate(() => { window.__alt = document.getElementById('status'); window.__alt.remove() })
|
||||
await page.evaluate(() => {
|
||||
const d = document.createElement('div')
|
||||
d.id = 'anderer'
|
||||
d.setAttribute('data-webklar-zeiten', '')
|
||||
d.setAttribute('data-modus', 'status')
|
||||
d.textContent = 'Ersatz-Status'
|
||||
document.getElementById('spa').appendChild(d)
|
||||
})
|
||||
await page.waitForFunction(() => { const s = document.getElementById('anderer'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
|
||||
// ... und kommt zurueck, mit dem Inhalt, den die App ihm gegeben hat
|
||||
await page.evaluate(() => { window.__alt.textContent = 'Ersatz-Status'; document.getElementById('spa').appendChild(window.__alt) })
|
||||
await page.waitForFunction(() => !document.getElementById('status').textContent.includes('Ersatz'), null, { timeout: 3000 })
|
||||
assert((await statusText(page)).includes('geöffnet'), `Status: ${await statusText(page)}`)
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('unbekannter_plan', async (page) => {
|
||||
const meldungen = []
|
||||
page.on('console', (m) => { if (m.type() === 'warning') meldungen.push(m.text()) })
|
||||
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status" data-plan="gibtsnicht">Ersatz-Status</div>${lang(5)}` })
|
||||
await page.goto(srv.seite('plan.html', html))
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leisten haengen nicht am Plan
|
||||
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML bei unbekanntem Plan')
|
||||
assert(meldungen.some((m) => m.includes('gibtsnicht')), `Konsole-Warnung fehlt: ${JSON.stringify(meldungen)}`)
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('kein_storage', async (page) => {
|
||||
await page.goto(srv.seite('storage.html', seitenHtml({ anfang: KOPF_FEST })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
|
||||
const s = await page.evaluate(async () => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length, db: (await indexedDB.databases()).length }))
|
||||
eq(s, { c: '', l: 0, s: 0, db: 0 }, 'Cookies/Storage')
|
||||
eq(await page.context().cookies(), [], 'Cookies im Browser')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), viewport: HANDY })
|
||||
|
||||
await check('doppelt_geladen', async (page) => {
|
||||
const anfragen = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
|
||||
await page.goto(srv.seite('doppelt.html', seitenHtml({ skript: `${SKRIPT}${SKRIPT}` })))
|
||||
await warteBereit(page)
|
||||
await page.waitForTimeout(300)
|
||||
eq(await anzahl(page, HOST('hinweis')), 1, 'Hinweis-Leisten')
|
||||
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
|
||||
eq(anfragen.length, 1, 'Abrufe der Schnittstelle')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
|
||||
|
||||
await check('reduzierte_bewegung', async (page) => {
|
||||
await page.goto(srv.seite('ruhig.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const region = page.getByRole('region', { name: 'Hinweis' })
|
||||
await region.waitFor()
|
||||
eq(await region.evaluate((e) => getComputedStyle(e).animationName), 'none', 'Animation trotz prefers-reduced-motion')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'reduce' } })
|
||||
|
||||
await check('normale_bewegung_hat_einblendung', async (page) => {
|
||||
await page.goto(srv.seite('bewegt.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
const region = page.getByRole('region', { name: 'Hinweis' })
|
||||
await region.waitFor()
|
||||
assert((await region.evaluate((e) => getComputedStyle(e).animationName)) !== 'none', 'ohne Praeferenz soll die Leiste einblenden')
|
||||
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'no-preference' } })
|
||||
|
||||
await check('status_wechselt_um_18_uhr', async (page, ctx) => {
|
||||
await page.goto(srv.seite('status.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
eq(await statusText(page), STATUS_OFFEN, 'Status um 17:55')
|
||||
await ctx.clock.fastForward(6 * 60 * 1000) // 18:01
|
||||
await page.waitForFunction((text) => document.getElementById('status').textContent.trim() === text, STATUS_ZU)
|
||||
eq(await statusText(page), STATUS_ZU, 'Status nach 18 Uhr')
|
||||
}, { uhr: '2026-10-05T15:55:00.000Z' })
|
||||
|
||||
await check('neu_laden_nach_zehn_minuten', async (page, ctx) => {
|
||||
const anfragen = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
|
||||
await page.goto(srv.seite('neu-laden.html', seitenHtml()))
|
||||
await warteBereit(page)
|
||||
eq(anfragen.length, 1, 'ein Abruf beim Laden')
|
||||
eq(await anzahl(page, HOST('hinweis')), 0, 'noch kein Hinweis')
|
||||
// das Team stellt einen Hinweis ein; die Server-Uhr steht auf dem neuen Zeitpunkt
|
||||
await srv.speichere(zeitenEingabe({ hinweise: [INFO] }))
|
||||
srv.setzeUhr('2026-10-05T16:06:00.000Z')
|
||||
await ctx.clock.fastForward(11 * 60 * 1000)
|
||||
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
|
||||
assert(anfragen.length === 2, `zweiter Abruf nach 10 Minuten (Abrufe: ${anfragen.length})`)
|
||||
}, { uhr: '2026-10-05T15:55:00.000Z' })
|
||||
|
||||
await check('fetch_wirft_sofort', async (page) => {
|
||||
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status">Ersatz-Status</div>${lang(3)}` })
|
||||
await page.goto(srv.seite('fetch-wirft.html', html))
|
||||
await page.waitForTimeout(500)
|
||||
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML')
|
||||
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste ohne Daten')
|
||||
}, { initScript: () => { window.fetch = () => { throw new TypeError('fetch kaputt') } } })
|
||||
|
||||
await check('screenshots_handy', async (page) => {
|
||||
await page.goto(srv.seite('shot.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang(12)}` })))
|
||||
await warteBereit(page)
|
||||
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
|
||||
await page.waitForTimeout(400)
|
||||
await shot(page, 'handy-hinweis-und-kontakt')
|
||||
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [hinweis('urlaub', 'Betriebsurlaub vom 12. bis 16. Oktober – ab dem 17. sind wir wieder für Euch da.', { knopf: { art: 'anruf', ziel: '06301 1234', text: 'Anrufen' } })] }) })
|
||||
}
|
||||
|
||||
browser = await chromium.launch()
|
||||
try {
|
||||
await pruefungen()
|
||||
} finally {
|
||||
await browser.close()
|
||||
await srv.stop()
|
||||
}
|
||||
|
||||
// Nur der Testserver (und kein Dritter) wurde angefragt
|
||||
const erlaubt = new Set([new URL(srv.url).host, new URL(srv.pageOrigin).host])
|
||||
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
|
||||
if (fremd.length) {
|
||||
results.push(['kein_fremder_host', false])
|
||||
console.log(`FAIL kein_fremder_host: ${fremd.join(', ')}`)
|
||||
} else {
|
||||
results.push(['kein_fremder_host', true])
|
||||
console.log('ok kein_fremder_host')
|
||||
}
|
||||
|
||||
const failed = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - failed.length}/${results.length} Pruefungen bestanden`)
|
||||
process.exit(failed.length ? 1 : 0)
|
||||
@@ -0,0 +1,526 @@
|
||||
// Playwright-Checks der Portal-Ansicht Öffnungszeiten live (public/zeiten*.js, Kachel in funktionen.js, zeiten.css) – Aufgabe 30:
|
||||
// Kachel, Einschalten, leerer Editor, Speichern mit Konflikt, Hinweis-Vorschlag, Handy (390 px) und Tastatur. Nicht Teil von npm test.
|
||||
// Aufruf: free -m; nice node test/ui/zeiten-portal.ui.mjs (es läuft nur ein Browser)
|
||||
// Screenshots: /root/reports/zeiten-portal-ui/portal-*.png (bei Fehlern fehler-portal-<Prüfung>.png). Exit-Code 1, wenn eine Prüfung scheitert.
|
||||
//
|
||||
// Alle Prüfungen sind "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten (wie test/ui/termine-portal.ui.mjs und
|
||||
// funktionen.ui.mjs); der Server liefert nur dashboard.html und die Dateien aus public/. Nichts verlässt den Rechner.
|
||||
// NUR=<Teil des Namens> führt nur passende Prüfungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
|
||||
// Uhr: Browser steht auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
|
||||
import http from 'node:http'
|
||||
import { mkdir, readdir, readFile, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
const SHOTS = '/root/reports/zeiten-portal-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Läufe
|
||||
|
||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x')
|
||||
try {
|
||||
const datei = path.join(PUBLIC, url.pathname === '/' ? 'dashboard.html' : url.pathname)
|
||||
if (!datei.startsWith(PUBLIC)) throw new Error('außerhalb')
|
||||
const daten = await readFile(datei)
|
||||
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
|
||||
res.end(daten)
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end('nicht gefunden')
|
||||
}
|
||||
})
|
||||
await new Promise((fertig) => { server.listen(0, '127.0.0.1', fertig) })
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
|
||||
const HEUTE = '2026-10-05'
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
const tagPlus = (tag, n) => new Date(Date.parse(`${tag}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10)
|
||||
|
||||
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhöhe (höchstens 3600 px). */
|
||||
async function shot(page, name) {
|
||||
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
|
||||
const fenster = page.viewportSize()
|
||||
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
|
||||
await page.setViewportSize({ width: fenster.width, height: hoehe })
|
||||
await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
|
||||
await page.setViewportSize(fenster)
|
||||
}
|
||||
/** Kein Streifen rechts: die Seite passt in die Breite. */
|
||||
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
// ---------------------------------------------------------------- Fixture
|
||||
|
||||
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
const woche = (zeiten = {}) => Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, zeiten[n] || { modus: 'zu', fenster: [] }]))
|
||||
const werktage = () => woche({
|
||||
1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] },
|
||||
2: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
|
||||
3: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
|
||||
4: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
|
||||
5: { modus: 'zeiten', fenster: [['09:00', '16:00']] },
|
||||
6: { modus: 'zeiten', fenster: [['09:00', '12:00']] },
|
||||
})
|
||||
const PLAN = 'p_haupt001'
|
||||
const KONTAKT = { an: true, telefon: '496301123456', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: { nummer: '4917112345678', starttext: 'Hallo, ich habe eine Frage zu euren Öffnungszeiten.', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }
|
||||
const FEIERTAGE = [
|
||||
{ tag: '2026-11-01', name: 'Allerheiligen', art: 'feiertag' },
|
||||
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
|
||||
{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' },
|
||||
{ tag: '2026-12-31', name: 'Silvester', art: 'pruefen' },
|
||||
]
|
||||
const gespeicherteDaten = (extra = {}) => ({
|
||||
land: 'RP',
|
||||
plaene: [{ id: PLAN, kurz: 'oeffnungszeiten', name: 'Öffnungszeiten', haupt: true, tage: werktage() }],
|
||||
feiertage: {},
|
||||
ausnahmen: [],
|
||||
hinweise: [],
|
||||
kontakt: KONTAKT,
|
||||
...extra,
|
||||
})
|
||||
const googleTextAus = (daten) => {
|
||||
const tage = daten.plaene[0].tage
|
||||
const text = (t) => (t.modus === 'zeiten' ? t.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ') : t.modus === 'rund' ? 'rund um die Uhr' : t.modus === 'vereinbarung' ? 'nach Vereinbarung' : 'geschlossen')
|
||||
return WOCHENTAGE.map((name, i) => `${name}: ${text(tage[i + 1])}`).join('\n')
|
||||
}
|
||||
|
||||
const FX_CONSENT = { version: 'fx-1', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen.', haken: true }] }
|
||||
|
||||
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/zeiten liefert. */
|
||||
function neueFixture(o = {}) {
|
||||
const fx = {
|
||||
state: 'active',
|
||||
inklusive: true,
|
||||
grund: null,
|
||||
zeitenLeer: false,
|
||||
daten: gespeicherteDaten(),
|
||||
version: 3,
|
||||
konflikt: false, // das nächste PUT antwortet 409 (jemand hat inzwischen gespeichert)
|
||||
aufrufe: [],
|
||||
...o,
|
||||
}
|
||||
fx.angebot = () => ({
|
||||
verfuegbar: true,
|
||||
inklusive: fx.inklusive,
|
||||
grund: fx.grund,
|
||||
preis: '30 €',
|
||||
einschalten: { consent: FX_CONSENT, leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
|
||||
belehrung: 'Widerrufsbelehrung der Fixture.',
|
||||
})
|
||||
fx.feature = () => ({
|
||||
state: fx.state, siteKey: 'abcdabcdabcdabcd', ticketWoid: fx.state === 'off' ? null : '1234', orderedAt: null, activatedAt: null, invoiceUrl: null,
|
||||
disabledReason: null, disabledAt: null, loeschungAm: null, unlockedBy: fx.state === 'off' ? null : 'abo', aboEndeAt: null, einbau: fx.state === 'setup' ? 'angestossen' : null,
|
||||
})
|
||||
fx.vorschau = () => (fx.daten ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : null)
|
||||
fx.liste = () => ({
|
||||
projects: [{
|
||||
projectId: 'p1', projectName: 'Bäckerei Sonnenschein', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' }, zeiten: fx.feature() },
|
||||
zeitenStatus: fx.state === 'active' && fx.daten ? fx.vorschau() : null, zeitenLeer: fx.zeitenLeer,
|
||||
}],
|
||||
features: { zeiten: { consent: FX_CONSENT, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } } },
|
||||
ustHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
|
||||
zeiten: fx.angebot(),
|
||||
})
|
||||
fx.ansicht = () => ({
|
||||
state: fx.state, daten: fx.daten, version: fx.version, kundenansicht: false, vorschau: fx.vorschau(), feiertage: FEIERTAGE, googleText: fx.daten ? googleTextAus(fx.daten) : '',
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
/** Die übrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
|
||||
function portalHilfe(p, json) {
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'berta@baeckerei.example' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Sonnenschein', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
|
||||
if (p === '/api/chat/unread-count') return json({ unread: 0 })
|
||||
return json({})
|
||||
}
|
||||
|
||||
/** Antworten der Fixture auf /api/funktionen und /api/zeiten (Teil von page.route). */
|
||||
function fixtureAntwort(fx, req, url, json) {
|
||||
const p = url.pathname
|
||||
const m = req.method()
|
||||
if (p === '/api/funktionen') return json(fx.liste())
|
||||
if (p === '/api/funktionen/zeiten/einschalten' && m === 'POST') {
|
||||
fx.aufrufe.push(['einschalten', req.postDataJSON()])
|
||||
fx.state = 'setup'
|
||||
fx.zeitenLeer = true
|
||||
fx.daten = null
|
||||
fx.version = 0
|
||||
return json({ feature: { state: 'setup' }, einbau: 'angestossen' })
|
||||
}
|
||||
if (p === '/api/zeiten/p1' && m === 'GET') {
|
||||
fx.aufrufe.push(['get'])
|
||||
return json(fx.ansicht())
|
||||
}
|
||||
if (p === '/api/zeiten/p1' && m === 'PUT') {
|
||||
const body = req.postDataJSON()
|
||||
fx.aufrufe.push(['put', body])
|
||||
if (fx.konflikt) {
|
||||
// jemand anders hat inzwischen gespeichert: neuer Stand, neue Version
|
||||
fx.konflikt = false
|
||||
fx.version += 1
|
||||
fx.daten = { ...fx.daten, plaene: [{ ...fx.daten.plaene[0], name: 'Öffnungszeiten (inzwischen geändert)' }] }
|
||||
return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
|
||||
}
|
||||
if (body.version !== fx.version) return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
|
||||
const erstes = fx.daten === null
|
||||
fx.daten = { ...body.daten, kontakt: body.daten.kontakt || { an: false, telefon: '', adresse: '', whatsapp: null } }
|
||||
fx.version += 1
|
||||
if (erstes) fx.zeitenLeer = false
|
||||
return json(fx.ansicht())
|
||||
}
|
||||
if (p === '/api/zeiten/p1/vorschau' && m === 'GET') {
|
||||
const zeit = url.searchParams.get('zeit') || ''
|
||||
fx.aufrufe.push(['vorschau', zeit])
|
||||
if (!fx.daten) return json({ error: 'Bitte speichere zuerst deine Öffnungszeiten.', code: 'keine_zeiten' }, 409)
|
||||
const wann = new Date(`${zeit}:00Z`)
|
||||
const stunde = wann.getUTCHours()
|
||||
const sonntag = wann.getUTCDay() === 0
|
||||
if (sonntag) return json({ offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
|
||||
return json(stunde >= 9 && stunde < 18 ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
|
||||
}
|
||||
return json({})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf der Prüfungen
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
/** Eine Prüfung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture()). */
|
||||
async function check(name, fn, { context = {}, fixture = neueFixture() } = {}) {
|
||||
if (process.env.NUR && !name.includes(process.env.NUR)) return
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: 'de-DE', timezoneId: 'Europe/Berlin', ...context })
|
||||
await ctx.clock.setFixedTime(JETZT)
|
||||
ctx.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
ctx.on('page', (seite) => {
|
||||
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
})
|
||||
await ctx.route('**/api/**', async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (url.pathname.startsWith('/api/funktionen') || url.pathname.startsWith('/api/zeiten')) return fixtureAntwort(fixture, req, url, json)
|
||||
return portalHilfe(url.pathname, json)
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
page.fx = fixture
|
||||
try {
|
||||
await fn(page, ctx, fixture)
|
||||
assert(fehler.length === 0, fehler.join(' | '))
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
if (process.env.STACK) console.log(e.stack)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
const oeffne = async (page, hash) => {
|
||||
await page.goto(`${BASE}/dashboard.html${hash}`)
|
||||
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
|
||||
}
|
||||
/** Ansicht #zeiten öffnen und warten, bis der Editor (mit Abschnitten) da ist. */
|
||||
const oeffneEditor = async (page) => {
|
||||
await oeffne(page, '#zeiten')
|
||||
await page.locator('#view-zeiten.active').waitFor()
|
||||
await page.locator('#zeiten-body .zt-editor [data-abschnitt="plaene"]').waitFor()
|
||||
}
|
||||
const editor = (page) => page.locator('#zeiten-body .zt-editor')
|
||||
const abschnitt = (page, key) => page.locator(`#zeiten-body [data-abschnitt="${key}"]`)
|
||||
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: wie die Relaunch-Station („Hinweis-Leiste anlegen“)
|
||||
const meldung = (page) => page.locator('#zeiten-body [data-meldung="editor"]')
|
||||
|
||||
async function pruefungen() {
|
||||
// ---------------------------------------------------------------- Kachel in Funktionen
|
||||
await check('fx_kachel_zustaende', async (page, _ctx, fx) => {
|
||||
const faelle = [
|
||||
['off, im Abo enthalten', { state: 'off', inklusive: true, zeitenLeer: true, daten: null }, 'Aus', /Im Wartungs-Abo enthalten\./, 'Einschalten'],
|
||||
['off, 30 €', { state: 'off', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Aus', /30 € einmalig/, 'Öffnungszeiten einrichten · 30 €'],
|
||||
['off, Abo noch nicht aktiv', { state: 'off', inklusive: false, grund: 'nicht_aktiv', zeitenLeer: true, daten: null }, 'Aus', /einschaltbar, sobald dein Abo aktiv ist/, null],
|
||||
['off, nicht prüfbar', { state: 'off', inklusive: null, grund: null, zeitenLeer: true, daten: null }, 'Aus', /Gerade nicht prüfbar – bitte später erneut\./, null],
|
||||
['Zahlung offen', { state: 'ordered', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Zahlung offen', /Zahlung wird bestätigt/, 'Status ansehen'],
|
||||
['In Einrichtung, leer', { state: 'setup', zeitenLeer: true, daten: null }, 'In Einrichtung', /Trag jetzt deine Zeiten ein/, 'Zeiten bearbeiten'],
|
||||
['Aktiv mit Status', { state: 'active' }, 'Aktiv', /Jetzt geöffnet · schließt um 18 Uhr/, 'Zeiten bearbeiten'],
|
||||
['Abgeschaltet', { state: 'disabled', zeitenLeer: true, daten: null }, 'Abgeschaltet', /abgeschaltet/, 'Chat öffnen'],
|
||||
]
|
||||
for (const [name, zustand, pille, text, cta] of faelle) {
|
||||
Object.assign(fx, { state: 'active', inklusive: true, grund: null, zeitenLeer: false, daten: gespeicherteDaten() }, zustand)
|
||||
await page.goto('about:blank')
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="zeiten"]')
|
||||
await kachel.waitFor()
|
||||
eq((await kachel.locator('.fn-pill').innerText()).trim(), pille, `${name}: Pille`)
|
||||
assert(text.test(await kachel.innerText()), `${name}: Text fehlt: ${text} in „${(await kachel.innerText()).replace(/\n/g, ' | ')}“`)
|
||||
if (cta) eq((await kachel.locator('.fn-tile-cta').first().innerText()).trim(), cta, `${name}: Knopf`)
|
||||
else eq(await kachel.locator('.fn-tile-cta').count(), 0, `${name}: kein Knopf`)
|
||||
assert(await passtInBreite(page), `${name}: waagerechter Streifen`)
|
||||
}
|
||||
await shot(page, 'kachel-abgeschaltet')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Einschalten
|
||||
await check('fx_einschalten', async (page, _ctx, fx) => {
|
||||
await oeffne(page, '#funktionen')
|
||||
await knopf(page.locator('[data-fn-tile="zeiten"]'), 'Einschalten').click()
|
||||
const seite = page.locator('[data-variante="einschalten"]')
|
||||
await seite.waitFor()
|
||||
const einschalten = knopf(seite, 'Einschalten')
|
||||
assert(await einschalten.isDisabled(), 'ohne Haken gesperrt')
|
||||
await shot(page, 'einschalten')
|
||||
await seite.locator('input[type="checkbox"]').check()
|
||||
assert(await einschalten.isEnabled(), 'der Haken gibt den Knopf frei')
|
||||
await einschalten.click()
|
||||
await page.locator('.zt-status[data-state="setup"]').waitFor()
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'einschalten'), [['einschalten', { projectId: 'p1', zustimmung: { version: 'fx-1', haken: ['leistung'] } }]], 'POST Einschalten')
|
||||
assert((await page.locator('.zt-status [data-danke]').innerText()).includes('Öffnungszeiten live ist eingeschaltet.'), 'Danke-Satz')
|
||||
await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').waitFor()
|
||||
assert((await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').innerText()).includes('Trag deine Öffnungszeiten ein'), 'der Editor wartet auf die ersten Zeiten')
|
||||
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
|
||||
assert(await passtInBreite(page), 'waagerechter Streifen')
|
||||
await shot(page, 'einschalten-danach')
|
||||
}, { fixture: neueFixture({ state: 'off', inklusive: true, zeitenLeer: true, daten: null, version: 0 }) })
|
||||
|
||||
// ---------------------------------------------------------------- Leerer Editor, erstes Speichern
|
||||
await check('fx_editor_leer', async (page, _ctx, fx) => {
|
||||
await oeffneEditor(page)
|
||||
assert((await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'Statuskarte fordert die Zeiten')
|
||||
assert((await editor(page).innerText()).includes('Trag deine Öffnungszeiten ein'), 'Editor: Trag deine Öffnungszeiten ein')
|
||||
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
|
||||
assert((await page.locator('#zeiten-body [data-kurz]').innerText()).trim() === 'oeffnungszeiten', 'Kurzname oeffnungszeiten')
|
||||
for (const key of ['feiertage', 'ausnahmen', 'hinweise', 'kontakt', 'google']) eq(await abschnitt(page, key).count(), 0, `${key}: erst nach dem ersten Speichern`)
|
||||
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).toLowerCase().includes('noch keine öffnungszeiten gespeichert'), 'Vorschau: noch nichts gespeichert')
|
||||
// Regler vor dem ersten Speichern: ein Hinweis, kein Fehler
|
||||
await page.locator('[data-regler="zeit"]').fill('11:00')
|
||||
await page.locator('[data-vorschau="test"]', { hasText: 'Speichere zuerst deine Öffnungszeiten' }).waitFor()
|
||||
eq(await page.locator('[data-vorschau="test"]').getAttribute('role'), null, 'der Hinweis ist kein Alarm')
|
||||
await shot(page, 'editor-leer')
|
||||
// Montag eintragen und speichern
|
||||
const montag = page.locator('details[data-plan] [data-tag="1"]')
|
||||
await montag.locator('input[type="checkbox"]').check()
|
||||
const zeiten = montag.locator('input[type="time"]')
|
||||
await zeiten.nth(0).fill('09:00')
|
||||
await zeiten.nth(1).fill('12:30')
|
||||
await zeiten.nth(2).fill('13:30')
|
||||
await zeiten.nth(3).fill('18:00')
|
||||
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
|
||||
await abschnitt(page, 'feiertage').waitFor()
|
||||
for (const key of ['ausnahmen', 'hinweise', 'kontakt', 'google']) await abschnitt(page, key).waitFor()
|
||||
const put = fx.aufrufe.find((a) => a[0] === 'put')[1]
|
||||
eq(put.version, 0, 'erstes PUT mit version 0')
|
||||
eq(put.daten.plaene[0].tage['1'], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }, 'Montag gespeichert')
|
||||
assert(!(await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'der Kopf kennt die Zeiten jetzt')
|
||||
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).includes('Jetzt geöffnet'), 'Vorschau aus der Antwort')
|
||||
await shot(page, 'editor-gespeichert')
|
||||
}, { fixture: neueFixture({ state: 'active', zeitenLeer: true, daten: null, version: 0 }) })
|
||||
|
||||
// ---------------------------------------------------------------- Speichern mit Konflikt
|
||||
await check('fx_speichern_konflikt', async (page, _ctx, fx) => {
|
||||
await oeffneEditor(page)
|
||||
fx.konflikt = true
|
||||
const name = page.locator('details[data-plan] input[type="text"]').first()
|
||||
await name.fill('Öffnungszeiten neu')
|
||||
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
|
||||
const box = page.locator('#zeiten-body .zt-editor [role="alert"]', { hasText: 'Inzwischen geändert – bitte neu laden.' })
|
||||
await box.waitFor()
|
||||
assert(await knopf(box, 'Neu laden').isVisible(), 'Knopf „Neu laden“')
|
||||
await shot(page, 'konflikt')
|
||||
await knopf(box, 'Neu laden').click()
|
||||
await page.waitForFunction(() => !document.querySelector('#zeiten-body .zt-editor [role="alert"]:not([hidden])') || [...document.querySelectorAll('#zeiten-body .zt-editor .tm-box[role="alert"]')].every((e) => e.hidden))
|
||||
eq(await page.locator('details[data-plan] input[type="text"]').first().inputValue(), 'Öffnungszeiten (inzwischen geändert)', 'der Stand vom Server steht da')
|
||||
// jetzt geht Speichern mit der neuen Version
|
||||
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
|
||||
await meldung(page).filter({ hasText: 'gespeichert' }).waitFor()
|
||||
const puts = fx.aufrufe.filter((a) => a[0] === 'put').map((a) => a[1].version)
|
||||
eq(puts, [3, 4], 'zweites PUT mit der neuen Version')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Bestätigung bleibt sichtbar
|
||||
// Die Meldung ist das erste Kind des Editors; nach dem Speichern eines unteren Abschnitts (Kontakt) springt der Fokus auf den Knopf. Ohne
|
||||
// klebende Meldung stünde „gespeichert“ dann ausserhalb des Fensters.
|
||||
await check('fx_meldung_bleibt_sichtbar', async (page) => {
|
||||
await oeffneEditor(page)
|
||||
const form = page.locator('form[aria-label="Kontakt-Leiste"]')
|
||||
await form.scrollIntoViewIfNeeded()
|
||||
await form.locator('input[type="tel"]').first().fill('06301 999888')
|
||||
await knopf(form, 'Kontakt-Leiste speichern').click()
|
||||
await meldung(page).filter({ hasText: 'Kontakt-Leiste gespeichert' }).waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
const lage = await meldung(page).evaluate((e) => {
|
||||
const r = e.getBoundingClientRect()
|
||||
return { oben: r.top, unten: r.bottom, hoehe: r.height, fenster: window.innerHeight, pos: getComputedStyle(e).position, scroll: window.scrollY }
|
||||
})
|
||||
assert(lage.scroll > 300, `die Prüfung braucht eine gescrollte Seite (scrollY ${lage.scroll})`)
|
||||
assert(lage.hoehe > 0 && lage.oben >= -0.5 && lage.unten <= lage.fenster + 0.5, `Meldung liegt ausserhalb des Fensters: ${JSON.stringify(lage)}`)
|
||||
eq(lage.pos, 'sticky', 'Meldung klebt unter der Kopfleiste')
|
||||
// leer klebt sie nicht (kein leerer Streifen)
|
||||
await meldung(page).evaluate((e) => { e.textContent = '' })
|
||||
eq(await meldung(page).evaluate((e) => getComputedStyle(e).position), 'static', 'leere Meldung')
|
||||
await shot(page, 'meldung-sichtbar')
|
||||
}, { context: { viewport: { width: 1280, height: 600 } } })
|
||||
|
||||
// ---------------------------------------------------------------- Urlaub ⇒ Hinweis-Vorschlag, „Hinweis-Leiste anlegen“
|
||||
await check('fx_hinweis_vorschlag', async (page, _ctx, fx) => {
|
||||
await oeffneEditor(page)
|
||||
const von = tagPlus(HEUTE, 20)
|
||||
const bis = tagPlus(HEUTE, 25)
|
||||
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
|
||||
const form = page.getByRole('form', { name: 'Betriebsurlaub eintragen' })
|
||||
await form.locator('input[type="date"]').nth(0).fill(von)
|
||||
await form.locator('input[type="date"]').nth(1).fill(bis)
|
||||
await form.locator('input[type="text"]').fill('Sommerpause')
|
||||
await form.getByRole('button', { name: 'Betriebsurlaub eintragen' }).click()
|
||||
const frage = page.getByRole('form', { name: 'Hinweis-Leiste dazu anlegen?' })
|
||||
await frage.waitFor()
|
||||
const kurz = (t) => `${t.slice(8)}.${t.slice(5, 7)}.`
|
||||
assert((await frage.innerText()).includes(`Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}`), 'Vorschlag mit Zeitraum')
|
||||
await shot(page, 'hinweis-vorschlag')
|
||||
await knopf(frage, 'Ja, anlegen').click()
|
||||
const liste = abschnitt(page, 'hinweise')
|
||||
await liste.locator('li', { hasText: `Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}` }).waitFor()
|
||||
const hinweise = fx.aufrufe.filter((a) => a[0] === 'put').at(-1)[1].daten.hinweise
|
||||
eq(hinweise.map((h) => [h.art, h.knopf, h.von, h.bis]), [['urlaub', null, tagPlus(von, -7), bis]], 'Hinweis im zweiten PUT')
|
||||
|
||||
// „Hinweis-Leiste anlegen“: der Knopf, den die Relaunch-Station antippt
|
||||
const anlegen = page.getByRole('button', { name: 'Hinweis-Leiste anlegen', exact: true })
|
||||
assert(await anlegen.isVisible(), 'Knopf „Hinweis-Leiste anlegen“ ist sichtbar')
|
||||
await anlegen.click()
|
||||
const neu = page.getByRole('form', { name: 'Neuer Hinweis' })
|
||||
await neu.waitFor()
|
||||
await neu.locator('input[type="text"]').first().fill('Heute 20 % auf alle Torten')
|
||||
await neu.locator('[data-hinweis="preis"]').waitFor({ state: 'visible' })
|
||||
await shot(page, 'hinweis-formular')
|
||||
await neu.locator('select').first().selectOption('aktion')
|
||||
await knopf(neu, 'Hinweis speichern').click()
|
||||
await neu.locator('[data-fehler$=".bis"]', { hasText: 'Eine Aktion braucht ein Enddatum.' }).waitFor()
|
||||
await neu.locator('input[type="date"]').nth(1).fill(tagPlus(HEUTE, 3))
|
||||
await knopf(neu, 'Hinweis speichern').click()
|
||||
await liste.locator('li', { hasText: 'Heute 20 % auf alle Torten' }).waitFor()
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'put').length, 3, 'drei Speichervorgänge')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Handy 390 px
|
||||
await check('fx_handy_390', async (page, _ctx, fx) => {
|
||||
fx.daten = gespeicherteDaten({
|
||||
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten – nur diese Woche, solange der Vorrat reicht, bei Abholung im Laden', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten/aktion/herbst', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
|
||||
})
|
||||
await oeffneEditor(page)
|
||||
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
|
||||
// alles aufklappen, was aufklappt: Hinweis-Formular, WhatsApp-Felder, Regler
|
||||
await abschnitt(page, 'hinweise').locator('li[data-id="h_aktion001"] [data-aktion="bearbeiten"]').click()
|
||||
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
|
||||
await page.locator('[data-regler="zeit"]').fill('19:30')
|
||||
await page.locator('[data-vorschau="test"]', { hasText: 'Geschlossen' }).waitFor()
|
||||
assert(await passtInBreite(page), 'waagerechter Streifen')
|
||||
// nichts im Editor ragt über den rechten Rand hinaus
|
||||
const ueberstehend = await page.evaluate(() => {
|
||||
const breite = window.innerWidth
|
||||
return [...document.querySelectorAll('#zeiten-body *')].filter((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.height > 0 && (r.right > breite + 0.5 || r.left < -0.5)
|
||||
}).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${el.className}`)
|
||||
})
|
||||
eq(ueberstehend, [], 'nichts ragt über den Rand')
|
||||
// Tippflächen mindestens 44 px (Knöpfe, Auswahllisten, Eingabefelder; Haken über ihre Beschriftungszeile)
|
||||
const zuKlein = await page.evaluate(() => [...document.querySelectorAll('#zeiten-body button, #zeiten-body select, #zeiten-body input:not([type="checkbox"]), #zeiten-body summary, #zeiten-body a[href]')]
|
||||
.filter((el) => el.getClientRects().length && !el.matches(':disabled'))
|
||||
.map((el) => ({ el, h: el.getBoundingClientRect().height }))
|
||||
.filter(({ h }) => h < 43.5)
|
||||
.slice(0, 6)
|
||||
.map(({ el, h }) => `${el.tagName.toLowerCase()}[${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.className}] ${Math.round(h)}px`))
|
||||
eq(zuKlein, [], 'Tippflächen ≥ 44 px')
|
||||
await shot(page, 'handy-editor')
|
||||
// Kontakt und Google unten
|
||||
await abschnitt(page, 'kontakt').scrollIntoViewIfNeeded()
|
||||
await shot(page, 'handy-kontakt')
|
||||
assert(await passtInBreite(page), 'waagerechter Streifen (unten)')
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
|
||||
// ---------------------------------------------------------------- Tastatur
|
||||
await check('fx_tastatur', async (page, _ctx, fx) => {
|
||||
fx.daten = gespeicherteDaten({
|
||||
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
|
||||
})
|
||||
await oeffneEditor(page)
|
||||
await abschnitt(page, 'hinweise').getByRole('button', { name: 'Hinweis-Leiste anlegen' }).click()
|
||||
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Sondertag eintragen' }).click()
|
||||
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
|
||||
// alles, was sichtbar und bedienbar ist, bekommt eine Nummer
|
||||
const erwartet = await page.evaluate(() => {
|
||||
const fokus = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])'
|
||||
const sichtbar = (el) => !el.matches(':disabled') && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden'
|
||||
const liste = [...document.querySelectorAll(`#zeiten-body ${fokus.split(', ').join(', #zeiten-body ')}`)].filter(sichtbar)
|
||||
liste.forEach((el, i) => el.setAttribute('data-tabidx', String(i)))
|
||||
liste[0].focus()
|
||||
return liste.map((el) => `${el.tagName.toLowerCase()}:${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.getAttribute('aria-label') || el.id || el.className}`)
|
||||
})
|
||||
assert(erwartet.length > 40, `genug Bedienelemente (${erwartet.length})`)
|
||||
// Zeit- und Datumsfelder haben in Chromium mehrere Tab-Stopps (Stunde, Minute, Tag …): bleibt der Fokus im selben Element, zählt es nicht als nächstes
|
||||
const erreicht = new Set([0])
|
||||
const ohneFokusring = []
|
||||
let aktuell = 0
|
||||
for (let versuch = 0; aktuell < erwartet.length - 1; versuch += 1) {
|
||||
if (versuch > erwartet.length * 8) throw new Error(`Tab: nach ${versuch} Tastendrücken erst bei ${aktuell + 1} von ${erwartet.length}`)
|
||||
await page.keyboard.press('Tab')
|
||||
const stand = await page.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
const stil = el ? getComputedStyle(el) : null
|
||||
return el && el.hasAttribute('data-tabidx')
|
||||
? { idx: Number(el.getAttribute('data-tabidx')), ring: stil.outlineStyle !== 'none' && parseFloat(stil.outlineWidth) > 0, schatten: stil.boxShadow !== 'none' }
|
||||
: { idx: -1 }
|
||||
})
|
||||
if (stand.idx < 0) throw new Error(`Tab: Fokus außerhalb des Editors (erwartet: ${erwartet[aktuell + 1]})`)
|
||||
if (stand.idx === aktuell) continue
|
||||
if (stand.idx !== aktuell + 1) throw new Error(`Tab: Fokus auf ${erwartet[stand.idx]} statt ${erwartet[aktuell + 1]} (Reihenfolge oder übersprungen)`)
|
||||
aktuell = stand.idx
|
||||
erreicht.add(aktuell)
|
||||
if (!stand.ring && !stand.schatten) ohneFokusring.push(erwartet[aktuell])
|
||||
}
|
||||
eq([...erreicht].length, erwartet.length, 'alle Felder per Tab erreichbar')
|
||||
eq(ohneFokusring, [], 'Fokus überall sichtbar')
|
||||
await shot(page, 'tastatur-ende')
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
let abbruch = null
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
await pruefungen()
|
||||
const fremd = [...hosts].filter((h) => h !== new URL(BASE).host)
|
||||
results.push(['nur_eigene_herkunft', fremd.length === 0])
|
||||
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
|
||||
} catch (e) {
|
||||
abbruch = e
|
||||
console.log(`ABBRUCH: ${e.stack || e.message}`)
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {})
|
||||
server.close()
|
||||
}
|
||||
|
||||
const fehlgeschlagen = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
|
||||
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
|
||||
@@ -0,0 +1,208 @@
|
||||
// UI-Testserver der Oeffnungszeiten (fuer test/ui/zeiten-embed.ui.mjs): die echte oeffentliche Schnittstelle
|
||||
// (server/routes/publicZeiten.js samt Dienst, Speicher und Zeitlogik) hinter dem Nachbau von Appwrite aus test/support/,
|
||||
// public/ als statische Dateien (public/embed/zeiten.js) und Testseiten, die der Server selbst ausliefert. Nichts verlaesst den Rechner
|
||||
// (useTestEnv erlaubt nur 127.0.0.1).
|
||||
//
|
||||
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
|
||||
// import { startZeitenUiServer, zeitenEingabe } from './zeitenTestServer.mjs'
|
||||
// const srv = await startZeitenUiServer()
|
||||
// ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.seite(name, html), srv.zuruecksetzen({ eingabe, team }) ...
|
||||
// await srv.stop() // erst den Browser schliessen, dann den Server
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT (die Schnittstelle), srv.pageOrigin ist http://localhost:PORT (derselbe Server,
|
||||
// anderer Host): Seiten von pageOrigin mit data-api=srv.url sprechen die Schnittstelle als echte Cross-Origin-Anfrage an (CORS `*`,
|
||||
// Date-Kopf sichtbar), so wie Kundenseiten es im Betrieb tun. Die Seiten kommen vom Server selbst und nicht per page.route: Chromium
|
||||
// lehnt Anfragen von Seiten, die Playwright per route.fulfill liefert, an 127.0.0.1 ab (Local Network Access).
|
||||
//
|
||||
// Uhr: Server-Uhr und Date-Kopf der Antworten stehen fest (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit); srv.setzeUhr(datum) stellt sie
|
||||
// um. Der Browser bekommt dieselbe Zeit per context.clock.install({ time: srv.uhr() }); ohne Uhr im Browser rechnet das Skript
|
||||
// ohnehin mit der Serverzeit (Date-Kopf weicht um mehr als 5 Minuten von der lokalen Uhr ab).
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import express from 'express'
|
||||
import { startAppwriteMock } from '../support/appwriteMock.js'
|
||||
import { schemaKeys } from '../support/schema.js'
|
||||
import { useTestEnv } from '../support/testEnv.js'
|
||||
|
||||
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
|
||||
export const SITE_KEY = 'abcdabcdabcdabcd'
|
||||
export const KUNDE = 'c_kunde01'
|
||||
export const PROJEKT = 'p_projekt01'
|
||||
export const FEATURE = 'f_p_projekt01_zeiten'
|
||||
export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ)
|
||||
export const ADRESSE = 'Hauptstraße 1, 67722 Winnweiler'
|
||||
export const FIRMA = 'Bäckerei Sonnenschein'
|
||||
|
||||
const P_STANDARD = 'p_aaaaaaa1'
|
||||
|
||||
const woche = (zeiten = {}) => {
|
||||
const t = {}
|
||||
for (let d = 1; d <= 7; d += 1) t[d] = zeiten[d] ? { modus: 'zeiten', fenster: [zeiten[d]] } : { modus: 'zu', fenster: [] }
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe fuer speichereZeiten: ein Plan "standard" (Mo bis Fr 09:00 bis 18:00, Sa 09:00 bis 12:00, So zu), keine Ausnahmen, keine
|
||||
* Hinweise, Kontakt mit Telefon, Adresse und WhatsApp. `ueberschreibe` ersetzt oberste Felder (hinweise, kontakt, ausnahmen, ...).
|
||||
*/
|
||||
export function zeitenEingabe(ueberschreibe = {}) {
|
||||
return {
|
||||
land: 'RP',
|
||||
plaene: [
|
||||
{ id: P_STANDARD, kurz: 'standard', name: 'Öffnungszeiten', haupt: true, tage: woche({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '12:00'] }) },
|
||||
],
|
||||
feiertage: {},
|
||||
ausnahmen: [],
|
||||
hinweise: [],
|
||||
kontakt: {
|
||||
an: true,
|
||||
telefon: '0171 1234567',
|
||||
adresse: ADRESSE,
|
||||
whatsapp: { nummer: '+49 171 1234567', starttext: 'Hallo, ich habe eine Frage!', bestaetigtAt: '2026-10-01T08:00:00.000Z' },
|
||||
},
|
||||
...ueberschreibe,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Hinweis fuer zeitenEingabe({ hinweise: [hinweis(...)] }). */
|
||||
export function hinweis(art, text, extra = {}) {
|
||||
return { id: `h_${art.slice(0, 4)}${String(text.length).padStart(4, '0')}`, art, text, knopf: null, ...extra }
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet Nachbau, Router und Seiten.
|
||||
* @param {object} [opt]
|
||||
* @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr
|
||||
*/
|
||||
export async function startZeitenUiServer({ jetzt = JETZT } = {}) {
|
||||
const { ZEITEN_DATETIMES } = await import('../../server/services/zeiten/schema.js')
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||
},
|
||||
datetimes: ZEITEN_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../../server/config.js')
|
||||
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { resetZeitenCacheForTests } = await import('../../server/services/zeiten/cache.js')
|
||||
const { speichereZeiten, speichereTeam } = await import('../../server/services/zeiten/speicher.js')
|
||||
const { oeffentlicheZeitenAntwort } = await import('../../server/services/zeiten/oeffentlich.js')
|
||||
const { default: publicZeitenRoutes, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../../server/routes/publicZeiten.js')
|
||||
|
||||
let uhrMs = new Date(jetzt).getTime()
|
||||
const uhr = () => new Date(uhrMs)
|
||||
setzeUhrFuerTests(uhr)
|
||||
const modusVorher = config.zeiten.mode
|
||||
config.zeiten.mode = 'on'
|
||||
|
||||
// Testseiten (HTML aus dem Skript) kommen vom Server selbst
|
||||
const seiten = new Map()
|
||||
const seitenRouter = express.Router()
|
||||
seitenRouter.get('/:name', (req, res) => {
|
||||
const html = seiten.get(req.params.name)
|
||||
if (html === undefined) return res.status(404).type('text').send('nicht gefunden')
|
||||
return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html)
|
||||
})
|
||||
|
||||
let ueberschreibung = null // (antwort) => antwort: veraendert die echte Antwort vor dem Senden (Pruefungen mit boesen Daten)
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use('/ui-seiten', seitenRouter)
|
||||
app.use('/api/public/zeiten/v1', (req, res, next) => {
|
||||
res.setHeader('Date', uhr().toUTCString()) // Serverzeit in der Antwort: das Skript rechnet bei mehr als 5 Minuten Abweichung damit
|
||||
// Die echte Antwort darf der Browser 60 Sekunden behalten (max-age=60). Dessen Zwischenspeicher laeuft nach der echten Uhr, die Pruefungen
|
||||
// spulen aber die Uhr der Seite vor: ein Neuladen nach "10 Minuten" soll deshalb das Netz erreichen, nicht den Zwischenspeicher.
|
||||
const setzeKopf = res.setHeader.bind(res)
|
||||
res.setHeader = (name, wert) => setzeKopf(name, String(name).toLowerCase() === 'cache-control' ? 'no-store' : wert)
|
||||
if (!ueberschreibung || req.method !== 'GET') return next()
|
||||
const siteKey = req.path.replace(/^\//, '')
|
||||
return oeffentlicheZeitenAntwort(siteKey, uhr()).then((antwort) => {
|
||||
res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'Date', 'Cache-Control': 'no-store' })
|
||||
res.json(ueberschreibung(JSON.parse(JSON.stringify(antwort))))
|
||||
}, next)
|
||||
})
|
||||
app.use('/api/public/zeiten/v1', publicZeitenRoutes)
|
||||
app.use('/', express.static(PUBLIC_DIR, { etag: false, lastModified: false, setHeaders: (res) => res.set('Cache-Control', 'no-store') }))
|
||||
|
||||
const server = await new Promise((resolve, reject) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||
s.on('error', reject)
|
||||
})
|
||||
const url = `http://127.0.0.1:${server.address().port}`
|
||||
const pageOrigin = url.replace('127.0.0.1', 'localhost')
|
||||
|
||||
const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'zeiten', siteKey: SITE_KEY, state, enabled: state === 'active' })
|
||||
const featureRef = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY }
|
||||
|
||||
const srv = {
|
||||
url, pageOrigin, SITE_KEY, aw, config, uhr,
|
||||
|
||||
/** Testseite unter pageOrigin/ui-seiten/<name> bereitstellen; liefert die Adresse. */
|
||||
seite(name, html) {
|
||||
seiten.set(name, html)
|
||||
return `${pageOrigin}/ui-seiten/${name}`
|
||||
},
|
||||
|
||||
/** Server-Uhr (und Date-Kopf) neu setzen; der Zwischenspeicher der Schnittstelle wird geleert. */
|
||||
setzeUhr(datum) {
|
||||
uhrMs = new Date(datum).getTime()
|
||||
resetZeitenCacheForTests()
|
||||
},
|
||||
|
||||
/** Zustand der Funktion aendern: 'active' | 'setup' | 'disabled' | 'off'. */
|
||||
setzeFunktion(state) {
|
||||
aw.seed('portalFeatures', feature(state))
|
||||
resetZeitenCacheForTests()
|
||||
},
|
||||
|
||||
/** Antwort der Schnittstelle vor dem Senden veraendern (null = echte Antwort): fn(antwort) liefert die neue Antwort. */
|
||||
ueberschreibe(fn) {
|
||||
ueberschreibung = fn
|
||||
resetZeitenCacheForTests()
|
||||
},
|
||||
|
||||
/** Zeiten speichern (Eingabe wie zeitenEingabe) und Team-Einstellungen setzen ({ navSelektor, kontaktleisteAus, hinweisleisteAus }). */
|
||||
async speichere(eingabe = zeitenEingabe(), team = null) {
|
||||
const doc = aw.docs('oeffnungszeiten').get(FEATURE)
|
||||
await speichereZeiten({ feature: featureRef, eingabe, version: doc?.version ?? 0, now: uhr() })
|
||||
if (team) {
|
||||
const doc2 = aw.docs('oeffnungszeiten').get(FEATURE)
|
||||
await speichereTeam({ feature: featureRef, eingabe: team, teamVersion: doc2?.teamVersion ?? 0, now: uhr() })
|
||||
}
|
||||
resetZeitenCacheForTests()
|
||||
},
|
||||
|
||||
/** Alles auf Anfang: Dokumente loeschen, Uhr zurueck, Funktion (active), Kunde, Projekt und Zeiten neu anlegen. */
|
||||
async zuruecksetzen({ eingabe, team = null, uhrNeu = true, state = 'active' } = {}) {
|
||||
for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
resetZeitenCacheForTests()
|
||||
ueberschreibung = null
|
||||
if (uhrNeu) uhrMs = new Date(jetzt).getTime()
|
||||
config.zeiten.mode = 'on'
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Frau Sonne', companyName: FIRMA })
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' })
|
||||
aw.seed('portalFeatures', feature(state))
|
||||
await srv.speichere(eingabe ?? zeitenEingabe(), team)
|
||||
},
|
||||
|
||||
/** Beendet Server und Nachbau (zuerst den Browser schliessen) und stellt config wieder her. */
|
||||
async stop() {
|
||||
config.zeiten.mode = modusVorher
|
||||
resetPublicZeitenForTests()
|
||||
server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
await aw.close()
|
||||
},
|
||||
}
|
||||
|
||||
await srv.zuruecksetzen()
|
||||
return srv
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// Abo-Abgleich von Öffnungszeiten live (Aufgabe 21, Spec 5 "Abo-Abgleich" Schritte 1 bis 4 und Z11): gleicheZeitenAboAb(now) koppelt die im
|
||||
// Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K29), nach 30 Tagen abschalten (K30, Ersatz-Ticket),
|
||||
// bei Rückkehr des Abos wieder einschalten (K27 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen.
|
||||
// Aufbau wie termineAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit JETZT; Tage zählen nach Berliner Kalendertagen.
|
||||
// Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null)
|
||||
const DATETIMES = {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
}
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||
},
|
||||
datetimes: DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = await import('../server/services/siteFeatures.js')
|
||||
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
|
||||
const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js')
|
||||
const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
||||
const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js')
|
||||
const { heute, plusTage, uhrzeit, zuUtc } = await import('../server/services/termine/zeit.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (Sommerzeit)
|
||||
const KUNDE = 'c1'
|
||||
const ZUVOR = '2026-09-20T10:00:00.000Z'
|
||||
const BASIS_VORHER = config.publicBaseUrl
|
||||
const originalRebuild = zeitenDeps.rebuild
|
||||
|
||||
const ms = (wert) => new Date(wert).getTime()
|
||||
/** Der Tag `tage` Kalendertage nach basis, zur selben Berliner Uhrzeit (nie Vielfache von 24 Stunden: am 25.10. endet die Sommerzeit). */
|
||||
const nach = (tage, basis = JETZT) => zuUtc(plusTage(heute(basis), tage), uhrzeit(basis))
|
||||
|
||||
let sink
|
||||
let log
|
||||
let rebuilds
|
||||
let sperrenBeimVersand
|
||||
|
||||
/** Ist die Sperre gerade frei? Eine gehaltene Sperre lässt die Probe warten: nach 60 ms gilt sie als gehalten. */
|
||||
const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))])
|
||||
|
||||
/** Alle Dokumente der Tests entfernen (auch mitten im Test, für eine zweite Runde). */
|
||||
async function raeumeAuf() {
|
||||
for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects', 'workorders']) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
resetZeitenCacheForTests()
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await raeumeAuf()
|
||||
aw.resetListCalls()
|
||||
resetZeitenCacheForTests()
|
||||
config.publicBaseUrl = 'https://project.webklar.com'
|
||||
sink = mailSink()
|
||||
// Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (die Mails gehen erst nach der Sperre raus)
|
||||
sperrenBeimVersand = []
|
||||
const roh = sink.sendMail.bind(sink)
|
||||
sink.sendMail = async (mail) => {
|
||||
sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1') })
|
||||
return roh(mail)
|
||||
}
|
||||
setMailTransportForTests(sink)
|
||||
rebuilds = []
|
||||
zeitenDeps.rebuild = async (projekt) => {
|
||||
rebuilds.push({ projektId: projekt.$id, state: aw.docs('portalFeatures').get(`f_${projekt.$id}_zeiten`)?.state, adminFrei: await sperreFrei(`feature-admin:${projekt.$id}`) })
|
||||
return { queued: true }
|
||||
}
|
||||
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) }
|
||||
})
|
||||
afterEach(() => {
|
||||
// Das Log enthält nie Adresse oder Namen des Kunden
|
||||
const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n')
|
||||
mock.timers.reset()
|
||||
mock.restoreAll()
|
||||
zeitenDeps.rebuild = originalRebuild
|
||||
config.publicBaseUrl = BASIS_VORHER
|
||||
assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Hilfen
|
||||
|
||||
const projektId = (nr) => `p${nr}`
|
||||
const funktionsId = (nr = 1) => `f_p${nr}_zeiten`
|
||||
const funktion = (nr = 1) => aw.docs('portalFeatures').get(funktionsId(nr))
|
||||
|
||||
function tage(woche = {}) {
|
||||
const t = {}
|
||||
for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
|
||||
return t
|
||||
}
|
||||
const EIN_PLAN = () => ({
|
||||
land: 'RP',
|
||||
plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }],
|
||||
feiertage: {},
|
||||
ausnahmen: [],
|
||||
hinweise: [],
|
||||
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
|
||||
})
|
||||
|
||||
/**
|
||||
* Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K27 gesendet,
|
||||
* Zeiten gespeichert (daten, ersatzHash leer). Das Projekt "Praxis Muster" und das Einbau-Ticket legt die Hilfe mit an.
|
||||
*/
|
||||
async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxis Muster', zeiten = true } = {}) {
|
||||
aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt, subdomain: `praxis${nr}`, repoFullName: `JUSN/praxis${nr}`, previewMode: 'auto', previewDetected: 'static' })
|
||||
aw.seed('workorders', { $id: `t_${nr}`, woid: `WO-${4710 + nr}`, status: 'Closed', topic: 'Öffnungszeiten live einbauen' })
|
||||
const doc = aw.seed('portalFeatures', {
|
||||
$id: funktionsId(nr),
|
||||
customerId: kunde,
|
||||
projectId: projektId(nr),
|
||||
featureKey: 'zeiten',
|
||||
state: 'active',
|
||||
enabled: true,
|
||||
unlockedBy: 'abo',
|
||||
unlockedByPurchase: false,
|
||||
siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`,
|
||||
ticketId: `t_${nr}`,
|
||||
einbau: 'angestossen',
|
||||
einschaltMailAt: ZUVOR,
|
||||
activatedAt: ZUVOR,
|
||||
...over,
|
||||
})
|
||||
if (zeiten) await speichereZeiten({ feature: funktion(nr), eingabe: EIN_PLAN(), version: 0, now: new Date(ZUVOR) })
|
||||
return doc
|
||||
}
|
||||
|
||||
/** Wartungs-Abo (ein Abo-Dokument je Kunde; ein zweiter Aufruf ersetzt es). */
|
||||
const wartung = (status, over = {}, kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...wartungDoc, customerId: kunde, status, ...over }))
|
||||
const hosting = (status = 'active', kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...hostingDoc, customerId: kunde, status }))
|
||||
const abgelaufen = () => wartung('canceled', { endedAt: ZUVOR, canceledAt: ZUVOR })
|
||||
const laeuft = () => wartung('active')
|
||||
|
||||
/** Der Abgleich läuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() und activate() (new Date()) denselben Zeitpunkt schreiben. */
|
||||
async function laufe(now = JETZT) {
|
||||
mock.timers.enable({ apis: ['Date'], now: now.getTime() })
|
||||
try {
|
||||
return await gleicheZeitenAboAb(now)
|
||||
} finally {
|
||||
mock.timers.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const ERGEBNIS_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }
|
||||
const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over })
|
||||
|
||||
const K29 = /^Dein Wartungs-Abo ist beendet/
|
||||
const K30 = 'Öffnungszeiten live ist abgeschaltet'
|
||||
const K27 = 'Öffnungszeiten live ist eingeschaltet'
|
||||
const mails = () => sink.sent
|
||||
const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff))
|
||||
const tickets = () => [...aw.docs('workorders').values()]
|
||||
const ersatzTickets = () => tickets().filter((t) => String(t.topic).startsWith('Ersatz-Tabelle aktualisieren'))
|
||||
const abfragenAufAbos = () => aw.listCalls('subscriptions').length
|
||||
|
||||
/** Die Zustände eines Dokuments, die der Abgleich bewegt (für Vergleiche "unverändert"). */
|
||||
const stand = (nr = 1) => structuredClone({ ...funktion(nr) })
|
||||
|
||||
/**
|
||||
* Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, läuft zuerst dazwischen() zu Ende, dann
|
||||
* erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen.
|
||||
*/
|
||||
async function mitEinwurf(passt, dazwischen, fn) {
|
||||
const echt = globalThis.fetch
|
||||
let offen = true
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
const antwort = await echt(url, optionen)
|
||||
if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) {
|
||||
offen = false
|
||||
globalThis.fetch = echt
|
||||
await dazwischen()
|
||||
}
|
||||
return antwort
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
globalThis.fetch = echt
|
||||
}
|
||||
}
|
||||
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 (K29), Tag 30 abgeschaltet (K30). */
|
||||
async function bisAbgeschaltet() {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
await laufe(nach(30))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K29
|
||||
|
||||
test('Abo endet: aboEndeAt gesetzt, K29 einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
|
||||
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }))
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'active', 'die Funktion läuft bis zur Frist weiter')
|
||||
assert.equal(doc.enabled, true)
|
||||
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung')
|
||||
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K29 erst nach dem Versand')
|
||||
assert.equal(doc.unlockedBy, 'abo')
|
||||
|
||||
assert.equal(mails().length, 1)
|
||||
const mail = mails()[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.subject, K29)
|
||||
assert.match(mail.text, /Silke Müller/)
|
||||
assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet')
|
||||
assert.match(mail.text, /Läuft weiter bis: 04\.11\.2026/, 'Frist = Tag der Erkennung + 30 Tage')
|
||||
assert.match(mail.text, /einmalig 30 €/)
|
||||
assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Behalten-Link zum Editor')
|
||||
assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
|
||||
// am selben Tag und am Folgetag: nichts mehr, keine zweite Mail, aboEndeAt bleibt (die Frist wird nie verschoben)
|
||||
const vorher = stand()
|
||||
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 }))
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 }))
|
||||
assert.deepEqual(stand(), vorher)
|
||||
assert.equal(mails().length, 1)
|
||||
})
|
||||
|
||||
test('Läuft das Abo, geschieht nichts: kein Schreiben, keine Mail', async () => {
|
||||
await legeFunktion(1)
|
||||
laeuft()
|
||||
const vorher = stand()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 }))
|
||||
assert.deepEqual(stand(), vorher)
|
||||
assert.equal(mails().length, 0)
|
||||
})
|
||||
|
||||
test('K29-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => {
|
||||
const varianten = [
|
||||
['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/],
|
||||
[
|
||||
'Wechsel widerrufen (der Spiegel sagt noch "Wartung läuft")',
|
||||
() => {
|
||||
wartung('active')
|
||||
aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: `sub_${KUNDE}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' })
|
||||
},
|
||||
/Du hast den Wechsel in das Wartungs-Abo widerrufen/,
|
||||
/Dein Wartungs-Abo ist beendet\./,
|
||||
],
|
||||
]
|
||||
for (const [name, abo, passt, passtNicht] of varianten) {
|
||||
await raeumeAuf()
|
||||
sink.sent.length = 0
|
||||
await legeFunktion(1)
|
||||
abo()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name)
|
||||
assert.match(mails()[0].subject, K29, name)
|
||||
assert.match(mails()[0].text, passt, name)
|
||||
assert.doesNotMatch(mails()[0].text, passtNicht, name)
|
||||
assert.equal(funktion().state, 'active', `${name}: die Frist läuft`)
|
||||
}
|
||||
})
|
||||
|
||||
test('K29 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => {
|
||||
await legeFunktion(1)
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1 }))
|
||||
assert.equal(mails().length, 0)
|
||||
assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'es gibt nichts zu senden, also auch nichts zu wiederholen')
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('K29 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
const roh = sink.sendMail
|
||||
sink.sendMail = async () => {
|
||||
throw new Error('SMTP down für silke@example.com')
|
||||
}
|
||||
assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, fehler: 1 }))
|
||||
assert.equal(ms(funktion().aboEndeAt), JETZT.getTime())
|
||||
assert.ok(!funktion().aboEndeMailAt, 'ohne Versand kein Marker')
|
||||
assert.equal(mails().length, 0)
|
||||
|
||||
sink.sendMail = roh
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K29).length, 1)
|
||||
assert.equal(ms(funktion().aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt: die Frist wächst nicht')
|
||||
assert.match(mails()[0].text, /Läuft weiter bis: 04\.11\.2026/, 'dieselbe Frist wie beim ersten Versuch')
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K30, Ersatz-Ticket
|
||||
|
||||
test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K30, Ersatz-Ticket', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
|
||||
const tag29 = await laufe(nach(29))
|
||||
assert.deepEqual(tag29, ergebnis({ geprueft: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(mails().length, 0)
|
||||
|
||||
const tag30 = nach(30)
|
||||
assert.equal(heute(tag30), '2026-11-04')
|
||||
assert.deepEqual(await laufe(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'disabled')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.equal(doc.disabledReason, 'abo_ende')
|
||||
assert.equal(ms(doc.disabledAt), tag30.getTime())
|
||||
assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen')
|
||||
assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K30')
|
||||
assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/)
|
||||
|
||||
// K30: Datum der Löschung = Abschalten + 30 Tage
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
const mail = mailsMit(K30)[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.text, /Eingaben gespeichert bis: 04\.12\.2026/)
|
||||
assert.match(mail.text, /30 €/)
|
||||
assert.ok(mail.bcc?.length)
|
||||
|
||||
// Ersatz-Ticket (R23: ohne gemerkten Hash gilt der Stand als unbekannt, Zeiten sind gespeichert)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.match(ersatzTickets()[0].topic, /Praxis Muster/)
|
||||
assert.ok((await ladeZeiten(funktionsId(1))).ersatzHash, 'der Hash wurde nach dem Ticket gemerkt')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
|
||||
// Folgetag: nichts mehr (kein zweites Ticket, keine zweite Mail, disabledAt bleibt)
|
||||
const vorher = stand()
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
|
||||
assert.deepEqual(stand(), vorher)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mails().length, 1)
|
||||
})
|
||||
|
||||
test('Frist nach Berliner Kalendertagen, nicht nach 24-Stunden-Schritten (Ende der Sommerzeit 25.10.)', async () => {
|
||||
// Abo-Ende erkannt am 24.10. um 23:30 Berliner Zeit (21:30 UTC); Frist = 23.11.
|
||||
const erkannt = new Date('2026-10-24T21:30:00.000Z')
|
||||
await legeFunktion(1, { aboEndeAt: erkannt.toISOString(), aboEndeMailAt: erkannt.toISOString() })
|
||||
abgelaufen()
|
||||
// 22.11., 23:59 Berliner Zeit: noch ein Tag
|
||||
assert.deepEqual(await laufe(new Date('2026-11-22T22:59:00.000Z')), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
// 23.11., 00:01 Berliner Zeit: der Tag der Frist ist da, auch vor 23:30 (24 x 30 Stunden später wäre erst 22:30 am 23.11.)
|
||||
assert.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/)
|
||||
})
|
||||
|
||||
test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
})
|
||||
|
||||
test('K30 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
const roh = sink.sendMail
|
||||
sink.sendMail = async () => {
|
||||
throw new Error('SMTP down')
|
||||
}
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K29 gilt nicht für die K30')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
|
||||
sink.sendMail = roh
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K30 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K30 genau einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(mailsMit(K30).length, 0, 'K30 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K30-Marker')
|
||||
|
||||
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K30 einmal
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K30')
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert mehrere Läufe: kein K30, jeder Lauf zählt fehler; sobald es klappt, genau eine K30', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
await laufe(nach(30))
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abschalten gelingt, die Folge scheitert (Marker der K29 steht noch, älter als disabledAt): der nächste Lauf holt K30 und das Ersatz-Ticket nach, einmal', async () => {
|
||||
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K29
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K30-Marker liegt nie davor, K30 geht nicht täglich raus', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
const tag30 = nach(30)
|
||||
mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs
|
||||
try {
|
||||
assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
|
||||
} finally {
|
||||
mock.timers.reset()
|
||||
}
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos
|
||||
|
||||
test('Rückkehr an Tag 45: zurück auf active, Neubau, K27 wieder', async () => {
|
||||
await bisAbgeschaltet()
|
||||
mails().length = 0
|
||||
laeuft()
|
||||
const tag45 = nach(45)
|
||||
|
||||
assert.deepEqual(await laufe(tag45), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'active', 'das Ticket war geschlossen (activatedAt)')
|
||||
assert.equal(doc.enabled, true)
|
||||
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
|
||||
assert.equal(doc.unlockedBy, 'abo')
|
||||
assert.equal(doc.einbau, 'angestossen', 'Ergebnis des neuen Neubaus')
|
||||
assert.match(doc.adminNote, /Wieder eingeschaltet: Wartungs-Abo läuft wieder/)
|
||||
|
||||
// Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre
|
||||
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }])
|
||||
// K27 mit dem Block wieder und dem Neubau-Text
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
const mail = mailsMit(K27)[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/)
|
||||
assert.match(mail.text, /nach dem nächsten Veröffentlichen/)
|
||||
assert.doesNotMatch(mail.text, /\(Ticket #/, 'kein neues Ticket, keine Ticketnummer')
|
||||
// die Daten sind da (nie angefasst) und danach ist wieder Ruhe
|
||||
assert.ok((await ladeZeiten(funktionsId(1))).daten)
|
||||
assert.deepEqual(await laufe(nach(46)), ergebnis({ geprueft: 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 K27 "das Team baut ein"', async () => {
|
||||
await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null })
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
await laufe(nach(30))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
mails().length = 0
|
||||
zeitenDeps.rebuild = async () => ({ queued: false })
|
||||
laeuft()
|
||||
|
||||
assert.deepEqual(await laufe(nach(40)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.equal(doc.einbau, 'manuell')
|
||||
assert.equal(doc.ticketId, 't_1', 'das Einbau-Ticket bleibt, der 5-Minuten-Job gibt frei, sobald es zu ist')
|
||||
assert.match(mails()[0].text, /baut unser Team/)
|
||||
})
|
||||
|
||||
test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K27 wieder ohne "Wie geht es weiter"', async () => {
|
||||
for (const [name, over] of [
|
||||
['aktiv', {}],
|
||||
['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }],
|
||||
]) {
|
||||
await raeumeAuf()
|
||||
mails().length = 0
|
||||
rebuilds.length = 0
|
||||
await legeFunktion(1, over, { zeiten: false })
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
// eine offene Behalten-Zahlung (Sitzung) ist mit der Rückkehr hinfällig
|
||||
aw.seed('portalFeatures', { ...funktion(1), behaltenSessionId: 'cs_test_offen' })
|
||||
assert.equal(funktion().behaltenSessionId, 'cs_test_offen', name)
|
||||
mails().length = 0
|
||||
laeuft()
|
||||
|
||||
assert.deepEqual(await laufe(nach(10)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), name)
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, over.state ?? 'active', `${name}: Zustand bleibt`)
|
||||
assert.deepEqual([doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, ''], `${name}: aboEndeAt, Marker und Sitzung geleert`)
|
||||
assert.equal(doc.einbau, 'angestossen', name)
|
||||
assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`)
|
||||
|
||||
assert.equal(mailsMit(K27).length, 1, name)
|
||||
const text = mailsMit(K27)[0].text
|
||||
assert.match(text, /Dein Wartungs-Abo läuft wieder/, name)
|
||||
assert.doesNotMatch(text, /Wie geht es weiter/, `${name}: beide Blöcke aus, der Kasten entfällt`)
|
||||
assert.doesNotMatch(text, /nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten/, name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Rückkehr ohne vorherige K29 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => {
|
||||
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' })
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 }))
|
||||
assert.equal(mails().length, 0)
|
||||
assert.deepEqual([funktion().aboEndeAt ?? null, funktion().behaltenSessionId ?? ''], [null, ''])
|
||||
})
|
||||
|
||||
test('Rückkehr: das Abo kommt zwischen Vorauswahl und Sperre zurück ⇒ der frisch gelesene Stand entscheidet, nicht abgeschaltet', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30)))
|
||||
assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(mailsMit(K30).length, 0)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
})
|
||||
|
||||
test('Rückkehr am Tag der Löschung, Daten noch da: die Rückkehr gewinnt vor der Löschung', async () => {
|
||||
await bisAbgeschaltet()
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }))
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.ok((await ladeZeiten(funktionsId(1))).daten, 'Daten erhalten')
|
||||
})
|
||||
|
||||
test('Löschung fällig, aber die Daten sind schon weg (Lauf nach dem Löschen unterbrochen) und das Abo läuft: nicht wieder einschalten, sondern abschliessen', async () => {
|
||||
await bisAbgeschaltet()
|
||||
await deleteDocument('oeffnungszeiten', funktionsId(1))
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 }))
|
||||
assert.equal(funktion().state, 'off')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 4: Löschung
|
||||
|
||||
test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht, state off', async () => {
|
||||
await bisAbgeschaltet()
|
||||
const feature = funktion()
|
||||
aw.seed('zeitenErinnerungen', { $id: 'm1', featureId: feature.$id, datum: '2026-11-10', createdAt: JETZT.toISOString() })
|
||||
aw.seed('zeitenErinnerungen', { $id: 'm2', featureId: 'f_andere_zeiten', datum: '2026-11-10', createdAt: JETZT.toISOString() })
|
||||
mails().length = 0
|
||||
const tag59 = nach(59)
|
||||
assert.equal(heute(tag59), '2026-12-03')
|
||||
assert.deepEqual(await laufe(tag59), ergebnis({ geprueft: 1 }), 'Tag 59 nach dem Abo-Ende: noch nichts')
|
||||
assert.ok(await ladeZeiten(feature.$id))
|
||||
|
||||
const tag60 = nach(60)
|
||||
assert.equal(heute(tag60), '2026-12-04', '30 Tage nach der Abschaltung am 04.11.')
|
||||
assert.deepEqual(await laufe(tag60), ergebnis({ geprueft: 1, geloescht: 1 }))
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'off')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.deepEqual(
|
||||
[doc.ticketId ?? '', doc.einbau ?? '', doc.einschaltMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
|
||||
['', '', null, null, '', null],
|
||||
'ticketId, einbau, einschaltMailAt, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
|
||||
)
|
||||
assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/)
|
||||
assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 0)
|
||||
assert.equal(aw.docs('zeitenErinnerungen').has('m1'), false, 'Erinnerungsmerker der Funktion gelöscht')
|
||||
assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben')
|
||||
assert.equal(mails().length, 0, 'die K30 hat die Löschung angekündigt, es gibt keine weitere Mail')
|
||||
|
||||
// aus ist aus: der nächste Lauf liest das Dokument nicht mehr
|
||||
assert.deepEqual(await laufe(nach(61)), ergebnis())
|
||||
})
|
||||
|
||||
test('nach der Löschung: ticketId, einbau, einschaltMailAt, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => {
|
||||
await bisAbgeschaltet()
|
||||
await laufe(nach(60))
|
||||
assert.equal(funktion().state, 'off')
|
||||
assert.equal(await ladeZeiten(funktionsId(1)), null, 'ersatzHash lebte im gelöschten Dokument')
|
||||
const ticketsVorher = tickets().length
|
||||
mails().length = 0
|
||||
rebuilds.length = 0
|
||||
|
||||
// Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K27
|
||||
laeuft()
|
||||
const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) })
|
||||
assert.equal(changed, true)
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket')
|
||||
assert.equal(tickets().length, ticketsVorher + 1)
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.ok(doc.einschaltMailAt, 'K27 neu gesendet')
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
})
|
||||
|
||||
test('Löschung scheitert (Zeiten-Dokument nicht löschbar): state bleibt disabled, fehler zählt, der nächste Lauf versucht es wieder', async () => {
|
||||
await bisAbgeschaltet()
|
||||
aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'Löschen kaputt' })
|
||||
assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.deepEqual(await laufe(nach(61)), ergebnis({ geprueft: 1, geloescht: 1 }))
|
||||
assert.equal(funktion().state, 'off')
|
||||
})
|
||||
|
||||
test('Abo-Lesefehler vor der Löschung: es wird nie gelöscht', async () => {
|
||||
await bisAbgeschaltet()
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
|
||||
assert.deepEqual(await laufe(nach(60)), ergebnis({ fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(await ladeZeiten(funktionsId(1)))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Nur unlockedBy abo
|
||||
|
||||
test('gekaufte und Team-Freischaltungen bleiben unberührt (das Abo wird nicht einmal gelesen)', async () => {
|
||||
await legeFunktion(1, { unlockedBy: 'kauf', unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||||
await legeFunktion(2, { unlockedBy: 'team' })
|
||||
await legeFunktion(3, { unlockedBy: undefined, unlockedByPurchase: true, purchaseStatus: 'paid' }) // Altbestand ohne unlockedBy: Kauf
|
||||
await legeFunktion(4, { unlockedBy: undefined }) // Altbestand ohne unlockedBy und ohne Kauf: Team
|
||||
await legeFunktion(5, { state: 'disabled', enabled: false, unlockedBy: 'kauf', unlockedByPurchase: true, disabledReason: 'abo_ende', disabledAt: '2026-08-01T08:00:00.000Z' })
|
||||
await legeFunktion(6, { state: 'setup', enabled: false, unlockedBy: 'team', aboEndeAt: ZUVOR })
|
||||
abgelaufen()
|
||||
const vorher = [1, 2, 3, 4, 5, 6].map((nr) => stand(nr))
|
||||
aw.resetListCalls()
|
||||
|
||||
for (const now of [JETZT, nach(40), nach(100)]) assert.deepEqual(await laufe(now), ergebnis())
|
||||
assert.deepEqual([1, 2, 3, 4, 5, 6].map((nr) => stand(nr)), vorher)
|
||||
assert.equal(abfragenAufAbos(), 0, 'kein gekoppeltes Dokument: keine Abo-Abfrage')
|
||||
assert.equal(mails().length, 0)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 6)
|
||||
})
|
||||
|
||||
test('News und Terminplaner (andere featureKey) und Entwürfe (off) bleiben unberührt', async () => {
|
||||
await legeFunktion(1, { state: 'off', enabled: false })
|
||||
aw.seed('portalFeatures', { $id: 'f_p9_termine', customerId: KUNDE, projectId: 'p9', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoz' })
|
||||
aw.seed('portalFeatures', { $id: 'f_p9_news', customerId: KUNDE, projectId: 'p9', featureKey: 'news', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoy' })
|
||||
abgelaufen()
|
||||
const vorher = [funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d }))
|
||||
assert.deepEqual(await laufe(nach(40)), ergebnis())
|
||||
assert.deepEqual([funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })), vorher)
|
||||
assert.equal(abfragenAufAbos(), 0)
|
||||
})
|
||||
|
||||
test('Grund team (und alle anderen Gründe ausser abo_ende) wird nie gelöscht', async () => {
|
||||
const alt = '2026-06-01T08:00:00.000Z'
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: alt, aboEndeAt: alt })
|
||||
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: alt })
|
||||
await legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: alt })
|
||||
await legeFunktion(4, { state: 'disabled', enabled: false, disabledAt: alt }) // Altbestand ohne Grund
|
||||
abgelaufen()
|
||||
const vorher = [1, 2, 3, 4].map((nr) => stand(nr))
|
||||
assert.deepEqual(await laufe(nach(200)), ergebnis())
|
||||
assert.deepEqual([1, 2, 3, 4].map((nr) => stand(nr)), vorher)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 4, 'alle Daten bleiben')
|
||||
// auch bei zurückgekehrtem Abo bleibt die Team-Abschaltung abgeschaltet
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(201)), ergebnis())
|
||||
assert.equal(funktion(1).state, 'disabled')
|
||||
assert.equal(mails().length, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Fehler und Fail-closed
|
||||
|
||||
test('Abo-Lesefehler: überspringen, nie abschalten (der andere Kunde ist trotzdem dran, der nächste Lauf holt es nach)', async () => {
|
||||
// c1: Frist verstrichen, würde abgeschaltet; c2: Löschtag erreicht, würde gelöscht
|
||||
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
|
||||
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }, { kunde: 'c2' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
|
||||
wartung('canceled', { endedAt: ZUVOR })
|
||||
wartung('canceled', { endedAt: ZUVOR }, 'c2')
|
||||
const vorher = [stand(1), stand(2)]
|
||||
|
||||
// das Lesen des Abos von c1 scheitert (Vorauswahl, der erste Abo-Abruf): nichts wird abgeschaltet und nichts gesendet
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
|
||||
assert.deepEqual(await laufe(nach(200)), ergebnis({ geprueft: 1, geloescht: 1, fehler: 1 }))
|
||||
assert.deepEqual(stand(1), vorher[0], 'c1 unverändert')
|
||||
assert.equal(funktion(2).state, 'off', 'c2 war lesbar und fällig')
|
||||
assert.equal(mails().length, 0)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
|
||||
// das Lesen des Abos von c2 scheitert, c1 ist lesbar: c2 bleibt, wie es ist (nie löschen, wenn unklar ist, ob das Abo zurück ist)
|
||||
await raeumeAuf()
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
|
||||
wartung('canceled', { endedAt: ZUVOR })
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
|
||||
assert.deepEqual(await laufe(nach(200)), ergebnis({ fehler: 1 }))
|
||||
assert.equal(funktion(1).state, 'disabled')
|
||||
assert.ok(await ladeZeiten(funktionsId(1)), 'Daten erhalten')
|
||||
|
||||
// der nächste Lauf mit lesbarem Abo holt es nach
|
||||
assert.deepEqual(await laufe(nach(201)), ergebnis({ geprueft: 1, geloescht: 1 }))
|
||||
assert.equal(funktion(1).state, 'off')
|
||||
})
|
||||
|
||||
test('Abo-Lesefehler unter der Sperre (zweites Lesen): nichts wird geschrieben', async () => {
|
||||
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
|
||||
abgelaufen()
|
||||
const vorher = stand()
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frisches Lesen scheitert
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, fehler: 1 }))
|
||||
assert.deepEqual(stand(), vorher)
|
||||
assert.equal(mails().length, 0)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
})
|
||||
|
||||
test('Liste nicht lesbar: der Abgleich entfällt, kein Wurf', async () => {
|
||||
await legeFunktion(1)
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Appwrite down' })
|
||||
assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }))
|
||||
assert.equal(mails().length, 0)
|
||||
})
|
||||
|
||||
test('ein unlesbares Datum bei einem Dokument stoppt die anderen nicht (je Dokument gefangen)', async () => {
|
||||
await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() })
|
||||
await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { kunde: 'c2' })
|
||||
await legeFunktion(3, {}, { kunde: 'c3' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
|
||||
aw.seed('customers', { $id: 'c3', name: 'Eva Schmidt', email: 'eva@example.com' })
|
||||
for (const kunde of [KUNDE, 'c2', 'c3']) wartung('canceled', { endedAt: ZUVOR }, kunde)
|
||||
// Datumsfelder, die Appwrite nie liefern würde: der Nachbau prüft sie hier nicht
|
||||
const feldNamen = DATETIMES.portalFeatures
|
||||
DATETIMES.portalFeatures = feldNamen.filter((f) => f !== 'aboEndeAt' && f !== 'disabledAt')
|
||||
try {
|
||||
aw.seed('portalFeatures', { ...funktion(1), aboEndeAt: 'kein-datum' })
|
||||
aw.seed('portalFeatures', { ...funktion(2), disabledAt: 'kein-datum' })
|
||||
} finally {
|
||||
DATETIMES.portalFeatures = feldNamen
|
||||
}
|
||||
const vorher = [stand(1), stand(2)]
|
||||
|
||||
const erg = await laufe(nach(30))
|
||||
assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K29')
|
||||
assert.deepEqual([stand(1), stand(2)], vorher, 'die kaputten Dokumente bleiben, wie sie sind')
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com'])
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht')
|
||||
})
|
||||
|
||||
test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K29 nach', async () => {
|
||||
await legeFunktion(1)
|
||||
await legeFunktion(2, {}, { kunde: 'c2' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
|
||||
for (const kunde of [KUNDE, 'c2']) wartung('canceled', { endedAt: ZUVOR }, kunde)
|
||||
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite down' })
|
||||
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, aboEnde: 2, mails: 1, fehler: 1 }))
|
||||
assert.equal(ms(funktion(1).aboEndeAt), JETZT.getTime(), 'das Abo-Ende steht, die Frist läuft')
|
||||
assert.ok(!funktion(1).aboEndeMailAt)
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com'])
|
||||
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 2, mails: 1 }))
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com', 'silke@example.com'])
|
||||
assert.match(mails()[1].text, /Läuft weiter bis: 04\.11\.2026/, 'Frist vom ersten Lauf, nicht von heute')
|
||||
})
|
||||
|
||||
test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT) // K29
|
||||
await laufe(nach(30)) // K30
|
||||
laeuft()
|
||||
await laufe(nach(45)) // Neubau und K27
|
||||
assert.deepEqual(
|
||||
sperrenBeimVersand.map((s) => [s.subject === K30 ? 'K30' : s.subject === K27 ? 'K27' : 'K29', s.admin]),
|
||||
[['K29', true], ['K30', true], ['K27', true]]
|
||||
)
|
||||
assert.equal(rebuilds[0].adminFrei, true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Zähler
|
||||
|
||||
test('Abo-Abfragen: die Vorauswahl liest je Kunde einmal, dazu das frische Lesen je bearbeitetem Dokument; mehrere Projekte eines Kunden werden einzeln bearbeitet', async () => {
|
||||
await legeFunktion(1)
|
||||
await legeFunktion(2)
|
||||
await legeFunktion(3, {}, { kunde: 'c2' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' })
|
||||
wartung('canceled', { endedAt: ZUVOR }, KUNDE)
|
||||
wartung('active', {}, 'c2')
|
||||
aw.resetListCalls()
|
||||
assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 }))
|
||||
assert.equal(abfragenAufAbos(), 2 + 2, 'zwei Kunden in der Vorauswahl; frisch nur für die zwei Dokumente von c1 (das von c2 braucht nichts)')
|
||||
assert.equal(mailsMit(K29).length, 2)
|
||||
assert.deepEqual(mails().map((m) => m.to[0].address), ['silke@example.com', 'silke@example.com'])
|
||||
assert.ok(funktion(1).aboEndeAt && funktion(2).aboEndeAt)
|
||||
assert.ok(!funktion(3).aboEndeAt)
|
||||
})
|
||||
|
||||
test('now muss ein gültiges Datum sein (Programmierfehler, wie bei den Terminplaner-Jobs)', async () => {
|
||||
await assert.rejects(() => gleicheZeitenAboAb(undefined), TypeError)
|
||||
await assert.rejects(() => gleicheZeitenAboAb(new Date('x')), TypeError)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- R25: activate() räumt das Abo-Ende ab
|
||||
|
||||
test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Marker und disabledAt); zahlungMeldung bleibt', async () => {
|
||||
await legeFunktion(1, {
|
||||
state: 'disabled',
|
||||
enabled: false,
|
||||
disabledAt: ZUVOR,
|
||||
disabledReason: 'abo_ende',
|
||||
aboEndeMailAt: ZUVOR,
|
||||
aboEndeAt: ZUVOR,
|
||||
behaltenSessionId: 'cs_test_offen',
|
||||
zahlungMeldung: 'abo_ende',
|
||||
})
|
||||
// der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection)
|
||||
const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER })
|
||||
assert.deepEqual([doc.state, doc.enabled], ['active', true])
|
||||
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
|
||||
assert.equal(doc.zahlungMeldung, 'abo_ende')
|
||||
// Folge: ein abgeschalteter Lauf des Abgleichs sieht ein aktives Dokument ohne Abo-Ende
|
||||
laeuft()
|
||||
assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => {
|
||||
const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' }
|
||||
await legeFunktion(1, offen)
|
||||
const ohne = await activate(funktionsId(1), { adminNote: 'Test' })
|
||||
assert.equal(ohne.state, 'active')
|
||||
assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen'])
|
||||
|
||||
await aw.seed('portalFeatures', { ...funktion(1), ...offen })
|
||||
const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' })
|
||||
assert.equal(team.state, 'active')
|
||||
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 K30 und das Ersatz-Ticket nach', async () => {
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K30).length, 1)
|
||||
assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// activate() ist der gemeinsame Schreibpfad aller Funktionen (News, Terminplaner, Zeiten). Die neuen Zeiten-Felder von portalFeatures
|
||||
// (unlockedBy, aboEndeAt, einbau, behaltenSessionId, einschaltMailAt) legt erst scripts/zeiten-setup.mjs an: laeuft der Code vor diesem Skript,
|
||||
// darf die Aktivierung von News und Terminplaner nie ein Zeiten-Feld schreiben (Appwrite: "Unknown attribute"). Der Nachbau hier hat
|
||||
// darum genau das Schema OHNE die Zeiten-Felder; nur der Zeiten-Pfad (extra) schreibt sie.
|
||||
const ZEITEN_FELDER = ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)
|
||||
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures').filter((k) => !ZEITEN_FELDER.includes(k)) } })
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { activate, adminActivate } = await import('../server/services/siteFeatures.js')
|
||||
|
||||
const ZUVOR = '2026-09-20T10:00:00.000Z'
|
||||
const lesen = (id) => aw.docs('portalFeatures').get(id)
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins' })
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
|
||||
|
||||
for (const key of ['news', 'termine']) {
|
||||
test(`activate() einer abgeschalteten Funktion (${key}) schreibt keine Zeiten-Felder und scheitert nicht ohne zeiten-setup`, async () => {
|
||||
const id = `f_${key}`
|
||||
aw.seed('portalFeatures', {
|
||||
$id: id, customerId: 'c1', projectId: 'p1', featureKey: key, state: 'disabled', enabled: false,
|
||||
disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeMailAt: ZUVOR,
|
||||
})
|
||||
const doc = await activate(id, { by: 'kenso' })
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.disabledAt, doc.disabledReason, doc.aboEndeMailAt], ['active', true, null, null, null])
|
||||
for (const feld of ZEITEN_FELDER) assert.equal(feld in lesen(id), false, `${feld} wurde nicht geschrieben`)
|
||||
})
|
||||
}
|
||||
|
||||
test('adminActivate() (News) schreibt keine Zeiten-Felder', async () => {
|
||||
aw.seed('portalFeatures', { $id: 'f_admin', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false })
|
||||
const doc = await adminActivate('c1', 'p1', 'news', { by: 'kenso' })
|
||||
assert.equal(doc.state, 'active')
|
||||
})
|
||||
@@ -0,0 +1,802 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// "Fuer 30 EUR behalten" (Aufgabe 20, Spec 3.3): nach dem Ende des Wartungs-Abos behaelt der Kunde Oeffnungszeiten live fuer einmalig
|
||||
// 30 EUR, ohne neues Einbau-Ticket. POST /api/funktionen/zeiten/behalten (starteBehalten), Webhook-Zweig zweck 'behalten'
|
||||
// (bucheBehalten in zeiten/behalten.js, vor markPaid/adoptPaidSession) und createFeatureCheckout mit zweck.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||
},
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
|
||||
featureConsents: ['acceptedAt'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
|
||||
const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { zustimmungFuerSitzung } = await import('../server/services/featureConsents.js')
|
||||
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
|
||||
const { speichereZeiten, speichereTeam, loescheZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||
const { bucheBehalten } = await import('../server/services/zeiten/behalten.js')
|
||||
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const Z = config.features.zeiten
|
||||
const MODUS = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }
|
||||
const c1 = customerCookie('c1')
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const ABO_ENDE = '2026-10-01T06:00:00.000Z'
|
||||
const ZUVOR = '2026-09-20T10:00:00.000Z'
|
||||
const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.'
|
||||
const SUBJECT = {
|
||||
K15: 'Deine Bestellung: Öffnungszeiten live',
|
||||
T16: 'Neue Funktion bestellt: Öffnungszeiten live',
|
||||
T19: '[Abo] Erstattung fällig: Öffnungszeiten live-Einrichtung',
|
||||
}
|
||||
|
||||
const projekt = (id, over = {}) =>
|
||||
aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
|
||||
|
||||
const bezahlteHaken = () => Z.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||||
const koerper = (projectId = 'p1', extra = {}) => ({ projectId, zustimmung: { version: Z.consent.version, haken: bezahlteHaken() }, ...extra })
|
||||
const behalten = (body, cookie = c1) => post('/api/funktionen/zeiten/behalten', body, { cookie })
|
||||
const checkout = (body, cookie = c1) => post('/api/funktionen/zeiten/checkout', body, { cookie })
|
||||
|
||||
const sessionAufrufe = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
|
||||
const preisDerSitzung = (aufruf) => st.prices.get(aufruf.body['line_items[0][price]'])
|
||||
const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`)
|
||||
const tickets = () => [...aw.docs('workorders').values()]
|
||||
const nachweise = () => [...aw.docs('featureConsents').values()]
|
||||
/** Appwrite liefert Datumsfelder als "...+00:00": verglichen wird der Zeitpunkt. */
|
||||
const zeit = (wert) => (wert ? Date.parse(wert) : null)
|
||||
const mailsMit = (subject) => sink.sent.filter((m) => m.subject === subject)
|
||||
const spuren = () => ({ sitzungen: sessionAufrufe().length, nachweise: nachweise().length, mails: sink.sent.length, tickets: tickets().length, neubauten: rebuilds.length })
|
||||
const keineSpuren = () => assert.deepEqual(spuren(), { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 })
|
||||
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
|
||||
const webhook = (type, session) => postWebhook(event(type, session))
|
||||
|
||||
/** Ist die Sperre gerade frei? Eine gehaltene Sperre laesst die Probe warten: nach 60 ms gilt sie als gehalten. */
|
||||
const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))])
|
||||
|
||||
/** Dokument der Zeiten wie nach dem Einschalten im Wartungs-Abo, dessen Abo inzwischen endete (aboEndeAt gesetzt). */
|
||||
const seedeZeiten = (over = {}) =>
|
||||
aw.seed('portalFeatures', {
|
||||
$id: 'f_p1_zeiten',
|
||||
customerId: 'c1',
|
||||
projectId: 'p1',
|
||||
featureKey: 'zeiten',
|
||||
state: 'active',
|
||||
enabled: true,
|
||||
unlockedBy: 'abo',
|
||||
unlockedByPurchase: false,
|
||||
purchaseStatus: '',
|
||||
siteKey: SITE_KEY,
|
||||
ticketId: 't_alt',
|
||||
activatedAt: ZUVOR,
|
||||
einbau: 'angestossen',
|
||||
aboEndeAt: ABO_ENDE,
|
||||
...over,
|
||||
})
|
||||
/** Abgeschaltet nach dem Abo-Ende (Frist verstrichen), Eingaben noch da. */
|
||||
const seedeAbgeschaltet = (over = {}) =>
|
||||
seedeZeiten({ state: 'disabled', enabled: false, disabledAt: '2026-10-02T06:00:00.000Z', disabledReason: 'abo_ende', aboEndeMailAt: '2026-10-02T06:05:00.000Z', ...over })
|
||||
|
||||
function tage(woche = {}) {
|
||||
const t = {}
|
||||
for (let d = 1; d <= 7; d += 1) {
|
||||
const w = woche[d]
|
||||
t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] }
|
||||
}
|
||||
return t
|
||||
}
|
||||
const EIN_PLAN = () => ({
|
||||
land: 'RP',
|
||||
plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }],
|
||||
feiertage: {},
|
||||
ausnahmen: [],
|
||||
hinweise: [],
|
||||
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
|
||||
})
|
||||
const mitZeiten = () => speichereZeiten({ feature: zeitenDoc(), eingabe: EIN_PLAN(), version: 0, now: new Date('2026-10-05T08:00:00.000Z') })
|
||||
|
||||
/** Behalten-Weg bis zur Stripe-Sitzung: die Route antwortet 200 und das Dokument traegt die Sitzung. */
|
||||
async function starteBehalten(projectId = 'p1') {
|
||||
const res = await behalten(koerper(projectId))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const sessionId = zeitenDoc(projectId).behaltenSessionId
|
||||
assert.match(sessionId, /^cs_test_/)
|
||||
return sessionId
|
||||
}
|
||||
/** Bezahlte Sitzung im Ereignis, mit den Metadaten, die createFeatureCheckout gesetzt hat; die Rechnung liegt im Stripe-Nachbau. */
|
||||
function bezahltAus(sessionId) {
|
||||
const invoice = `in_${sessionId}`
|
||||
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
|
||||
return { ...st.checkoutSessions.get(sessionId), payment_status: 'paid', status: 'complete', invoice }
|
||||
}
|
||||
/** Bezahlte Sitzung, die nicht aus diesem Portal-Lauf stammt (z. B. der zweite Tab): Metadaten wie beim Behalten. */
|
||||
function fremdBezahlt(sessionId, { projectId = 'p1', customerId = 'c1', featureKey = 'zeiten' } = {}) {
|
||||
const invoice = `in_${sessionId}`
|
||||
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
|
||||
return {
|
||||
id: sessionId,
|
||||
object: 'checkout.session',
|
||||
mode: 'payment',
|
||||
payment_status: 'paid',
|
||||
status: 'complete',
|
||||
invoice,
|
||||
metadata: { customerId, projectId, featureKey, zweck: 'behalten', consentAt: '2026-10-05T08:00:00.000Z' },
|
||||
}
|
||||
}
|
||||
|
||||
let sink
|
||||
let rebuilds
|
||||
let sperrenBeimVersand
|
||||
const originalRebuild = zeitenDeps.rebuild
|
||||
beforeEach(async () => {
|
||||
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||||
}
|
||||
resetZeitenCacheForTests()
|
||||
st.checkoutSessions.clear()
|
||||
st.invoices.clear()
|
||||
st.calls.length = 0
|
||||
seedFixtures(aw)
|
||||
projekt('p1')
|
||||
projekt('p3', { customerId: 'c3' })
|
||||
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
|
||||
config.zeiten.mode = 'pilot'
|
||||
config.zeiten.pilotEmails = ['*']
|
||||
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar']) delete Z[name]
|
||||
sink = mailSink()
|
||||
// Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (K15 und T19 gehen erst nach der Sperre raus)
|
||||
sperrenBeimVersand = []
|
||||
const roh = sink.sendMail.bind(sink)
|
||||
sink.sendMail = async (mail) => {
|
||||
sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1'), order: await sperreFrei('feature-order:c1:p1') })
|
||||
return roh(mail)
|
||||
}
|
||||
setMailTransportForTests(sink)
|
||||
rebuilds = []
|
||||
zeitenDeps.rebuild = async (p) => {
|
||||
rebuilds.push({
|
||||
projektId: p.$id,
|
||||
tickets: aw.docs('workorders').size,
|
||||
state: zeitenDoc(p.$id)?.state,
|
||||
unlockedBy: zeitenDoc(p.$id)?.unlockedBy,
|
||||
adminFrei: await sperreFrei(`feature-admin:${p.$id}`),
|
||||
})
|
||||
return { queued: true }
|
||||
}
|
||||
mock.method(console, 'error', () => {})
|
||||
mock.method(console, 'warn', () => {})
|
||||
mock.method(console, 'log', () => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
mock.restoreAll()
|
||||
zeitenDeps.rebuild = originalRebuild
|
||||
})
|
||||
after(() => {
|
||||
config.zeiten.mode = MODUS.mode
|
||||
config.zeiten.pilotEmails = MODUS.pilotEmails
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------- Behalten starten
|
||||
|
||||
test('Behalten nur nach Abo-Ende, sonst 409', async () => {
|
||||
const leeren = async () => {
|
||||
for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||||
}
|
||||
// [Name, Dokument oder null, Eingaben gespeichert?]
|
||||
const faelle = [
|
||||
['kein Dokument', null, false],
|
||||
['aus', () => seedeZeiten({ state: 'off', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false],
|
||||
['bestellt', () => seedeZeiten({ state: 'ordered', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false],
|
||||
['aktiv im Abo, ohne Abo-Ende', () => seedeZeiten({ aboEndeAt: null }), true],
|
||||
['in Einrichtung im Abo, ohne Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, aboEndeAt: null }), true],
|
||||
['vom Team abgeschaltet', () => seedeAbgeschaltet({ disabledReason: 'team' }), true],
|
||||
['abgeschaltet vor der Einrichtung', () => seedeAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung' }), true],
|
||||
['abgeschaltet nach Abo-Ende, Eingaben gelöscht', () => seedeAbgeschaltet(), false],
|
||||
['abgeschaltet nach Abo-Ende, nur Team-Einstellungen (keine Eingaben des Kunden)', () => seedeAbgeschaltet(), 'team'],
|
||||
]
|
||||
for (const [name, vorbereiten, daten] of faelle) {
|
||||
await leeren()
|
||||
vorbereiten?.()
|
||||
if (daten === true) await mitZeiten()
|
||||
if (daten === 'team') await speichereTeam({ feature: zeitenDoc(), eingabe: { navSelektor: '' }, teamVersion: 0, now: new Date('2026-10-05T08:00:00.000Z') })
|
||||
const vorher = zeitenDoc() ? { ...zeitenDoc() } : null
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 409, `${name}: ${JSON.stringify(res.body)}`)
|
||||
assert.equal(res.body.error, NICHT_MOEGLICH, name)
|
||||
keineSpuren()
|
||||
if (vorher) assert.deepEqual({ ...zeitenDoc() }, vorher, `${name}: Dokument unverändert`)
|
||||
else assert.equal(zeitenDoc(), undefined, `${name}: kein Dokument angelegt`)
|
||||
}
|
||||
|
||||
// dagegen möglich: in Einrichtung oder aktiv mit Abo-Ende, und abgeschaltet nach Abo-Ende mit noch vorhandenen Eingaben
|
||||
for (const [name, vorbereiten] of [
|
||||
['aktiv mit Abo-Ende', () => seedeZeiten()],
|
||||
['in Einrichtung mit Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null })],
|
||||
['abgeschaltet nach Abo-Ende, Eingaben da', () => seedeAbgeschaltet()],
|
||||
]) {
|
||||
await leeren()
|
||||
vorbereiten()
|
||||
await mitZeiten()
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
|
||||
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_test_/, name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Läuft das Wartungs-Abo wieder, ist Behalten gesperrt (409, die Funktion ist enthalten); Lesefehler beim Abo ⇒ 503; beides ohne Spur (R25)', async () => {
|
||||
const wartungLaeuft = (over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'active', ...over }))
|
||||
const LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
|
||||
// aktiv mit Abo-Ende, die Zahlung hat schon begonnen (offene Sitzung): kehrt das Abo zurück, gibt es auch die alte Seite nicht mehr heraus
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
const davor = spuren()
|
||||
wartungLaeuft()
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 409, JSON.stringify(res.body))
|
||||
assert.equal(res.body.error, LAEUFT)
|
||||
assert.equal(res.body.url, undefined)
|
||||
assert.deepEqual(spuren(), davor, 'keine neue Sitzung, kein Nachweis')
|
||||
assert.equal(zeitenDoc().behaltenSessionId, sessionId)
|
||||
|
||||
// eine vorgemerkte Kündigung zählt als laufend (bis zum Periodenende)
|
||||
wartungLaeuft({ cancelAtPeriodEnd: true })
|
||||
assert.equal((await behalten(koerper())).status, 409)
|
||||
|
||||
// abgeschaltet nach dem Abo-Ende, Eingaben noch da: ebenso
|
||||
for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id)
|
||||
seedeAbgeschaltet()
|
||||
await mitZeiten()
|
||||
const aus = await behalten(koerper())
|
||||
assert.equal(aus.status, 409)
|
||||
assert.equal(aus.body.error, LAEUFT)
|
||||
|
||||
// Abo-Lage nicht lesbar: fail-closed
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
|
||||
const kaputt = await behalten(koerper())
|
||||
assert.equal(kaputt.status, 503)
|
||||
assert.match(kaputt.body.error, /nicht prüfbar/)
|
||||
assert.equal(JSON.stringify(kaputt.body).includes('Appwrite'), false)
|
||||
|
||||
// ohne laufendes Wartungs-Abo (Hosting, beendet) geht es weiter wie gehabt
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'canceled', endedAt: ABO_ENDE }))
|
||||
const frei = await behalten(koerper())
|
||||
assert.equal(frei.status, 200, JSON.stringify(frei.body))
|
||||
assert.equal(sessionAufrufe().length, 2, 'die erste Sitzung und diese; die gesperrten Versuche legten keine an')
|
||||
})
|
||||
|
||||
test('Vorprüfungen wie bei der Bestellung: Kundenansicht 403, gleiche Zustimmung (beide Haken), Projekt, Schalter, Unterlagen; alles ohne Spur', async () => {
|
||||
seedeZeiten()
|
||||
// Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden)
|
||||
const ansicht = await behalten(koerper(), kundenansichtCookie())
|
||||
assert.equal(ansicht.status, 403)
|
||||
assert.equal(ansicht.body.error, 'In der Kundenansicht kann nicht bestellt werden.')
|
||||
// Zustimmung: ohne, nur der Haken des Gratisweges, falsche Fassung
|
||||
const ohne = await behalten({ projectId: 'p1' })
|
||||
assert.deepEqual([ohne.status, ohne.body.code], [400, 'zustimmung_fehlt'])
|
||||
const nurEiner = await behalten({ projectId: 'p1', zustimmung: { version: Z.consent.version, haken: ['leistung'] } })
|
||||
assert.deepEqual([nurEiner.status, nurEiner.body.code], [400, 'zustimmung_fehlt'])
|
||||
const alt = await behalten(koerper('p1', { zustimmung: { version: '2020-01-01', haken: bezahlteHaken() } }))
|
||||
assert.deepEqual([alt.status, alt.body.code], [409, 'fassung_veraltet'])
|
||||
assert.equal(alt.body.consent.version, Z.consent.version)
|
||||
// Projekt: keines, fremdes
|
||||
assert.equal((await behalten({ zustimmung: koerper().zustimmung })).status, 400)
|
||||
assert.equal((await behalten(koerper('p3'))).status, 403)
|
||||
// Schalter und Unterlagen
|
||||
config.zeiten.mode = 'off'
|
||||
const aus = await behalten(koerper())
|
||||
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar'])
|
||||
config.zeiten.mode = 'pilot'
|
||||
Z.unterlagenBereit = async () => false
|
||||
const unterlagen = await behalten(koerper())
|
||||
assert.deepEqual([unterlagen.status, unterlagen.body.code], [503, 'unterlagen_fehlen'])
|
||||
delete Z.unterlagenBereit
|
||||
keineSpuren()
|
||||
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
|
||||
})
|
||||
|
||||
test('während der Zahlung bleibt der Zustand (Leisten bleiben live); Sitzung, Nachweis und Metadaten', async () => {
|
||||
seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' })
|
||||
const vorher = { ...zeitenDoc() }
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body), ['url'])
|
||||
const sessionId = zeitenDoc().behaltenSessionId
|
||||
assert.equal(res.body.url, `https://checkout.stripe.test/c/pay/${sessionId}`)
|
||||
|
||||
// einzige Aenderung am Dokument: behaltenSessionId (Zustand, Freischaltung, Frist, Rechnung bleiben)
|
||||
const nachher = { ...zeitenDoc() }
|
||||
const { behaltenSessionId, $updatedAt, ...rest } = nachher
|
||||
const { behaltenSessionId: _vorher, $updatedAt: _u, ...restVorher } = vorher
|
||||
assert.equal(behaltenSessionId, sessionId)
|
||||
assert.deepEqual(rest, restVorher)
|
||||
assert.deepEqual([nachher.state, nachher.enabled, nachher.unlockedBy, nachher.unlockedByPurchase, zeit(nachher.aboEndeAt)], ['active', true, 'abo', false, zeit(ABO_ENDE)])
|
||||
const live = await sf.findActiveBySiteKey(SITE_KEY, 'zeiten', { states: ['setup', 'active'] })
|
||||
assert.equal(live?.$id, 'f_p1_zeiten', 'die Leisten antworten weiter')
|
||||
|
||||
// Stripe: einmalig 30 EUR, Metadaten und Rechnungs-Metadaten mit zweck, Rueckkehr zum Editor
|
||||
assert.equal(sessionAufrufe().length, 1)
|
||||
const aufruf = sessionAufrufe()[0].body
|
||||
assert.equal(preisDerSitzung(sessionAufrufe()[0]).unit_amount, 3000)
|
||||
assert.equal(aufruf.mode, 'payment')
|
||||
assert.equal(aufruf['metadata[zweck]'], 'behalten')
|
||||
assert.equal(aufruf['metadata[featureKey]'], 'zeiten')
|
||||
assert.equal(aufruf['metadata[projectId]'], 'p1')
|
||||
assert.equal(aufruf['metadata[customerId]'], 'c1')
|
||||
assert.match(aufruf['metadata[consentAt]'], /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
|
||||
assert.equal(aufruf['invoice_creation[invoice_data][metadata][zweck]'], 'behalten')
|
||||
assert.equal(aufruf['invoice_creation[invoice_data][metadata][featureKey]'], 'zeiten')
|
||||
assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?behalten=1&projekt=p1`)
|
||||
assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`)
|
||||
|
||||
// Nachweis der Zustimmung (die der Bestellung: beide Haken) traegt die Sitzung und den Zeitpunkt des Klicks
|
||||
assert.equal(nachweise().length, 1)
|
||||
const nachweis = await zustimmungFuerSitzung(sessionId)
|
||||
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], ['c1', 'p1', 'zeiten'])
|
||||
assert.equal(zeit(nachweis.acceptedAt), zeit(st.checkoutSessions.get(sessionId).metadata.consentAt))
|
||||
assert.deepEqual(JSON.parse(nachweis.texte).filter((t) => t.haken).map((t) => t.id), bezahlteHaken())
|
||||
|
||||
// sonst nichts: keine Mail, kein Ticket, kein Neubau
|
||||
assert.deepEqual({ ...spuren(), sitzungen: 0, nachweise: 0 }, { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 })
|
||||
})
|
||||
|
||||
test('während der Zahlung bleibt auch ein abgeschaltetes Dokument abgeschaltet', async () => {
|
||||
seedeAbgeschaltet()
|
||||
await mitZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual(
|
||||
[doc.state, doc.enabled, doc.disabledReason, zeit(doc.disabledAt), doc.unlockedBy, zeit(doc.aboEndeAt), doc.behaltenSessionId],
|
||||
['disabled', false, 'abo_ende', zeit('2026-10-02T06:00:00.000Z'), 'abo', zeit(ABO_ENDE), sessionId]
|
||||
)
|
||||
})
|
||||
|
||||
test('zweiter Klick: dieselbe offene Zahlungsseite; abgelaufene Sitzung ⇒ neue; bezahlt oder in Prüfung ⇒ 409', async () => {
|
||||
seedeZeiten()
|
||||
const erste = await starteBehalten()
|
||||
const zweiter = await behalten(koerper())
|
||||
assert.equal(zweiter.status, 200)
|
||||
assert.equal(zweiter.body.url, `https://checkout.stripe.test/c/pay/${erste}`)
|
||||
assert.equal(sessionAufrufe().length, 1, 'keine zweite Stripe-Sitzung')
|
||||
assert.equal(nachweise().length, 1, 'kein zweiter Nachweis')
|
||||
assert.equal(zeitenDoc().behaltenSessionId, erste)
|
||||
|
||||
// abgelaufen: eine neue Sitzung ersetzt die alte, mit eigenem Nachweis
|
||||
st.checkoutSessions.get(erste).status = 'expired'
|
||||
const dritter = await behalten(koerper())
|
||||
assert.equal(dritter.status, 200)
|
||||
const neue = zeitenDoc().behaltenSessionId
|
||||
assert.notEqual(neue, erste)
|
||||
assert.equal(dritter.body.url, `https://checkout.stripe.test/c/pay/${neue}`)
|
||||
assert.equal(sessionAufrufe().length, 2)
|
||||
assert.equal((await zustimmungFuerSitzung(neue)).stripeSessionId, neue)
|
||||
|
||||
// abgeschlossen (Zahlung eingegangen, Webhook noch nicht verbucht): kein neuer Versuch
|
||||
st.checkoutSessions.get(neue).status = 'complete'
|
||||
const vierter = await behalten(koerper())
|
||||
assert.equal(vierter.status, 409)
|
||||
assert.match(vierter.body.error, /Zahlung/)
|
||||
assert.equal(zeitenDoc().behaltenSessionId, neue)
|
||||
assert.equal(sessionAufrufe().length, 2)
|
||||
})
|
||||
|
||||
test('Nachweis scheitert ⇒ 500 ohne Rohtext, keine URL, behaltenSessionId bleibt leer', async () => {
|
||||
seedeZeiten()
|
||||
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' })
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 500)
|
||||
assert.equal(JSON.stringify(res.body).includes('Appwrite'), false)
|
||||
assert.equal(res.body.url, undefined)
|
||||
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
|
||||
assert.equal(nachweise().length, 0)
|
||||
})
|
||||
|
||||
test('Abruf der offenen Sitzung scheitert ⇒ 503, es entsteht keine zweite Zahlung; unbekannte Sitzung (404) ⇒ neue Sitzung', async () => {
|
||||
seedeZeiten()
|
||||
const erste = await starteBehalten()
|
||||
// 400 statt 5xx: das SDK wiederholt 5xx von selbst, hier zaehlt, dass der Fehler nie zu einer zweiten Sitzung fuehrt
|
||||
st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${erste}`, status: 400, message: 'Stripe lehnt ab' })
|
||||
const res = await behalten(koerper())
|
||||
assert.equal(res.status, 503)
|
||||
assert.equal(res.body.error, 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.')
|
||||
assert.equal(sessionAufrufe().length, 1)
|
||||
assert.equal(zeitenDoc().behaltenSessionId, erste)
|
||||
|
||||
// Stripe kennt die Sitzung nicht mehr: wie abgelaufen
|
||||
st.checkoutSessions.delete(erste)
|
||||
const neu = await behalten(koerper())
|
||||
assert.equal(neu.status, 200, JSON.stringify(neu.body))
|
||||
assert.equal(sessionAufrufe().length, 2)
|
||||
assert.notEqual(zeitenDoc().behaltenSessionId, erste)
|
||||
})
|
||||
|
||||
test('Sperre feature-order: zwei gleichzeitige Klicks legen genau eine Sitzung an', async () => {
|
||||
seedeZeiten()
|
||||
const [a, b] = await Promise.all([behalten(koerper()), behalten(koerper())])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(a.body.url, b.body.url)
|
||||
assert.equal(sessionAufrufe().length, 1)
|
||||
assert.equal(nachweise().length, 1)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------------------------------- Zahlung (Webhook)
|
||||
|
||||
test('Webhook behalten: kauf, Frist weg, kein neues Ticket, K15 behalten', async () => {
|
||||
seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' })
|
||||
const sessionId = await starteBehalten()
|
||||
const res = await webhook('checkout.session.completed', bezahltAus(sessionId))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual(
|
||||
[doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.stripeSessionId],
|
||||
['active', true, 'kauf', true, 'paid', null, '', sessionId]
|
||||
)
|
||||
assert.equal(sf.unlockedByVon(doc), 'kauf')
|
||||
assert.equal(doc.stripeInvoiceId, `in_${sessionId}`)
|
||||
assert.equal(doc.invoiceUrl, `https://invoice.stripe.test/in_${sessionId}`)
|
||||
assert.equal(doc.ticketId, 't_alt', 'das bisherige Ticket bleibt')
|
||||
assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00')
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.match(doc.adminNote, /System: .*behalten.*Sitzung /)
|
||||
assert.equal(doc.zahlungMeldung ?? '', '')
|
||||
|
||||
// kein neues Einbau-Ticket, kein Neubau, kein T16, kein T19
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
|
||||
// K15 mit dem Behalten-Wortlaut, nach der Sperre
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
const k15 = mailsMit(SUBJECT.K15)[0]
|
||||
assert.deepEqual(k15.to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.match(k15.text, /Du behältst Öffnungszeiten live für Dein Projekt, auch ohne Wartungs-Abo\./)
|
||||
assert.match(k15.text, /Deine Einstellungen und Daten bleiben unverändert, und es wird nichts neu eingebaut\./)
|
||||
assert.doesNotMatch(k15.text, /Wir richten/)
|
||||
assert.doesNotMatch(k15.text, /eingebaut ist, schalten wir/)
|
||||
assert.ok(k15.text.includes(`https://invoice.stripe.test/in_${sessionId}`), 'Rechnungsknopf')
|
||||
assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.K15, admin: true, order: true }])
|
||||
|
||||
// Ansicht: freigeschaltet durch Kauf, keine Frist mehr
|
||||
const sicht = await sf.featureView(doc, 'zeiten')
|
||||
assert.deepEqual([sicht.state, sicht.unlockedBy, sicht.aboEndeAt], ['active', 'kauf', null])
|
||||
|
||||
// Widerrufsfrist: der Anker ist der Klick dieser Sitzung (Nachweis mit der Sitzungs-ID)
|
||||
const bezahlt = await sf.paidFeatureOrders('c1')
|
||||
assert.deepEqual(bezahlt.map((b) => [b.featureKey, b.state, zeit(b.vertragAt)]), [['zeiten', 'active', zeit(st.checkoutSessions.get(sessionId).metadata.consentAt)]])
|
||||
})
|
||||
|
||||
test('in Einrichtung mit Abo-Ende: bleibt in Einrichtung (setup), kauf, ohne Neubau und ohne Ticket', async () => {
|
||||
seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, einbau: 'manuell' })
|
||||
const sessionId = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.einbau, doc.ticketId], ['setup', false, 'kauf', null, '', 'manuell', 't_alt'])
|
||||
assert.deepEqual(rebuilds, [])
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
})
|
||||
|
||||
test('Stripe wiederholt den Webhook (auch als async_payment_succeeded): keine zweite Buchung, keine zweite K15, kein T19', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
const gebucht = { ...zeitenDoc() }
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200)
|
||||
assert.deepEqual({ ...zeitenDoc() }, gebucht)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 0)
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('SEPA: completed ohne Geld bucht nichts, async_payment_succeeded bucht', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
const ausstehend = { ...bezahltAus(sessionId), payment_status: 'unpaid' }
|
||||
assert.equal((await webhook('checkout.session.completed', ausstehend)).status, 200)
|
||||
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['abo', sessionId, 0])
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200)
|
||||
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['kauf', '', 1])
|
||||
})
|
||||
|
||||
test('Rechnung (noch) nicht lesbar: gebucht, K15 ohne Rechnungslink', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
const sitzung = { ...bezahltAus(sessionId), invoice: 'in_gibt_es_nicht' }
|
||||
assert.equal((await webhook('checkout.session.completed', sitzung)).status, 200)
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['kauf', sessionId, ''])
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Rechnung ansehen/)
|
||||
})
|
||||
|
||||
test('K15 scheitert: gebucht bleibt gebucht, Webhook 200 (kein T19)', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
sink.sendMail = async () => {
|
||||
throw new Error('SMTP down')
|
||||
}
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['kauf', ''])
|
||||
})
|
||||
|
||||
test('Behalten aus disabled: zurück auf active und Neubau (nach der Sperre), kein neues Ticket', async () => {
|
||||
seedeAbgeschaltet()
|
||||
await mitZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual(
|
||||
[doc.state, doc.enabled, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId],
|
||||
['active', true, null, null, null, null, '']
|
||||
)
|
||||
assert.deepEqual([doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId], ['kauf', true, 'paid', sessionId])
|
||||
assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00', 'der frühere Zeitpunkt der Freischaltung bleibt')
|
||||
assert.equal(doc.ticketId, 't_alt')
|
||||
assert.equal(tickets().length, 0)
|
||||
|
||||
// Neubau: einmal, als der Zustand schon active/kauf war, ausserhalb der Sperre; das Ergebnis steht in einbau
|
||||
assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, state: 'active', unlockedBy: 'kauf', adminFrei: true }])
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.K15)[0].text, /Du behältst/)
|
||||
|
||||
// die Leisten antworten wieder, die Eingaben sind unberuehrt
|
||||
assert.equal((await sf.findActiveBySiteKey(SITE_KEY, 'zeiten'))?.$id, 'f_p1_zeiten')
|
||||
const view = await sf.featureView(doc, 'zeiten')
|
||||
assert.deepEqual([view.state, view.disabledReason, view.loeschungAm, view.unlockedBy], ['active', null, null, 'kauf'])
|
||||
})
|
||||
|
||||
test('Behalten aus disabled ohne frühere Freischaltung: zurück auf setup (kein active) und Neubau', async () => {
|
||||
seedeAbgeschaltet({ activatedAt: null })
|
||||
await mitZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.disabledReason ?? null, doc.einbau], ['setup', false, 'kauf', null, 'angestossen'])
|
||||
assert.equal(rebuilds.length, 1)
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('Behalten aus disabled, Neubau nicht möglich oder wirft: einbau manuell, gebucht bleibt gebucht', async () => {
|
||||
for (const [name, vorbereiten] of [
|
||||
['Runner wirft', () => { zeitenDeps.rebuild = async () => { throw new Error('Runner nicht erreichbar') } }],
|
||||
['kein Repo', () => aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' })],
|
||||
]) {
|
||||
for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||||
sink.sent.length = 0
|
||||
vorbereiten()
|
||||
seedeAbgeschaltet()
|
||||
await mitZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200, name)
|
||||
assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().einbau], ['active', 'kauf', 'manuell'], name)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1, name)
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------------------------- unpassende Zahlung (T19)
|
||||
|
||||
test('Sitzung passt nicht zu behaltenSessionId ⇒ T19 Erstattung fällig, nichts gebucht, nichts eingerichtet', async () => {
|
||||
seedeZeiten({ behaltenSessionId: 'cs_andere' })
|
||||
const vorher = { ...zeitenDoc() }
|
||||
const res = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
|
||||
// Zustand, Freischaltung, Frist und offene Zahlung bleiben; nur Vermerk und Marker kommen dazu
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, zeit(doc.aboEndeAt), doc.behaltenSessionId, doc.purchaseStatus], ['active', 'abo', false, zeit(ABO_ENDE), 'cs_andere', ''])
|
||||
assert.equal(doc.stripeSessionId ?? '', vorher.stripeSessionId ?? '')
|
||||
assert.equal(doc.zahlungMeldung, 'gemeldet')
|
||||
assert.match(doc.adminNote, /cs_fremd/)
|
||||
|
||||
// T19: eine Mail ans Team mit der Sitzung, nach dem Abo-Ende, ohne "Ticket offen", ohne Rechnung des alten Dokuments; keine K15, T16
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
const t19 = mailsMit(SUBJECT.T19)[0]
|
||||
assert.match(t19.text, /cs_fremd/)
|
||||
assert.match(t19.text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.doesNotMatch(t19.text, /Ticket noch offen/)
|
||||
assert.ok(t19.text.includes('https://invoice.stripe.test/in_cs_fremd'), 'Rechnung dieser Sitzung')
|
||||
assert.match(t19.text, /30 €/)
|
||||
assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.T19, admin: true, order: true }], 'T19 erst nach der Sperre')
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 0)
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
|
||||
// Stripe wiederholt: genau ein T19
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', fremdBezahlt('cs_fremd'))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
})
|
||||
|
||||
test('schon behalten (zweiter Tab zahlt ebenfalls) ⇒ die zweite Zahlung ist ein T19, das Dokument bleibt wie gebucht', async () => {
|
||||
seedeZeiten()
|
||||
const erste = await starteBehalten()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(erste))).status, 200)
|
||||
const gebucht = { ...zeitenDoc() }
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_zweite'))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_zweite/)
|
||||
assert.ok(mailsMit(SUBJECT.T19)[0].text.includes('in_cs_zweite'), 'Rechnung der zweiten, nicht die der ersten Zahlung')
|
||||
assert.ok(!mailsMit(SUBJECT.T19)[0].text.includes(`in_${erste}`))
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['active', 'kauf', erste, gebucht.invoiceUrl])
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1, 'keine zweite K15')
|
||||
})
|
||||
|
||||
test('Daten gelöscht, bevor die Zahlung ankommt ⇒ T19, das Dokument bleibt abgeschaltet, kein Neubau, keine K15', async () => {
|
||||
seedeAbgeschaltet()
|
||||
await mitZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
await loescheZeiten('f_p1_zeiten')
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.unlockedBy, doc.unlockedByPurchase, doc.behaltenSessionId], ['disabled', false, 'abo_ende', 'abo', false, sessionId])
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T19)[0].text, new RegExp(sessionId))
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('vom Team abgeschaltet, während die Zahlung lief ⇒ T19 (nicht stillschweigend wieder eingeschaltet)', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
aw.seed('portalFeatures', { ...zeitenDoc(), state: 'disabled', enabled: false, disabledAt: '2026-10-03T06:00:00.000Z', disabledReason: 'team' })
|
||||
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
|
||||
assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().disabledReason], ['disabled', 'abo', 'team'])
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
})
|
||||
|
||||
test('ein früheres „gemeldet“ verschluckt das T19 einer neuen unpassenden Zahlung nicht', async () => {
|
||||
seedeZeiten({ behaltenSessionId: 'cs_andere', zahlungMeldung: 'gemeldet' })
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_neu/)
|
||||
assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet', 'danach wieder gemeldet')
|
||||
// eine weitere, andere Zahlung wird wieder gemeldet; die Wiederholung derselben nicht
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_noch_eine'))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 2)
|
||||
})
|
||||
|
||||
test('T19 nicht gesendet ⇒ Webhook 500 und Wiederholung; danach genau ein T19', async () => {
|
||||
seedeZeiten({ behaltenSessionId: 'cs_andere' })
|
||||
const roh = sink.sendMail
|
||||
sink.sendMail = async () => {
|
||||
sink.sendMail = roh
|
||||
throw new Error('SMTP down')
|
||||
}
|
||||
const erster = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
|
||||
assert.equal(erster.status, 500)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
assert.notEqual(zeitenDoc().zahlungMeldung, 'gemeldet')
|
||||
const zweiter = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
|
||||
assert.equal(zweiter.status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet')
|
||||
})
|
||||
|
||||
test('kein Dokument zur Zahlung (oder andere Funktion) ⇒ T16 „ungeklärt“ ans Team, nichts angelegt', async () => {
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_ohne_dok'))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T16)[0].text, /cs_ohne_dok/)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
assert.equal(aw.docs('portalFeatures').size, 0)
|
||||
// Metadaten eines anderen Kunden (Dokument gehört nicht dazu) ⇒ dasselbe
|
||||
seedeZeiten({ behaltenSessionId: 'cs_x' })
|
||||
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_x', { customerId: 'c3' }))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 2)
|
||||
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['abo', 'cs_x'])
|
||||
})
|
||||
|
||||
test('bucheBehalten liefert den Fall: behalten bzw. unpassend', async () => {
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' })
|
||||
assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' }, 'wiederholt: weiterhin behalten, ohne zweite Buchung')
|
||||
assert.deepEqual(await bucheBehalten(fremdBezahlt('cs_zweite')), { fall: 'unpassend' })
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------------------- Zahlung abgebrochen, abgelaufen
|
||||
|
||||
test('Sitzung abgelaufen oder SEPA fehlgeschlagen ⇒ behaltenSessionId wird frei, Zustand bleibt, man kann neu starten', async () => {
|
||||
for (const type of ['checkout.session.expired', 'checkout.session.async_payment_failed']) {
|
||||
for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id)
|
||||
seedeZeiten()
|
||||
const sessionId = await starteBehalten()
|
||||
// eine andere Sitzung desselben Projekts raeumt nichts ab
|
||||
assert.equal((await webhook(type, fremdBezahlt('cs_andere'))).status, 200, type)
|
||||
assert.equal(zeitenDoc().behaltenSessionId, sessionId, type)
|
||||
// die eigene schon
|
||||
const eigene = { ...st.checkoutSessions.get(sessionId), status: type === 'checkout.session.expired' ? 'expired' : 'complete', payment_status: 'unpaid' }
|
||||
assert.equal((await webhook(type, eigene)).status, 200, type)
|
||||
assert.deepEqual([zeitenDoc().behaltenSessionId, zeitenDoc().state, zeitenDoc().unlockedBy, zeit(zeitenDoc().aboEndeAt)], ['', 'active', 'abo', zeit(ABO_ENDE)], type)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 0, type)
|
||||
const neu = await behalten(koerper())
|
||||
assert.equal(neu.status, 200, `${type}: ${JSON.stringify(neu.body)}`)
|
||||
assert.notEqual(zeitenDoc().behaltenSessionId, sessionId, type)
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Kauf unverändert
|
||||
|
||||
test('normaler Kauf-Webhook unverändert: keine Metadaten zweck, bestellt=1, setup/kauf, Neubau, Ticket, K15 „neu“', async () => {
|
||||
const res = await checkout(koerper())
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const aufruf = sessionAufrufe().at(-1).body
|
||||
assert.equal(Object.keys(aufruf).some((k) => k.includes('zweck')), false, 'weder in metadata noch in invoice_data')
|
||||
assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?bestellt=1&projekt=p1`)
|
||||
assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`)
|
||||
const sessionId = [...st.checkoutSessions.values()].at(-1).id
|
||||
assert.equal(zeitenDoc().state, 'ordered')
|
||||
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
|
||||
|
||||
const invoice = `in_${sessionId}`
|
||||
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
|
||||
const paid = { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', invoice, metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'zeiten' } }
|
||||
assert.equal((await webhook('checkout.session.completed', paid)).status, 200)
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId, doc.einbau], ['setup', 'kauf', true, 'paid', sessionId, 'angestossen'])
|
||||
assert.equal(rebuilds.length, 1)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.K15)[0].text, /Wir richten Öffnungszeiten live jetzt für Dein Projekt ein\./)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Du behältst/)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
})
|
||||
|
||||
test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufruf); News und Termine ohne zweck', async () => {
|
||||
const { createFeatureCheckout } = await import('../server/services/stripeBilling.js')
|
||||
const kunde = { $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }
|
||||
await assert.rejects(createFeatureCheckout(kunde, { projectId: 'p1', key: 'zeiten', zweck: 'was_anderes' }), (err) => err.status === 400)
|
||||
assert.equal(sessionAufrufe().length, 0)
|
||||
await createFeatureCheckout(kunde, { projectId: 'p1', key: 'news', consentAt: '2026-10-05T08:00:00.000Z' })
|
||||
const news = sessionAufrufe().at(-1).body
|
||||
assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false)
|
||||
assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`)
|
||||
})
|
||||
@@ -0,0 +1,878 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { config } from '../server/config.js'
|
||||
import { featureConfig, featureHaken, freiweg, oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
|
||||
import { oeffentlicheConsent } from '../server/services/featureOrder.js'
|
||||
import { h } from '../public/termine-dom.js'
|
||||
import { initZeiten } from '../public/zeiten.js'
|
||||
import { behaltenFrist, behaltenLage, renderZeitenBestellen, tagText, zeitenStatusKarte } from '../public/zeiten-bestellen.js'
|
||||
|
||||
// Einschalt-, Bestell- und Behalten-Weg der Öffnungszeiten live im Portal (public/zeiten.js, public/zeiten-bestellen.js; Spec 3.1 bis 3.3,
|
||||
// Rulings R20 und R32). Fake-DOM und Fake-fetch aus test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach
|
||||
// aus fx.antworten. Die Texte der Register (Zustimmungen, Knopf, Belehrung) kommen aus dem echten Register des Servers.
|
||||
|
||||
const REGISTER = featureConfig('zeiten')
|
||||
const BELEHRUNG = await featureHaken('zeiten').belehrung()
|
||||
const STRIPE = 'https://checkout.stripe.test/c/pay/cs_test_1'
|
||||
const KNOPF_BEZAHLT = REGISTER.consent.knopf
|
||||
const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||||
|
||||
// Der Editor im Editorplatz (public/zeiten-editor.js, eigene Tests in test/zeitenEditor.test.js) lädt GET /api/zeiten/<id>: hier mit fester Antwort
|
||||
// (noch nichts gespeichert), ohne die Warteschlange der Antworten und fx.fetchLog zu berühren; fx.editorAufrufe zählt diese Abrufe.
|
||||
const EDITOR_STAND = { state: 'active', daten: null, version: 0, kundenansicht: false, vorschau: null, feiertage: [], googleText: '' }
|
||||
const mitDom = (optionen, fn) => async () => {
|
||||
const fx = installFakeDom(optionen)
|
||||
const echtes = globalThis.fetch
|
||||
fx.editorAufrufe = []
|
||||
globalThis.fetch = async (url, anfrage) => {
|
||||
if (!String(url).startsWith('/api/zeiten/')) return echtes(url, anfrage)
|
||||
fx.editorAufrufe.push(String(url))
|
||||
return { ok: true, status: 200, json: async () => structuredClone(EDITOR_STAND) }
|
||||
}
|
||||
try {
|
||||
await fn(fx)
|
||||
await fx.ruhe() // ein Editor, der noch lädt, soll fertig sein, bevor das Fake-DOM geht
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Fixtures
|
||||
const zeitenFeature = (state = 'off', extra = {}) => ({
|
||||
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
|
||||
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
|
||||
})
|
||||
const projekt = (id = 'p1', state = 'off', feature = {}, extra = {}) => ({
|
||||
projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) }, zeitenStatus: null, zeitenLeer: true, ...extra,
|
||||
})
|
||||
const angebot = (inklusive, grund = null, extra = {}) => ({
|
||||
verfuegbar: true,
|
||||
inklusive,
|
||||
grund,
|
||||
preis: REGISTER.priceLabel,
|
||||
einschalten: { consent: oeffentlicheConsent(freiweg(REGISTER).consent), leistungsbeschreibung: { version: REGISTER.leistungsbeschreibung.version, url: REGISTER.leistungsbeschreibung.url } },
|
||||
belehrung: BELEHRUNG,
|
||||
...extra,
|
||||
})
|
||||
/** Antwort von GET /api/funktionen. */
|
||||
const liste = (projects, zeiten, extra = {}) => ({
|
||||
status: 200,
|
||||
body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten, ...extra },
|
||||
})
|
||||
const fehler = (status, error, code) => ({ status, body: { error, ...(code ? { code } : {}) } })
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Hilfen
|
||||
/** Die Ansicht in einen Behälter hängen und initZeiten laufen lassen. */
|
||||
async function oeffne(fx, { projectId = 'p1', hash = '', dataset = {} } = {}) {
|
||||
const root = h('div', { id: 'zeiten-body' })
|
||||
document.body.append(root)
|
||||
fx.window.location.hash = hash
|
||||
for (const [name, wert] of Object.entries(dataset)) root.dataset[name] = wert
|
||||
await initZeiten(root, { projectId })
|
||||
return root
|
||||
}
|
||||
const knopf = (fx, root, aktion) => fx.finde(root, `[data-aktion="${aktion}"]`)
|
||||
const haken = (fx, root) => fx.alle(root, 'input[type="checkbox"]')
|
||||
const alleAnhaken = async (fx, root) => { for (const feld of haken(fx, root)) if (!feld.checked) await fx.klick(feld) }
|
||||
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="bestellen"]'))
|
||||
const posts = (fx) => fx.fetchLog.filter((aufruf) => aufruf.method === 'POST')
|
||||
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Einschalten
|
||||
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'angestossen' } },
|
||||
liste([projekt('p1', 'setup', { einbau: 'angestossen' })], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
const seite = fx.finde(root, '[data-variante="einschalten"]')
|
||||
assert.ok(seite, 'die Einschaltseite')
|
||||
assert.equal(fx.finde(seite, '.tm-rechtstext'), null, 'ohne Preis keine Widerrufsbelehrung: es gibt kein Widerrufsverlangen')
|
||||
assert.doesNotMatch(fx.text(seite), /30 €|Zahlungspflichtig|einmalig|Widerruf/)
|
||||
assert.match(fx.text(seite), /ohne Aufpreis enthalten/)
|
||||
const link = fx.finde(seite, 'a')
|
||||
assert.equal(link.getAttribute('href'), '/vertragsunterlagen/zeiten-leistungsbeschreibung.html')
|
||||
assert.equal(link.getAttribute('rel'), 'noopener noreferrer')
|
||||
|
||||
const button = knopf(fx, root, 'einschalten')
|
||||
assert.equal(fx.text(button), 'Einschalten', 'der Knopf trägt genau den Text des Registers (consentFrei)')
|
||||
assert.equal(fx.text(button), REGISTER.consentFrei.knopf)
|
||||
assert.equal(button.disabled, true, 'ohne Haken gesperrt')
|
||||
assert.equal(haken(fx, root).length, 1)
|
||||
assert.equal(fx.text(fx.finde(seite, '[data-hinweis="bestellen"]')), 'Setze den Haken, um einzuschalten.')
|
||||
|
||||
await fx.klick(button)
|
||||
assert.equal(posts(fx).length, 0, 'ein gesperrter Knopf sendet nichts')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, false, 'der Haken gibt den Knopf frei')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, true, 'ohne Haken wieder gesperrt')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
|
||||
await fx.klick(button)
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/einschalten',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consentFrei.version, haken: ['leistung'] } },
|
||||
}])
|
||||
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/einschalten', 'GET /api/funktionen'], 'danach wird die Ansicht nach dem neuen Stand gezeichnet')
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'setup')
|
||||
assert.match(fx.text(fx.finde(karte, '[data-danke]')), /Öffnungszeiten live ist eingeschaltet/)
|
||||
assert.match(fx.text(karte), /nach dem nächsten Veröffentlichen/)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null, 'die Einschaltseite ist weg')
|
||||
}))
|
||||
|
||||
test('Einschalten: Fehler 503 und 400 bleiben am Knopf, der Knopf ist danach wieder frei; ein Klick zählt nur einmal', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
fehler(503, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'),
|
||||
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'manuell' } },
|
||||
liste([projekt('p1', 'setup', { einbau: 'manuell', ticketWoid: '4711' })], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
const button = knopf(fx, root, 'einschalten')
|
||||
await fx.klick(button)
|
||||
assert.equal(meldung(fx, root), 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.')
|
||||
assert.equal(button.disabled, false, 'nach dem Fehler wieder frei (Haken bleiben)')
|
||||
assert.equal(haken(fx, root)[0].checked, true)
|
||||
await fx.klick(button)
|
||||
assert.equal(posts(fx).length, 2)
|
||||
assert.equal(fx.text(fx.finde(root, '.zt-status')).includes('#4711'), true, 'Ticketnummer in der Karte')
|
||||
}))
|
||||
|
||||
test('Einschalten: 409 (Abo nicht mehr aktiv) zeichnet die Ansicht nach der neuen Liste mit der Meldung des Servers', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
fehler(409, 'Dein Wartungs-Abo läuft nicht mehr. Du kannst Öffnungszeiten live für einmalig 30 € bestellen.', 'gekuendigt'),
|
||||
liste([projekt()], angebot(false, 'gekuendigt')))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.equal(fx.alle(root, '[data-variante]').length, 1)
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), 'jetzt der bezahlte Weg')
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /läuft nicht mehr/)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, true, 'die Haken sind neu, der Knopf gesperrt')
|
||||
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
|
||||
}))
|
||||
|
||||
test('Einschalten: scheitert das Neuladen nach einem 409, steht die Meldung am Knopf', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(true)), fehler(409, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.'), fehler(500, 'Interner Serverfehler'))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.match(meldung(fx, root), /schon bestellt oder eingeschaltet/)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Bestellen
|
||||
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
const seite = fx.finde(root, '[data-variante="bestellen"]')
|
||||
assert.ok(seite)
|
||||
const alle = fx.alle(root, '*')
|
||||
const stelle = (selektor) => {
|
||||
const el = fx.finde(root, selektor)
|
||||
assert.ok(el, `${selektor} fehlt`)
|
||||
return alle.indexOf(el)
|
||||
}
|
||||
const stellen = ['.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
|
||||
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Preis, Belehrung, Unterlagen, Haken, Knopf in dieser Reihenfolge')
|
||||
assert.equal(new Set(stellen).size, stellen.length)
|
||||
|
||||
// Ruling R32: genau der Text des Nachweises, unverändert, in einem scrollbaren Bereich, nie eingeklappt
|
||||
const bereich = fx.finde(root, '.tm-rechtstext')
|
||||
assert.equal(bereich.textContent, BELEHRUNG)
|
||||
assert.match(bereich.textContent, /Muster-Widerrufsformular/)
|
||||
assert.equal(bereich.getAttribute('tabindex'), '0')
|
||||
assert.equal(fx.alle(root, 'details').length, 0)
|
||||
assert.equal(fx.alle(root, '[hidden]').length, 0)
|
||||
|
||||
assert.match(fx.text(fx.finde(root, '.tm-bestellen-betrag')), /^30 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG$/)
|
||||
assert.match(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung: meist innerhalb weniger Tage/)
|
||||
assert.deepEqual(haken(fx, root).map((feld) => feld.dataset.haken), HAKEN_BEZAHLT, 'die zwei Haken des Registers: Leistungsbeschreibung und Beginn')
|
||||
assert.deepEqual(fx.alle(root, '.tm-haken span').map((el) => fx.text(el)), REGISTER.consent.items.map((item) => item.text), 'die Texte aus dem Register')
|
||||
|
||||
const button = knopf(fx, root, 'bestellen')
|
||||
assert.equal(fx.text(button), KNOPF_BEZAHLT)
|
||||
assert.equal(fx.text(button), 'Zahlungspflichtig bestellen · 30 €')
|
||||
assert.equal(button.disabled, true)
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, true, 'ein Haken von zwei reicht nicht')
|
||||
await fx.klick(haken(fx, root)[1])
|
||||
assert.equal(button.disabled, false)
|
||||
|
||||
await fx.klick(button)
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/checkout',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
|
||||
}])
|
||||
assert.equal(fx.window.location.href, STRIPE, 'Weiterleitung zu Stripe')
|
||||
assert.equal(button.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
|
||||
assert.equal(fx.fetchLog.length, 2, 'nach der Weiterleitung keine weitere Abfrage')
|
||||
}))
|
||||
|
||||
test('Bestellen: ohne Abo, bei vorgemerkter Kündigung und ohne Grund erscheint die Bestellseite; mit Preis aus dem Angebot', mitDom({}, async (fx) => {
|
||||
for (const grund of ['kein_abo', 'gekuendigt', null]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, grund)))
|
||||
const root = await oeffne(fx)
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), String(grund))
|
||||
}
|
||||
}))
|
||||
|
||||
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert', mitDom({}, async (fx) => {
|
||||
for (const url of ['javascript:alert(1)', 'http://stripe.example/x', '/rechtliches/x.html', '//evil.example/x', '', undefined, 5]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url } })
|
||||
fx.window.location.href = 'http://localhost/'
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.window.location.href, 'http://localhost/', `Weiterleitung zu ${String(url)}`)
|
||||
assert.match(meldung(fx, root), /Zahlungsseite konnte nicht geöffnet werden/)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, false, 'der Kunde kann es noch einmal versuchen')
|
||||
}
|
||||
}))
|
||||
|
||||
test('Bestellen: ohne Belehrung oder ohne Zustimmung wird nie bestellt (R32), das Einschalten braucht beides nicht', mitDom({}, async (fx) => {
|
||||
for (const belehrung of [null, '', ' \n', undefined]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo', { belehrung })))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'bestellen'), null, `Belehrung ${JSON.stringify(belehrung)}: kein Bestellknopf`)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
assert.match(fx.text(root), /konnte gerade nicht geladen werden/)
|
||||
}
|
||||
// Zustimmung des Registers fehlt oder ist beschädigt
|
||||
for (const features of [{}, { zeiten: { consent: null } }, { zeiten: { consent: { version: 'v', items: [] } } }]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo'), { features }))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'bestellen'), null, JSON.stringify(features))
|
||||
}
|
||||
// Einschalten ohne Belehrung: geht (kein Widerrufsverlangen)
|
||||
fx.antworten.push(liste([projekt()], angebot(true, null, { belehrung: null })))
|
||||
const root = await oeffne(fx)
|
||||
assert.ok(knopf(fx, root, 'einschalten'))
|
||||
}))
|
||||
|
||||
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', mitDom({}, async (fx) => {
|
||||
const text = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), fehler(409, text, 'inklusive'), liste([projekt()], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/checkout', 'GET /api/funktionen'])
|
||||
assert.equal(fx.finde(root, '[data-variante="bestellen"]'), null)
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die Einschaltseite')
|
||||
assert.equal(fx.text(knopf(fx, root, 'einschalten')), 'Einschalten')
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'die Zustimmung des Gratisweges ist eine andere, der Haken muss neu gesetzt werden')
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
|
||||
assert.equal(fx.finde(root, '.tm-rechtstext'), null)
|
||||
}))
|
||||
|
||||
test('409 fassung_veraltet: die Seite zeigt nach dem Neuladen die neue Fassung mit leeren Haken', mitDom({}, async (fx) => {
|
||||
const neu = oeffentlicheAnsicht('zeiten')
|
||||
neu.consent = { ...neu.consent, version: '2099-01-01', items: neu.consent.items.map((item, i) => (i === 0 ? { ...item, text: 'Neuer Wortlaut des ersten Hakens.' } : item)) }
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(false, 'kein_abo')),
|
||||
fehler(409, 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', 'fassung_veraltet'),
|
||||
liste([projekt()], angebot(false, 'kein_abo'), { features: { zeiten: neu } }),
|
||||
{ status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.text(fx.alle(root, '.tm-haken span')[0]), 'Neuer Wortlaut des ersten Hakens.')
|
||||
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /inzwischen geändert/)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(posts(fx).at(-1).body.zustimmung.version, '2099-01-01', 'gesendet wird die Fassung, die die Seite zeigt')
|
||||
}))
|
||||
|
||||
test('Abo noch nicht aktiv (R20): Hinweis „sobald dein Abo aktiv ist“, kein Knopf, kein Haken; Abo nicht lesbar: Hinweis mit „Noch einmal versuchen“', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'nicht_aktiv')))
|
||||
let root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /einschaltbar, sobald dein Abo aktiv ist/)
|
||||
assert.equal(fx.alle(root, 'button').length, 0)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
assert.doesNotMatch(fx.text(root), /30 €/)
|
||||
|
||||
fx.antworten.push(liste([projekt()], angebot(null, null)), liste([projekt()], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
const nochmal = fx.alle(root, 'button').find((b) => fx.text(b) === 'Noch einmal versuchen')
|
||||
assert.ok(nochmal)
|
||||
await fx.klick(nochmal)
|
||||
assert.ok(knopf(fx, root, 'einschalten'), 'nach dem erneuten Versuch der Einschaltweg')
|
||||
}))
|
||||
|
||||
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', mitDom({}, async (fx) => {
|
||||
fx.window.KbView = { id: () => 'ansicht-1' }
|
||||
fx.antworten.push(liste([projekt()], angebot(true)))
|
||||
let root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'auch mit Haken')
|
||||
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht eingeschaltet werden.')
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, true)
|
||||
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
|
||||
// Behalten und Zahlung fortsetzen in der Karte
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten').disabled, true)
|
||||
assert.match(fx.text(root), /In der Kundenansicht kann nicht bestellt werden/)
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'fortsetzen').disabled, true)
|
||||
assert.equal(posts(fx).length, 0, 'in der Kundenansicht geht keine Anfrage hinaus')
|
||||
|
||||
// ohne Kundenansicht ist nichts gesperrt
|
||||
fx.window.KbView = { id: () => '' }
|
||||
fx.antworten.push(liste([projekt()], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Rückkehr
|
||||
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', mitDom({}, async (fx) => {
|
||||
// Bestellung: der Webhook hat die Zahlung noch nicht gebucht (ordered)
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
let root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=p2' })
|
||||
let karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'ordered')
|
||||
assert.equal(fx.text(fx.finde(karte, '[data-danke="bestellt"]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
|
||||
assert.equal(fx.window.location.hash, '#zeiten', 'bestellt=1 und projekt stehen nicht mehr in der Adresse')
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2', 'das Projekt der Zahlung ist gewählt')
|
||||
// nächster Besuch ohne Flag: kein Dank mehr
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
|
||||
// Bestellung schon gebucht (setup)
|
||||
fx.antworten.push(liste([projekt('p1', 'setup', { unlockedBy: 'kauf', einbau: 'angestossen' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?bestellt=1&projekt=p1' })
|
||||
assert.equal(fx.text(fx.finde(root, '[data-danke="bestellt"]')), 'Danke! Deine Zahlung ist bei uns angekommen.')
|
||||
|
||||
// Behalten: die Zahlung ist unterwegs (aboEndeAt noch gesetzt) bzw. gebucht (aboEndeAt leer, unlockedBy kauf)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
|
||||
assert.match(fx.text(fx.finde(root, '[data-danke="behalten"]')), /^Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live\./)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { unlockedBy: 'kauf' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
|
||||
assert.equal(fx.text(fx.finde(root, '[data-danke="behalten"]')), 'Danke! Du behältst Öffnungszeiten live.')
|
||||
assert.equal(fx.window.location.hash, '#zeiten')
|
||||
|
||||
// Abbruch in Stripe (#zeiten?projekt=p2): das Projekt ist gewählt, kein Dank
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '', hash: '#zeiten?projekt=p2' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
// ein Projekt, das es nicht (mehr) gibt: kein Dank für ein anderes
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=unbekannt' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
// andere Hashes werden nicht angefasst
|
||||
fx.antworten.push(liste([projekt('p1')], angebot(true)))
|
||||
await oeffne(fx, { hash: '#zeitenplan' })
|
||||
assert.equal(fx.window.location.hash, '#zeitenplan')
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Behalten
|
||||
const ABO_ENDE = { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }
|
||||
|
||||
test('Behalten: Hinweis mit Datum in der Karte, Behalten-Seite mit Belehrung, POST /api/funktionen/zeiten/behalten, „Zurück“ zeigt die Karte wieder', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'active')
|
||||
assert.match(fx.text(fx.finde(karte, '[data-hinweis="behalten"]')), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €\./)
|
||||
const editor = fx.finde(root, '.zt-editor')
|
||||
assert.ok(editor, 'der Platz des Editors steht unter der Karte')
|
||||
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
const seite = fx.finde(root, '[data-variante="behalten"]')
|
||||
assert.ok(seite)
|
||||
assert.equal(fx.finde(root, '.zt-status'), null, 'die Seite ersetzt die Karte')
|
||||
assert.equal(editor.hidden, true, 'der Editor bleibt, nur versteckt (nichts geht verloren)')
|
||||
assert.equal(fx.finde(root, '.tm-rechtstext').textContent, BELEHRUNG)
|
||||
assert.match(fx.text(seite), /ohne neuen Einbau/)
|
||||
assert.equal(fx.alle(root, '.tm-bestellen-preis h4').map((el) => fx.text(el))[0], 'Preis')
|
||||
assert.doesNotMatch(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung:/, 'beim Behalten gibt es keinen neuen Einbau')
|
||||
assert.equal(fx.text(knopf(fx, root, 'behalten')), KNOPF_BEZAHLT)
|
||||
|
||||
// zurück: die Karte ist wieder da, der Editor sichtbar
|
||||
await fx.klick(knopf(fx, root, 'zurueck'))
|
||||
assert.ok(fx.finde(root, '.zt-status'))
|
||||
assert.equal(editor.hidden, false)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz, nicht neu gebaut')
|
||||
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/behalten',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
|
||||
}])
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
}))
|
||||
|
||||
test('Behalten: Frist ist der Berliner Tag des Abo-Endes plus 30 Tage; läuft das Abo wieder, gibt es keinen Behalten-Knopf', mitDom({}, async (fx) => {
|
||||
assert.equal(behaltenFrist('2026-10-01T10:00:00.000Z'), '31.10.2026')
|
||||
assert.equal(behaltenFrist('2026-10-01T23:30:00.000Z'), '01.11.2026', 'in Berlin schon der 2. Oktober')
|
||||
assert.equal(behaltenFrist('2026-10-01T10:00:00.000+00:00'), '31.10.2026')
|
||||
assert.equal(behaltenFrist('2026-03-01T10:00:00.000Z'), '31.03.2026')
|
||||
assert.equal(behaltenFrist('kein-datum'), '')
|
||||
assert.equal(behaltenFrist(null), '')
|
||||
assert.equal(tagText('2026-11-04'), '04.11.2026')
|
||||
assert.equal(tagText('4.11.2026'), '')
|
||||
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(true)))
|
||||
let root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, 'das Abo läuft wieder: nichts zu bezahlen')
|
||||
assert.doesNotMatch(fx.text(root), /behalte die Funktion/)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(null, null)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, 'Abo nicht lesbar: kein Bezahlknopf auf Verdacht')
|
||||
}))
|
||||
|
||||
test('Behalten aus der Kachel (dataset.wunschWeg): die Seite öffnet direkt, das Projekt der Kachel ist gewählt; ein Weg ohne Anlass wird ignoriert', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
const root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2', wunschWeg: 'behalten' } })
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
assert.equal(root.dataset.wunschProjekt, undefined, 'die Wünsche werden einmal gelesen')
|
||||
assert.equal(root.dataset.wunschWeg, undefined)
|
||||
|
||||
// kein Abo-Ende: kein Behalten, sondern die Karte
|
||||
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.equal(fx.finde(ohne, '[data-variante]'), null)
|
||||
assert.ok(fx.finde(ohne, '.zt-status'))
|
||||
}))
|
||||
|
||||
test('Behalten nach dem Abschalten (abo_ende) mit Daten: „Für 30 € wieder einschalten“; ohne Daten, nach der Löschfrist oder durch das Team nicht', mitDom({}, async (fx) => {
|
||||
const abgeschaltet = (feature, extra) => projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', ...feature }, extra)
|
||||
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
let root = await oeffne(fx)
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'disabled')
|
||||
assert.match(fx.text(karte), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
|
||||
assert.match(fx.text(fx.finde(karte, '[data-hinweis="wieder-einschalten"]')), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
|
||||
assert.equal(fx.text(knopf(fx, root, 'behalten')), 'Für 30 € wieder einschalten')
|
||||
assert.ok(fx.finde(root, '.zt-editor'), 'die Eingaben bleiben lesbar (Platz des Editors)')
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.match(fx.text(fx.finde(root, '.zt-bestellen > p')), /abgeschaltet, deine Eingaben sind noch gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.equal(posts(fx)[0].url, '/api/funktionen/zeiten/behalten')
|
||||
|
||||
for (const [name, p] of [
|
||||
['keine Daten', abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: true })],
|
||||
['Löschfrist vorbei', abgeschaltet({ loeschungAm: '2000-01-01' }, { zeitenLeer: false })],
|
||||
['vom Team', projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })],
|
||||
]) {
|
||||
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, name)
|
||||
}
|
||||
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
|
||||
// das Abo läuft wieder: der Abgleich schaltet selbst ein
|
||||
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null)
|
||||
assert.match(fx.text(root), /Dein Wartungs-Abo läuft wieder/)
|
||||
}))
|
||||
|
||||
test('Behalten: 409 „Abo läuft wieder“ zeichnet die Ansicht nach der neuen Liste (kein Behalten mehr) mit der Meldung des Servers', mitDom({}, async (fx) => {
|
||||
const text = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')),
|
||||
fehler(409, text),
|
||||
liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null)
|
||||
assert.ok(fx.finde(root, '.zt-status'))
|
||||
}))
|
||||
|
||||
// Fix Runde 1: Behalten (30 €) gibt es nur ohne Abo (inklusive false UND grund kein_abo). nicht_aktiv und gekuendigt heissen, dass das Abo läuft (R20/R25, Z12).
|
||||
test('behaltenLage: bezahlen nur bei inklusive false und kein_abo', () => {
|
||||
assert.equal(behaltenLage(angebot(false, 'kein_abo')), 'bezahlen')
|
||||
assert.equal(behaltenLage(angebot(false, 'nicht_aktiv')), 'abo')
|
||||
assert.equal(behaltenLage(angebot(false, 'gekuendigt')), 'abo')
|
||||
assert.equal(behaltenLage(angebot(true)), 'inklusive')
|
||||
for (const unklar of [angebot(null, null), angebot(false, null), angebot(false, 'unbekannt'), { verfuegbar: true }, undefined, null]) assert.equal(behaltenLage(unklar), 'unklar', JSON.stringify(unklar))
|
||||
})
|
||||
|
||||
test('Behalten bei laufendem Abo (nicht_aktiv, gekuendigt): die Karte sagt „Abo läuft wieder“, es gibt keinen Behalten-Knopf und keine Behalten-Seite', mitDom({}, async (fx) => {
|
||||
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
|
||||
// aktiv mit erkanntem Abo-Ende, auch wenn die Kachel „Für 30 € behalten“ (wunschWeg) schicken wollte
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, grund)))
|
||||
let root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, grund)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null, `${grund}: keine Behalten-Seite`)
|
||||
assert.match(fx.text(fx.finde(root, '[data-hinweis="abo-laeuft"]')), /Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten\./, grund)
|
||||
assert.doesNotMatch(fx.text(root), /ist beendet|behalte die Funktion|Für 30 €/, grund)
|
||||
assert.equal(fx.alle(root, 'input[type="checkbox"]').length, 0)
|
||||
|
||||
// abgeschaltet (abo_ende) mit Daten
|
||||
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, grund)))
|
||||
root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, `${grund}: abgeschaltet`)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
assert.match(fx.text(fx.finde(root, '[data-hinweis="abo-laeuft"]')), /Dein Wartungs-Abo läuft wieder/, grund)
|
||||
assert.doesNotMatch(fx.text(root), /Für 30 €|schaltest du die Funktion wieder ein/, grund)
|
||||
}
|
||||
// unbekannter Grund (false, null): kein Bezahlknopf auf Verdacht
|
||||
for (const an of [angebot(false, null), angebot(false, 'unbekannt')]) {
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], an))
|
||||
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.equal(knopf(fx, root, 'behalten'), null)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], an))
|
||||
const aus = await oeffne(fx)
|
||||
assert.equal(knopf(fx, aus, 'behalten'), null)
|
||||
assert.ok(fx.finde(aus, '[data-hinweis="nicht-pruefbar"]'))
|
||||
}
|
||||
// Gegenprobe: ohne Abo (kein_abo) bleibt Behalten
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.ok(fx.finde(ohne, '[data-variante="behalten"]'))
|
||||
}))
|
||||
|
||||
test('Behalten-Seite direkt gerufen (renderZeitenBestellen, modus behalten): nur bei kein_abo die bezahlte Seite, sonst Hinweis ohne Haken und Knopf', mitDom({}, (fx) => {
|
||||
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
|
||||
const zeige = (an) => {
|
||||
const wurzel = h('div')
|
||||
renderZeitenBestellen(wurzel, { ctx, angebot: an, projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
|
||||
return wurzel
|
||||
}
|
||||
assert.ok(fx.finde(zeige(angebot(false, 'kein_abo')), '[data-variante="behalten"]'))
|
||||
for (const grund of ['nicht_aktiv', 'gekuendigt']) {
|
||||
const wurzel = zeige(angebot(false, grund))
|
||||
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/, grund)
|
||||
assert.equal(fx.alle(wurzel, 'input,button').length, 0, grund)
|
||||
}
|
||||
for (const grund of [null, 'unbekannt']) {
|
||||
const wurzel = zeige(angebot(false, grund))
|
||||
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/, String(grund))
|
||||
assert.equal(fx.alle(wurzel, 'input').length, 0)
|
||||
assert.equal(fx.finde(wurzel, '[data-variante]'), null)
|
||||
}
|
||||
}))
|
||||
|
||||
test('renderZeitenBestellen im Modus behalten: Abo läuft wieder ⇒ Hinweis statt Seite; Abo nicht lesbar ⇒ „Gerade nicht prüfbar“', mitDom({}, (fx) => {
|
||||
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
|
||||
const wurzel = h('div')
|
||||
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(true), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
|
||||
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/)
|
||||
assert.equal(fx.alle(wurzel, 'input').length, 0)
|
||||
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(null), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
|
||||
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Statuskarte
|
||||
test('Statuskarte: Zahlung offen, In Einrichtung (je nach einbau), Aktiv, Abgeschaltet', mitDom({}, (fx) => {
|
||||
const karte = (state, feature = {}, extra = {}, an = angebot(true)) => zeitenStatusKarte({ projekt: projekt('p1', state, feature, extra), feature: zeitenFeature(state, feature), angebot: an, kundenansicht: false })
|
||||
const ordered = karte('ordered', { unlockedBy: null })
|
||||
assert.equal(ordered.getAttribute('data-state'), 'ordered')
|
||||
assert.match(fx.text(ordered), /Zahlung offen/)
|
||||
assert.match(fx.text(ordered), /Zahlung wird bestätigt …/)
|
||||
assert.match(fx.text(ordered), /SEPA/)
|
||||
assert.ok(fx.finde(ordered, '[data-aktion="fortsetzen"]'))
|
||||
|
||||
const angestossen = karte('setup', { einbau: 'angestossen' }, { zeitenLeer: false })
|
||||
assert.match(fx.text(angestossen), /In Einrichtung/)
|
||||
assert.match(fx.text(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website \(meist in wenigen Minuten\)\./)
|
||||
assert.equal(fx.finde(angestossen, '[data-hinweis="zeiten-eintragen"]'), null)
|
||||
assert.equal(fx.finde(angestossen, '[data-aktion="fortsetzen"]'), null)
|
||||
|
||||
const manuell = karte('setup', { einbau: 'manuell', ticketWoid: '4711', orderedAt: '2026-10-04T10:00:00.000Z' }, { zeitenLeer: true })
|
||||
assert.match(fx.text(manuell), /Unser Team baut die Funktion ein\./)
|
||||
assert.doesNotMatch(fx.text(manuell), /nach dem nächsten Veröffentlichen/)
|
||||
assert.match(fx.text(manuell), /Ticketnummer#4711/)
|
||||
assert.match(fx.text(manuell), /Bestellt am04\.10\.2026/)
|
||||
assert.match(fx.text(fx.finde(manuell, '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen\./)
|
||||
|
||||
const rechnung = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'https://invoice.stripe.test/i/1' }), angebot: angebot(false, 'kein_abo') })
|
||||
assert.equal(fx.finde(rechnung, 'a').getAttribute('href'), 'https://invoice.stripe.test/i/1')
|
||||
const boese = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'javascript:alert(1)' }), angebot: angebot(false, 'kein_abo') })
|
||||
assert.equal(fx.finde(boese, 'a'), null, 'unsichere Adressen werden nie Links')
|
||||
|
||||
const aktiv = karte('active', {}, { zeitenLeer: false })
|
||||
assert.match(fx.text(aktiv), /Aktiv/)
|
||||
assert.match(fx.text(aktiv), /Öffnungszeiten live ist aktiv/)
|
||||
assert.equal(fx.finde(aktiv, '[data-hinweis="zeiten-eintragen"]'), null)
|
||||
assert.match(fx.text(fx.finde(karte('active', {}, { zeitenLeer: true }), '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein\./)
|
||||
|
||||
const aus = karte('disabled', { disabledReason: 'team' }, { zeitenLeer: false })
|
||||
assert.match(fx.text(aus), /Abgeschaltet/)
|
||||
assert.match(fx.text(aus), /Schreib uns im Chat/)
|
||||
assert.ok(fx.finde(aus, '[data-view="chat"]'))
|
||||
}))
|
||||
|
||||
test('Statuskarte: Zahlung fortsetzen führt zur offenen Zahlungsseite; ohne offene Sitzung zeigt die Ansicht wieder die Bestellseite', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
let root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/zeiten/checkout', body: { projectId: 'p1', fortsetzen: true } }])
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
|
||||
// keine offene Zahlungsseite mehr (400 zustimmung_fehlt): die Bestellseite mit der Meldung des Servers; Bestellen legt eine neue Sitzung an
|
||||
fx.window.location.href = 'http://localhost/'
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')),
|
||||
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
{ status: 200, body: { url: STRIPE } })
|
||||
root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'))
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /stimme der Erklärung zu/)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
|
||||
// andere Fehler stehen am Knopf, der Knopf ist wieder frei
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), fehler(500, 'Interner Serverfehler'))
|
||||
root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="karte"]')), 'Interner Serverfehler')
|
||||
assert.equal(knopf(fx, root, 'fortsetzen').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Ansicht
|
||||
test('Projektauswahl: bei mehreren Websites eine Auswahl; der Wunsch der Kachel gilt einmal; ein Wechsel zeichnet das andere Projekt', mitDom({}, async (fx) => {
|
||||
const projekte = [projekt('p1', 'active', {}, { zeitenLeer: false }), projekt('p2'), projekt('p3', 'setup')]
|
||||
fx.antworten.push(liste(projekte, angebot(true)))
|
||||
let root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2' } })
|
||||
const auswahl = fx.finde(root, '#zeiten-project')
|
||||
assert.deepEqual(fx.alle(auswahl, 'option').map((o) => o.getAttribute('value')), ['p1', 'p2', 'p3'])
|
||||
assert.equal(auswahl.value, 'p2')
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'p2 ist aus')
|
||||
await fx.setze(auswahl, 'p3')
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'setup')
|
||||
await fx.setze(auswahl, 'p1')
|
||||
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
|
||||
|
||||
// ohne Wunsch: die gemerkte Auswahl, bei neuer Sitzung aktiv vor Einrichtung vor aus
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'setup'), projekt('p3', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx, { projectId: '', dataset: {} })
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p1', 'die Auswahl von eben bleibt')
|
||||
fx.antworten.push(liste([projekt('p9'), projekt('p8', 'setup'), projekt('p7', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx, { projectId: '' })
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p7', 'ein unbekanntes Projekt: das aktive')
|
||||
|
||||
// genau eine Website: keine Auswahl
|
||||
fx.antworten.push(liste([projekt('p1')], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(fx.finde(root, '#zeiten-project'), null)
|
||||
}))
|
||||
|
||||
test('Ansicht: nicht verfügbar und keine Website', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], { verfuegbar: false }))
|
||||
let root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Öffnungszeiten live ist noch nicht verfügbar/)
|
||||
assert.equal(fx.alle(root, 'input,button').length, 0)
|
||||
fx.antworten.push(liste([projekt()], undefined))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /noch nicht verfügbar/)
|
||||
fx.antworten.push(liste([], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen/)
|
||||
// ein Fehler der Liste kommt bei app.js an (zeigt ihn in der Ansicht)
|
||||
fx.antworten.push(fehler(500, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.'))
|
||||
await assert.rejects(oeffne(fx), /konnten gerade nicht geladen werden/)
|
||||
}))
|
||||
|
||||
test('Ansicht: der Editorplatz steht nur in setup, active und (mit Daten) disabled; nie in off und ordered', mitDom({}, async (fx) => {
|
||||
const platz = async (p) => {
|
||||
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
|
||||
return fx.finde(await oeffne(fx), '.zt-editor')
|
||||
}
|
||||
assert.equal(await platz(projekt('p1', 'off')), null)
|
||||
assert.equal(await platz(projekt('p1', 'ordered', { unlockedBy: null })), null)
|
||||
assert.ok(await platz(projekt('p1', 'setup')))
|
||||
assert.ok(await platz(projekt('p1', 'active')))
|
||||
assert.ok(await platz(projekt('p1', 'disabled', { disabledReason: 'abo_ende' }, { zeitenLeer: false })))
|
||||
assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null)
|
||||
}))
|
||||
|
||||
test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand zeichnet nur den Kopf neu, die Rückkehr von Stripe alles', mitDom({}, async (fx) => {
|
||||
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
|
||||
fx.antworten.push(aktiv())
|
||||
const root = await oeffne(fx)
|
||||
await fx.ruhe() // der Editor lädt (fester Stand, siehe mitDom)
|
||||
const editor = fx.finde(root, '.zt-editor')
|
||||
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'])
|
||||
assert.ok(fx.finde(editor, 'details[data-plan]'), 'der Editor steht im Platz')
|
||||
const eingabe = h('input', { type: 'text' })
|
||||
eingabe.value = 'ungespeichert'
|
||||
editor.append(eingabe)
|
||||
|
||||
// zweiter Besuch: nur die Liste wird geholt, nichts neu gezeichnet
|
||||
fx.antworten.push(aktiv())
|
||||
await initZeiten(root, { projectId: 'p1' })
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz')
|
||||
assert.equal(eingabe.parentNode, editor)
|
||||
assert.equal(eingabe.value, 'ungespeichert')
|
||||
assert.equal(fx.fetchLog.length, 2)
|
||||
assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage')
|
||||
|
||||
// die Kachel schickt dasselbe Projekt: ebenfalls kein Neuaufbau
|
||||
root.dataset.wunschProjekt = 'p1'
|
||||
fx.antworten.push(aktiv())
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor)
|
||||
|
||||
// die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also wird der Kopf neu gezeichnet – der Editor bleibt mit seinen Eingaben
|
||||
const kopf = fx.finde(root, '.zt-kopf')
|
||||
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editorplatz bleibt stehen')
|
||||
assert.equal(eingabe.value, 'ungespeichert', 'ungespeicherte Eingaben gehen nicht verloren')
|
||||
assert.notEqual(fx.finde(root, '.zt-kopf'), kopf, 'der Kopf ist neu gezeichnet')
|
||||
assert.equal(root.dataset.zeitenKey.includes('kein_abo'), true, 'der neue Stand gilt')
|
||||
|
||||
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), der Editor bleibt auch dann
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root.dataset.wunschWeg = 'behalten'
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor)
|
||||
assert.equal(editor.hidden, true, 'die Behalten-Seite versteckt den Editor')
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
|
||||
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
|
||||
await fx.klick(knopf(fx, root, 'zurueck'))
|
||||
assert.equal(editor.hidden, false)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root.dataset.wunschWeg = 'behalten'
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editor bleibt')
|
||||
assert.equal(editor.hidden, true)
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'], 'der Editor hat in der ganzen Zeit nur einmal geladen')
|
||||
|
||||
// Rückkehr von Stripe: immer neu
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
fx.window.location.hash = '#zeiten?behalten=1&projekt=p1'
|
||||
await initZeiten(root)
|
||||
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
|
||||
assert.ok(fx.finde(root, '[data-danke="behalten"]'))
|
||||
assert.equal(fx.editorAufrufe.length, 2, 'ein frischer Editor lädt')
|
||||
}))
|
||||
|
||||
test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
|
||||
const bose = '<img src=x onerror=alert(1)>'
|
||||
fx.antworten.push(
|
||||
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)),
|
||||
fehler(409, bose),
|
||||
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(fx.alle(root, 'img').length, 0)
|
||||
assert.ok(fx.text(fx.finde(root, '#zeiten-project')).includes(bose))
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.equal(fx.alle(root, 'img').length, 0)
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), bose)
|
||||
}))
|
||||
|
||||
test('veraltete Antwort: ein Wechsel der Website während des Neuladens überschreibt die neuere Ansicht nicht', mitDom({}, async (fx) => {
|
||||
const echtes = globalThis.fetch
|
||||
let freigeben
|
||||
const wartet = new Promise((resolve) => { freigeben = resolve })
|
||||
let listen = 0
|
||||
// die zweite Liste (nach dem 409) kommt erst nach der Freigabe
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (String(url) === '/api/funktionen' && (optionen?.method ?? 'GET') === 'GET' && ++listen === 2) await wartet
|
||||
return echtes(url, optionen)
|
||||
}
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1'), projekt('p2')], angebot(true)),
|
||||
fehler(409, 'Stand geändert'),
|
||||
liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
|
||||
const root = await oeffne(fx, { projectId: 'p1' })
|
||||
await alleAnhaken(fx, root)
|
||||
const klick = fx.klick(knopf(fx, root, 'einschalten'))
|
||||
await fx.ruhe()
|
||||
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2') // der Kunde wechselt die Website, während die Liste noch lädt
|
||||
freigeben()
|
||||
await klick
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die späte Antwort zeichnet nichts mehr')
|
||||
assert.equal(fx.finde(root, '[data-meldung="ansicht"]'), null)
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Quelltext
|
||||
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||
|
||||
test('Portal: Ansicht #zeiten ist in app.js und dashboard.html verdrahtet', () => {
|
||||
const app = lesen('public/app.js')
|
||||
// zeiten steht in allen drei Listen hinter dem Webshop (main zuerst, dann die Öffnungszeiten)
|
||||
assert.match(app, /VIEW_HASHES = \{[^}]*webshop: 'webshop', zeiten: 'zeiten' \}/)
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', [^}]*webshop: 'funktionen', zeiten: 'funktionen' \}/)
|
||||
assert.match(app, /FEATURE_LABELS = \{[^}]*shop: 'Webshop', zeiten: 'Öffnungszeiten' \}/)
|
||||
assert.match(app, /zeiten: \(\) => import\('\/zeiten\.js'\)\.then\(\(m\) => m\.initZeiten\(document\.getElementById\('zeiten-body'\)\)\)/)
|
||||
const html = lesen('public/dashboard.html')
|
||||
assert.match(html, /<section class="kb-view" id="view-zeiten">[\s\S]*<h1>Öffnungszeiten live<\/h1>[\s\S]*<div id="zeiten-body">/)
|
||||
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">/)
|
||||
assert.doesNotMatch(html, /id="view-zeiten"[^>]*aria-live/)
|
||||
assert.doesNotMatch(html.slice(html.indexOf('id="zeiten-body"') - 80, html.indexOf('id="zeiten-body"') + 80), /aria-live/)
|
||||
// die bestehenden Ansichten bleiben, wie sie waren
|
||||
assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
|
||||
})
|
||||
|
||||
test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => {
|
||||
const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1])
|
||||
assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js', './zeiten-editor.js'])
|
||||
assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js'])
|
||||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f))
|
||||
for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) {
|
||||
assert.ok(dateien.includes(datei))
|
||||
const quelle = ohneKommentare(lesen(`public/${datei}`))
|
||||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
|
||||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
|
||||
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
|
||||
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
|
||||
assert.doesNotMatch(quelle, /24 Uhr|\bbrutto\b|inklusive (Mehr|Umsatz)steuer/i, `${datei}: Wortwahl`)
|
||||
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
|
||||
}
|
||||
// der einzige Link der Bestellseite ist die Leistungsbeschreibung (und die Rechnung in der Karte), jeder geht durch sicherLink bzw. unterlagenLink
|
||||
const bestellen = ohneKommentare(lesen('public/zeiten-bestellen.js'))
|
||||
assert.deepEqual(bestellen.match(/href: [^,}]+/g), ['href: link', 'href: rechnung'])
|
||||
assert.match(bestellen, /const link = unterlagenLink\(/)
|
||||
assert.match(bestellen, /const rechnung = sicherLink\(/)
|
||||
// keine termine*.js und kein funktion-bestellen.js angefasst: sie sind gepinnt
|
||||
assert.doesNotMatch(bestellen, /\btermine-view\b/)
|
||||
})
|
||||
|
||||
test('zeiten.css: die Klassen der Ansicht sind gestaltet, die Bausteine der Bestellseite gibt es in termine.css', () => {
|
||||
const css = lesen('public/zeiten.css')
|
||||
for (const klasse of ['zt-kopf', 'zt-editor', 'zt-bestellen', 'zt-status', 'zt-hinweis']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
|
||||
assert.match(css, /#view-zeiten \.fn-project/)
|
||||
assert.match(css, /prefers-reduced-motion/)
|
||||
assert.match(css, /min-height: 44px/, 'Tippflächen am Handy')
|
||||
const termine = lesen('public/termine.css')
|
||||
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte', 'tm-haken', 'tm-pille', 'tm-box', 'tm-meldung', 'tm-danke', 'tm-fakten', 'tm-info', 'tm-aktionen']) assert.match(termine, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} steht in termine.css`)
|
||||
// jede zt-Klasse, die die Module setzen, ist gestaltet (tm-Klassen sind teils reine Haken der Struktur)
|
||||
const quelle = lesen('public/zeiten.js') + lesen('public/zeiten-bestellen.js')
|
||||
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
|
||||
assert.ok(gesetzt.size >= 4)
|
||||
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
|
||||
})
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user