Compare commits

...
53 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 be7f9b1aa3 Terminplaner: Test an zweiten absoluten Import von funktionen.js (/geraete.js) angepasst
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:18:21 +00:00
ClaudeandClaude Opus 5.5 5bd8ee8457 Terminplaner: Abschluss – kein "null" in abgeschalteten Einstellungen, Kommentar zu disabledAt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 f134ed0032 Terminplaner: Abschluss – Spezifikation und Rechtshinweise an den Stand des Codes angeglichen
- Spezifikation: Abweichungen aus den Abschlussreviews nachgezogen (kontakt_url, kein ETag und formMinSekunden,
  Token-Endpunkte unter /termin/*, Zellen bis blockEndsAt, Outbox-Fristen, Reihenfolge Buchung vor Zellen,
  Widerrufsfrist nach §§ 187/188/193 BGB, Abwesenheit schlägt Sonderöffnung, Kunden-API vollständig,
  Impressum-Adresse) sowie R53 (Wartezeit vor dem Absenden), R54 (Widerruf als Knopf, im Knopfmodus
  dauerhaft) und R55 (Widerruf bei jeder Verbraucher-Buchung, auch Anfragen).
- Neuer Abschnitt "Betrieb und Livegang – Hinweise" in 14 (SESSION_SECRET nie rotieren, Modus off stoppt die
  Löschjobs, Zellbereinigung vor TP2 begrenzen, Warteschlange listet jede Minute alles Fällige).
- Plan: Impressum-Link auf https://webklar.com/impressum.
- Rechtshinweise: "offen 1" durch R14 erledigt, Haken für vorzeitigen Beginn nach R29/R31, Widerrufshinweis
  nach R55 und G02; offene Punkte zu Abo-Ende und Knopfmodus in den Datenschutz-Entwürfen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 794a63c0d7 Terminplaner: Abschluss – Client (Formular-Token, Widerruf prominent, Telefonlink, Einbau-Code, G02)
- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
  ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
  nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
  Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
  Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
  tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
  zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
  Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 bd65c9dc58 Terminplaner: Abschluss – Server (Widerruf auch bei Anfragen, Gastdaten löschen vollständig, formMinSekunden)
- R55: Widerrufsbelehrung, Anschrift-Pflicht und Knopf "Vertrag widerrufen" gelten für jede
  Verbraucher-Buchung mit sichtbarer Leistung, auch Anfragen (zeigtWiderruf); der Nachweis
  hält die Belehrung auch bei Anfragen fest. Damit bleibt die Annahme einer Anfrage nie mehr
  still ohne G02.
- "Gastdaten löschen" leert auch absageGrund und löscht noch nicht versandte Mails der Buchung
  (neu: verwerfeWartende in der Warteschlange).
- R53: GET /:siteKey/config nennt formMinSekunden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 4be886a4de Terminplaner: Portal-Ansicht – Fokus und aria-busy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 3ad056f261 Terminplaner: Portal-Ansicht Einstellungen – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 0b75b31009 Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 650919b022 Terminplaner: Portal-Ansicht – Nachbesserungen (2/2)
- Annehmen mit Rueckfrage in der Seite (schliesst den Vertrag, schickt G02)
- Offene Anfragen: alle bis Ende des Buchungszeitraums (Fenster zu je 62 Tagen,
  hoechstens 6 Aufrufe), passend zur Zahl auf Kachel und Banner
- Meldung ins Bild geholt, Fehler mit Fokus; einzige Live-Region
- Netzwerkfehler auf Deutsch; Antwort einer frueheren Website erscheint nie
  unter der neuen (Zaehler je Aufruf); Datumssprung nur mit echtem Tag
- Ende um Mitternacht als 24:00, Statusfilter nach Eintragen auf Alle,
  abgeschaltet ohne Einstellung mit Export-Link, dunkles Datumsfeld
- Playwright: Zeitzone New York/Berlin, ferne Anfragen, Projektwechsel,
  Meldung im Bild, Datumssprung, abgeschaltet ohne Einstellung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 9c07f6a8c5 Terminplaner: Portal-Ansicht – Nachbesserungen (1/2: Module getrennt)
public/termine.js in Huelle und Module zerlegt, Verhalten unveraendert (R48):
termine-dom.js (Helfer ohne Zustand), termine-tab-termine.js (Liste, Kopf, Zeilen),
termine-aktionen.js (Aktionen mit Rueckfrage), termine-formulare.js (Uhrzeiten,
Termin eintragen, Zeit sperren). Reiter bekommen Zustand und aktuell() nur ueber
ctx; die Huelle haengt sie ueber die Registry REITER ein. Statiktest prueft alle
public/termine*.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 43e4826ae3 Terminplaner: Portal-Ansicht, Kachel, Termine
Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet),
Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl
beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze,
Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit
Rueckfragen in der Seite, Termin eintragen und Zeit sperren.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 70940e02c8 Terminplaner: Gaesteseiten – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 734e397e73 Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 781eccb2d6 Terminplaner: Einbau-Skript – veraltetes Angebot
Fehler an leistung/ressource (beim Absenden und bei Tage/Zeiten) laden zuerst die Konfiguration
neu (ladeNeu): gilt die Auswahl nicht mehr, beginnt die Strecke von vorn mit Hinweis; sonst
steht die Meldung des Servers. Knopfmodus: Aufbaufehler zeigt den Ausweichtext statt "Wird
geladen".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 4465ac7b94 Terminplaner: Einbau-Skript – Nachbesserungen
429 ohne Schleife (Meldung und Erneut-versuchen-Knopf, Formulare bleiben stehen), Schriftfarbe
auf der Betriebsfarbe mit mindestens 4,5 : 1, AGB-Link aus der oeffentlichen Konfiguration
(betrieb.agbUrl), Haken starten bei geaenderter Auswahl ohne Haken, Fehler ohne Feld nicht
verschluckt, Konfiguration fail closed, ladeNeu fuer Neuladen und neu_laden, Tastatur und
Fokus (Modifikatoren, Monatspfeile, Belehrungstext), Aufraeumen. UI-Testserver: Timer,
offene Verbindungen, Hinweis auf sessionCookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00
ClaudeandClaude Sonnet 5.5 5fef7e47f0 Terminplaner: Einbau-Skript, Demo und Einbauanleitung
public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog,
Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url,
Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit
Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer
Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 bb45d35ec7 Terminplaner: Kunden-Schnittstelle – Nachbesserungen
- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim
  Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42)
- Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und
  Terminplaner statt kopierter Funktion (R43)
- Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern
  ueberschreibt den Schaden statt dauerhaft 409
- U+FEFF als Escape statt als rohes Zeichen
- Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 e3e198fb1a Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen
(Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der
Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei
Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen,
kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung
nimmt eine Funktion unter der Sperre.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9e8fd33713 Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 ae1886c030 Terminplaner: Zaehler je IP getrennt und Abgelaufene zuerst, Notantwort beim Schreiben, Pruefung aller Antworten auf interne IDs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 2b0c05b8e9 Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz
Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit
eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64),
siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten.
Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 43ac05aa06 Terminplaner: Jobs lesen abgeschaltete Funktionen vor dem Löschen neu, Tagesbericht im Log
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 485ff3bc15 Terminplaner: Hintergrund-Jobs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 efe17551c2 Spec/Plan: Zustimmung vorzeitiger Beginn und Widerrufsfrist nach §§ 187/188/193 BGB (Rulings R29, R31)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 589f5088a6 Terminplaner: Handeintraege verlangen nur den Namen, Bestaetigung braucht eine Adresse
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 be49d38e0c Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren
Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben,
nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert
die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot,
verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist
(withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 c4ec24448e Terminplaner: Fix Widerruf nach Terminbeginn, Zustimmung beim Verschieben, Berliner Widerrufsfrist, Grenzen nur ohne Treffer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 2d9b75b3bf Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 1289b5697b Terminplaner: Bestaetigung prueft vorher, dass die Zellen der Haltung ihr noch gehoeren
bestaetige zieht unter der Buchungssperre fehlende eigene Zellen nach (belegeZellen mit ausser). Hat ein anderer
Gast sie uebernommen, wird die Buchung verfallen und es kommt dieselbe 410-Antwort wie bei abgelaufener Haltung,
ohne Mails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 d1470b894a Terminplaner: Buchung halten und bestaetigen
Gast-Ablauf in ablauf.js: halte (Eingabe pruefen, Slot neu berechnen, Buchung gehalten vor den Zellen,
Grenze offener Termine mit G01 zu_viele), vorschau (Bestaetigungs- und Verwaltungstoken) und bestaetige
(gehalten -> bestaetigt/angefragt, G02/G03 und K20). einstellungen.js exportiert text und emailAdresse,
mails.js dauerText zur Wiederverwendung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 8e25cd2d13 Terminplaner: websiteUrl verweist auf die Live-Domain statt auf die Vorschau
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 30d40ad6ce Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 af4029af81 Terminplaner: Mail-Warteschlange mit Stundenbudget
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 70b904260a Terminplaner: G08 Satz ohne Widerspruch zu zugeordnet, Fristhinweis 'Nach unseren Unterlagen'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 6c25a76e28 Terminplaner: G01 Block bestaetigen, G02 kein_widerruf, G06 grund/nachricht, Hinweise zu Blockpaaren
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 274192e88e Terminplaner: Mailvorlagen G01–G08, K20, K21
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 171b5086b1 Terminplaner: ICS-Faltung prueft Surrogatpaare statt wirkungslosem UTF-8-Decode
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 181445a69d Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 0c033ab1d2 Terminplaner: Widerrufsfunktion im Satzrahmen des Musters, Belehrung nur mit Anschrift und Telefon
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 459efff10a Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9ad8515d60 Terminplaner: Zellen nur unter der Ressourcensperre freigeben, gemeinsame Hilfsfunktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 e281db1b2b Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 4def5c735d Terminplaner: Verfuegbarkeit, Abwesenheit schlaegt Sonderoeffnung, waehleRessource ignoriert abgelaufene Haltungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 cce6e64dd1 Terminplaner: Verfuegbarkeit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 759be69250 Terminplaner: Einstellungen, URL-Host einschraenken und unsichtbare Formatzeichen entfernen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 9cacd5bd3b Terminplaner: Einstellungen und Branchen-Vorlagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 310f8434d6 Terminplaner: Zeit (Berlin, Umstellung) und Feiertage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Sonnet 5.5 a5e12d7f34 Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau
appwriteAdmin: adminFetch kennt die Option raw (Body unverpackt), createDocuments
(Bulk-Create, alles oder nichts, 409 bei vorhandener ID), listAllDocuments (seitenweise
per cursorAfter, sortiert nach $id, Fehler ueber max) und Query.between/greaterThanEqual/
lessThanEqual/cursorAfter.

Nachbau: Bulk-Create mit Alles-oder-nichts (409/400, hoechstens 100), die neuen Abfragen
samt cursorAfter, Zeitpunktvergleich fuer ISO-Datumswerte, Optionen datetimes (Normalisierung
auf +00:00, leeres Datum => 400) und defaultLimit (Standard-Limit wie Appwrite). Ohne die
Optionen unveraendert. testApp exportiert send(method, path, opts).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 334dc1e669 Terminplaner: Schalter, Konfiguration, Schema
Schalter TERMINE_ENABLED (off|pilot|on) mit TERMINE_PILOT_EMAILS, config.termine
und die vier Collection-IDs, Schema der Termine-Collections als reine Daten
(server/services/termine/schema.js), Setup-Skript scripts/termine-setup.mjs mit
--check und schemaKeys() fuer den Appwrite-Nachbau der Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 f63165c158 Rechtliches: Anlage 1 und 2 zu Art. 246a EGBGB als Quelle fuer die Terminplaner-Widerrufsbelehrung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 f28060198c Plan TP1: Funde der Planpruefung eingearbeitet (Einstellungsfeld, Verwaltungstoken, Verschieben, Testbarkeit, Missbrauchsschutz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 e3ff05c804 Plan: Terminplaner TP1 Kern (17 Aufgaben + Livegang), Spec-Feinschliff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
ClaudeandClaude Opus 5.5 275dbcd690 Spec: Terminplaner (TP1 Kern, TP2 Verkauf, TP3 Online-Zahlung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00
115 changed files with 34065 additions and 109 deletions

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
# Terminplaner auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite.
## 1. Das Snippet
```html
<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
<p>Termine telefonisch unter 06301 1234.</p> <!-- Ausweichtext, siehe Abschnitt 4 -->
</div>
<script src="https://project.webklar.com/embed/termine.js" defer></script>
```
Das Skript steht einmal pro Seite, vor `</body>`. Es hat keine Abhängigkeiten, setzt keine Cookies, speichert nichts im Browser und lädt nichts von Dritten. Texte der Kunden setzt es nur als Text ein, nie als HTML. Es wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
## 2. Variablen (Attribute am `div`)
| Attribut | Pflicht | Bedeutung |
| --- | --- | --- |
| `data-webklar-termine` | ja | der `siteKey` des Projekts |
| `data-api` | nein | Herkunft des Portals, Standard `https://project.webklar.com`. Ohne Angabe nimmt das Skript die Herkunft seiner eigenen Adresse. Das klappt nur bei einem normalen `<script src>`. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht. |
| `data-modus` | nein | `inline` (Standard): die Buchungsstrecke steht direkt in der Seite und lädt beim Aufruf der Seite. `knopf`: ein Knopf öffnet die Strecke in einem Dialog (`<dialog>`). Beim Laden der Seite ruft das Widget einmal die Konfiguration ab (für den Knopf „Vertrag widerrufen“, siehe Abschnitt 5), alles Weitere erst beim Klick. |
| `data-knopf` | nein | Text des Knopfes im Modus `knopf`, Standard „Termin buchen“. |
| `data-leistung` | nein | ID einer Leistung (`s_` plus 8 Zeichen, steht im Portal bei der Leistung). Die Leistung ist vorgewählt, der Schritt „Leistung wählen“ entfällt. Praktisch für einen Knopf neben einer Leistung auf der Seite. Eine unbekannte ID wird ignoriert. |
Alle Klassen beginnen mit `wkt-`. Die Stile stehen einmalig in `<style id="wkt-style">` und zwar in `@layer wkt`. Das heißt: **jede** Regel der Kundenseite (ohne Layer) gewinnt gegen die Standard-Stile. Das ist gewollt, hat aber eine Folge: Hat das Theme der Kundenseite globale Regeln wie `button{…}` oder `input{…}`, greifen sie auch im Widget. Prüfe das beim Abnehmen und überschreibe bei Bedarf gezielt, z. B. `[data-webklar-termine] .wkt-btn{ … }`.
Das Layout richtet sich nach der Breite des `div` (Container Query, braucht aktuelle Browser), nicht nach dem Bildschirm. Das `div` muss eine Breite haben (normaler Blockcontainer; in einem Flex- oder Grid-Element ohne Breite schrumpft es auf null).
### Zwei Widgets auf einer Seite
Das geht. Jedes Widget bekommt eigene IDs, die Stile werden nur einmal eingefügt. Beispiel: zwei Knöpfe neben zwei Leistungen.
```html
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="s_abcd1234"></div>
<div data-webklar-termine="SITEKEY" data-modus="knopf" data-knopf="Färben buchen" data-leistung="s_efgh5678"></div>
```
## 3. Aussehen anpassen (CSS-Variablen)
Setze die Variablen auf dem `div`, in der Seiten-CSS oder auf `:root`, z. B. `[data-webklar-termine]{ --wkt-akzent:#0f766e; }`. Alle sind optional.
| Variable | Standard |
| --- | --- |
| `--wkt-akzent` (Knöpfe, aktive Tage, Rahmen der freien Tage) | die Betriebsfarbe aus den Einstellungen, sonst `#1d4ed8` |
| `--wkt-akzent-text` (Schrift auf dem Akzent) | passend zur Betriebsfarbe (hell oder dunkel), sonst `#fff` |
| `--wkt-hintergrund` | `#fff` |
| `--wkt-text` | `#1f2937` |
| `--wkt-rand` (Eingabefelder, Karten) | `#858f9e` |
| `--wkt-radius` | `10px` |
| `--wkt-schrift` | die Schrift der Seite (`inherit`) |
| `--wkt-schrift-titel` (Überschriften) | wie `--wkt-schrift` |
Die Betriebsfarbe gilt nur, solange `--wkt-akzent` nicht gesetzt ist; die Schrift darauf (Weiß, dunkles Grau oder Schwarz) wählt das Skript so, dass sie mindestens 4,5 : 1 erreicht. Setzt Du `--wkt-akzent`, setze auch `--wkt-akzent-text`. Im Modus `knopf` kennt das Widget die Betriebsfarbe erst nach dem Klick: Der Knopf selbst nimmt `--wkt-akzent` der Seite oder `#1d4ed8`. Setze `--wkt-akzent` dort also immer.
Kontrast (WCAG 2.1 AA): Text auf dem Hintergrund mindestens 4,5 : 1, Schrift auf dem Akzent mindestens 4,5 : 1, `--wkt-rand` gegen den Hintergrund mindestens 3 : 1 (sonst sind Eingabefelder schwer zu erkennen). Der Fokusrahmen nimmt die Textfarbe.
### Stil 1: hell und freundlich (Salon, Kosmetik)
```css
[data-webklar-termine]{
--wkt-akzent:#9d4b6f;
--wkt-akzent-text:#fff;
--wkt-hintergrund:#fff;
--wkt-text:#3b2a33;
--wkt-rand:#8c7580;
--wkt-radius:14px;
--wkt-schrift:"Nunito", system-ui, sans-serif;
--wkt-schrift-titel:"Fredoka", system-ui, sans-serif;
}
```
### Stil 2: dunkel (Tattoo, Studio, Bar)
```css
[data-webklar-termine]{
--wkt-akzent:#f5c542;
--wkt-akzent-text:#111;
--wkt-hintergrund:#18181b;
--wkt-text:#f4f4f5;
--wkt-rand:#8b8b94;
--wkt-radius:4px;
--wkt-schrift:"Inter", system-ui, sans-serif;
}
```
### Stil 3: schlicht und eckig (Kanzlei, Handwerk, Beratung)
```css
[data-webklar-termine]{
--wkt-akzent:#1f3a5f;
--wkt-akzent-text:#fff;
--wkt-hintergrund:#fff;
--wkt-text:#1a1a1a;
--wkt-rand:#767676;
--wkt-radius:0;
--wkt-schrift:Georgia, "Times New Roman", serif;
--wkt-schrift-titel:system-ui, sans-serif;
}
```
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift.
## 4. Ausweichtext
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die Konfiguration geladen werden konnte. Bei ausgeschaltetem JavaScript, unbekanntem Schlüssel, abgeschalteter Funktion, fehlenden Pflichtangaben des Betriebs oder Netzfehler beim Laden bleibt der Text stehen. Schreib dort die Telefonnummer des Betriebs hinein und gestalte den Text im Stil der Seite.
Im Modus `knopf` ersetzt der Knopf den Text auf der Seite. Der Text erscheint dafür im Dialog, wenn das Laden beim Klick scheitert.
Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht erreichbar. Bitte rufen Sie an: {Telefon des Betriebs}“ (bei Anrede „du“: „Bitte ruf an“).
## 5. Was das Widget zeigt
1. Leistung wählen (Name, Dauer, Preis, Kurzbeschreibung) mit der Zahlungsart („Bezahlung vor Ort: …“). Entfällt bei `data-leistung`.
2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt.
3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers.
4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs.
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
Im Fuß stehen „Vertrag widerrufen“ als zweiter Knopf (kein bloßer Textlink; für Verbraucher bei jeder Leistung, ob sofort buchbar oder als Anfrage, weil auch ein angenommener Anfrage-Vertrag widerrufen werden kann), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Im Modus `knopf` steht der Fuß nur im geöffneten Dialog. Damit die Widerrufsfunktion (§ 356a BGB) trotzdem dauerhaft auffindbar ist, lädt das Widget dort schon beim Laden der Seite die Konfiguration (ein einziger Abruf, sonst nichts) und setzt, wenn der Betrieb ein Widerrufsrecht hat, den Knopf „Vertrag widerrufen“ als zweiten Knopf neben den Buchungsknopf. Er öffnet den Dialog gleich beim Widerrufsformular. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
Bedienung nur mit der Tastatur: Tab und Umschalt+Tab zwischen den Elementen, im Monatsraster Pfeiltasten (Tag), Pos1 und Ende (Wochenanfang und -ende), Bild ↑ und Bild ↓ (Monat), Eingabe oder Leertaste wählt den Tag. Im Dialog schließt Esc.
## 6. Datenschutzerklärung des Kunden
Das Skript lädt beim Aufruf der Seite die Konfiguration (Texte, Leistungen) von `project.webklar.com`; im Modus `inline` folgen die Termine beim Durchklicken, im Modus `knopf` erst beim Klick auf den Knopf. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Der Absatz trägt den Kopf **„ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“**. Solange der Kopf dort steht, gilt: nur beim Pilotkunden einsetzen, bei anderen Betrieben erst nach der Rechtsprüfung. Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß.
## 7. Abnahme-Checkliste
- [ ] Funktion im Ticketsystem aktiviert (Zustand „aktiv“), `TERMINE_ENABLED` auf `pilot` oder `on`, Kunde im Pilot. `siteKey` und Einbau-Code aus dem Portal (Terminplaner → Einstellungen → „Einbau auf deiner Website“) übernommen.
- [ ] Einstellungen im Portal gespeichert, Telefon und Anschrift des Betriebs eingetragen (das Portal weist darauf hin, solange etwas fehlt), URLs von Impressum und Datenschutz mit `https://`.
- [ ] Datenschutz-Absatz steht in der Datenschutzerklärung der Website (Abschnitt 6).
- [ ] Ausweichtext prüfen: JavaScript aus, Funktion im Ticketsystem ausgeschaltet oder falscher `siteKey`: Der Text steht, nichts ist kaputt.
- [ ] Komplett durchbuchen bis „Fast geschafft“; die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. Danach den Testtermin absagen (Verwaltungslink aus der E-Mail oder im Portal).
- [ ] Bei Verbraucher-Betrieb (auch mit nur Anfrage-Leistungen): Widerrufsbelehrung aufklappbar, bei Preis erscheint der Haken für vorzeitigen Beginn bei Terminen in den nächsten 14 Tagen, „Vertrag widerrufen“ steht als Knopf im Fuß und das Formular funktioniert.
- [ ] Modus `knopf` bei Verbraucher-Betrieb: Neben dem Buchungsknopf steht dauerhaft der Knopf „Vertrag widerrufen“ (nicht erst im Dialog) und öffnet den Dialog gleich beim Widerrufsformular; ohne Widerrufsrecht (Unternehmen, Freizeitleistung) fehlt er.
- [ ] Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog und der Fokus geht zum Knopf zurück.
- [ ] Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, Dialog passt in den Bildschirm.
- [ ] Zoom 200 % und große Schrift: nichts wird abgeschnitten.
- [ ] Kontrast der gesetzten Variablen geprüft (Abschnitt 3), globale Regeln des Themes (`button`, `input`) stören nicht.
- [ ] Zwei Widgets auf einer Seite (falls geplant) stören sich nicht.
- [ ] Anderer Browser, andere Zeitzone (z. B. im Browser auf New York stellen): dieselben Tage und Uhrzeiten wie in Berlin.
- [ ] Entwicklerwerkzeuge: keine Cookies, nichts in Local/Session Storage, keine Anfrage an fremde Hosts außer dem Portal.
- [ ] Automatische Prüfung gegen die Testseite: `nice node test/ui/termine-widget.ui.mjs` (startet einen eigenen Testserver, Screenshots unter `/root/reports/terminplaner-ui/`).
## 8. API-Kurzreferenz
Alle Pfade unter `https://project.webklar.com/api/public/termine/v1`. Antworten sind JSON. Fehler haben die Form `{ "fehler": { "feld": "Text" }, "code": "…" }` (`_` = Fehler ohne Feld). Ist der `siteKey` unbekannt, die Funktion nicht aktiv oder der Betrieb noch nicht buchbar, antworten alle Pfade unter `/:siteKey` mit `200 { "aktiv": false }`. Appwrite nicht erreichbar: `503 { "fehler": { "_": "Gerade nicht erreichbar" } }`. Die Antworten enthalten nie interne IDs.
| Methode und Pfad | Zweck |
| --- | --- |
| `GET /:siteKey/config` | Betrieb (Name, Telefon, Adresse, URLs von Impressum, Datenschutz und AGB), Anrede, Farbe, Leistungen mit Knopftext, Personen, Regeln, Zahlungsart, Stornotext, `widerruf`, Texte der Haken und der Widerrufsbelehrung, Datenschutz-Hinweis, `formToken`. Eine Minute gecacht; das Widget lädt sie nach 100 Minuten neu. |
| `GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten plus Kalenderdaten des Monats (`monat`) |
| `GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten, `datumLang`, `vorzeitigNoetig` (Haken für vorzeitigen Beginn an diesem Tag) |
| `POST /:siteKey/buchungen` | Formular absenden. Antwort `201 { ok, email: "m***@gmail.com", minuten }`. Honeypot `kontakt_url` und Formular-Token (Fehlercode `neu_laden`) schützen vor Automaten. |
| `POST /:siteKey/widerruf` | Widerruf ohne Link: `{ name, email, nummer }`, Antwort immer gleich `{ ok, eingang: { datum, uhrzeit } }` |
| `POST /termin/vorschau`, `bestaetigen`, `absagen`, `verschieben`, `widerruf`, `tage`, `zeiten` | Seiten der Gäste (Token aus dem Link der E-Mail, `termin.html`) |
| `GET /kalender/:siteKey/:feedToken.ics` | Kalender-Abo des Betriebs je Person (geheimer Link aus dem Portal) |
POST-Anfragen sind nur von Seiten des Betriebs erlaubt (Host des Projekts bzw. `liveDomain` samt `www`-Gegenstück) und vom Portal selbst; andere Herkunft: `403`. Grenzen: 120 Abrufe pro Minute und 10 Buchungen pro 10 Minuten je IP, 60 Buchungen pro Stunde je Funktion. Mehr in `server/routes/publicTermine.js`.
## 9. Testen
`/embed/termine-demo.html` auf dem Portal ist eine nicht verlinkte Testseite (Strecke inline und als Knopf). Sie braucht einen Testbetrieb mit dem `siteKey` `abcdefghijklmnop`. Automatische Prüfungen: `node --test test/termineEmbedStatic.test.js` (Quelltext) und `nice node test/ui/termine-widget.ui.mjs` (Browser, eigener Testserver mit Nachbau der Datenbank; vorher `free -m`).
+63
View File
@@ -0,0 +1,63 @@
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_253anlage_1.html (Anlage 1), abgerufen 2026-10-02
(Fundstelle: BGBl. I 2013, S. 3663 – 3664;
bzgl. der einzelnen Änderungen vgl. Fußnote)
Widerrufsbelehrung
Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag .
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ( ) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Gestaltungshinweise:Fügen Sie einen der folgenden in Anführungszeichen gesetzten Textbausteine ein:
a)
im Falle eines Dienstleistungsvertrags oder eines Vertrags über die Lieferung von Wasser, Gas oder Strom, wenn sie nicht in einem begrenzten Volumen oder in einer bestimmten Menge zum Verkauf angeboten werden, von Fernwärme oder von digitalen Inhalten, die nicht auf einem körperlichen Datenträger geliefert werden: „des Vertragsabschlusses.“;
b)
im Falle eines Kaufvertrags: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.“;
c)
im Falle eines Vertrags über mehrere Waren, die der Verbraucher im Rahmen einer einheitlichen Bestellung bestellt hat und die getrennt geliefert werden: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.“;
d)
im Falle eines Vertrags über die Lieferung einer Ware in mehreren Teilsendungen oder Stücken: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Teilsendung oder das letzte Stück in Besitz genommen haben bzw. hat.“;
e)
im Falle eines Vertrags zur regelmäßigen Lieferung von Waren über einen festgelegten Zeitraum hinweg: „, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die erste Ware in Besitz genommen haben bzw. hat.“
Fügen Sie Ihren Namen, Ihre Anschrift, Ihre Telefonnummer und Ihre E-Mail-Adresse ein.
Wenn Sie dazu verpflichtet sind, eine Funktion bereitzustellen, mit der der Verbraucher den online geschlossenen Vertrag widerrufen kann, fügen Sie Folgendes ein: „Sie können Ihr Widerrufsrecht auch online unter … [Internetadresse oder anderen geeigneten Hinweis darüber eingeben, wo die Widerrufsfunktion verfügbar ist] ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. “ Wenn Sie dem Verbraucher die Wahl einräumen, die Information über seinen Widerruf des Vertrags auf Ihrer Webseite elektronisch auszufüllen und zu übermitteln, fügen Sie Folgendes ein: „Sie können das Muster-Widerrufsformular oder eine andere eindeutige Erklärung auch auf unserer Webseite … [Internet-Adresse einfügen] elektronisch ausfüllen und übermitteln. Machen Sie von dieser Möglichkeit Gebrauch, so werden wir Ihnen unverzüglich (z. B. per E-Mail) eine Bestätigung über den Eingang eines solchen Widerrufs übermitteln. “
Im Falle von Kaufverträgen, in denen Sie nicht angeboten haben, im Falle des Widerrufs die Waren selbst abzuholen, fügen Sie Folgendes ein: „Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.“
Wenn der Verbraucher Waren im Zusammenhang mit dem Vertrag erhalten hat:
a)
Fügen Sie ein:
–
„Wir holen die Waren ab.“ oder
–
„Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an … uns oder an [hier sind gegebenenfalls der Name und die Anschrift der von Ihnen zur Entgegennahme der Waren ermächtigten Person einzufügen] zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.“
b)
fügen Sie ein:
–
„Wir tragen die Kosten der Rücksendung der Waren.“;
–
„Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.“;
–
Wenn Sie bei einem Fernabsatzvertrag nicht anbieten, die Kosten der Rücksendung der Waren zu tragen, und die Waren aufgrund ihrer Beschaffenheit nicht normal mit der Post zurückgesandt werden können: „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren in Höhe von … EUR [Betrag einfügen].“, oder, wenn die Kosten vernünftigerweise nicht im Voraus berechnet werden können: „Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. Die Kosten werden auf höchstens etwa … EUR [Betrag einfügen] geschätzt.“ oder
–
Wenn die Waren bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag aufgrund ihrer Beschaffenheit nicht normal mit der Post zurückgesandt werden können und zum Zeitpunkt des Vertragsschlusses zur Wohnung des Verbrauchers gebracht worden sind: „Wir holen die Waren auf unsere Kosten ab.“ und
c)
fügen Sie ein: „Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.“
Im Falle eines Vertrags, der die Erbringung von Dienstleistungen oder die Lieferung von Wasser, Gas oder Strom, wenn sie nicht in einem begrenzten Volumen oder in einer bestimmten Menge zum Verkauf angeboten werden, oder von Fernwärme zum Gegenstand hat, fügen Sie Folgendes ein: „Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen] während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. “
+30
View File
@@ -0,0 +1,30 @@
Quelle: https://www.gesetze-im-internet.de/bgbeg/art_253anlage_2.html (Anlage 2), abgerufen 2026-10-02
(Fundstelle: BGBl. I 2013, S. 3665;
bzgl. der einzelnen Änderungen vgl. Fußnote)
Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
–
An [hier ist der Name, die Anschrift und die E-Mail-Adresse des Unternehmers durch den Unternehmer einzufügen]:
–
Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
–
Bestellt am (*)/erhalten am (*)
–
Name des/der Verbraucher(s)
–
Anschrift des/der Verbraucher(s)
–
Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
–
Datum
(*) Unzutreffendes streichen.
@@ -0,0 +1,37 @@
ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen
# Datenschutzerklärung von WEBklar: Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“
Fassung 2026-10-02. Entwurf für `public/datenschutz.html`, **nicht eingebaut und nicht veröffentlicht**: Die Datei `public/datenschutz.html` bleibt bis TP2 unverändert (dort: Livegang des Terminplaners, Prüfrunde wie bei der Website-Analyse). Der Abschnitt beschreibt, was WEBklar selbst verarbeitet: als Auftragsverarbeiter für die Gäste der Betriebe und als Verantwortlicher für die Betriebsdaten im Kundenportal. Den Text für die Website des Betriebs enthält `termine-datenschutz-absatz.md`.
## Einbau in TP2 (Checkliste)
1. Abschnitt als `<h2 id="termine">` nach 6b (Website-Analyse) und vor 7 einfügen; Stil wie 6a und 6b.
2. Fassung (Stand) oben auf der Seite und in den Vertragsunterlagen nachziehen; `docs/WEBklar-Datenschutzerklaerung.pdf` neu erzeugen und in WOMS „Rechtliches“ hochladen (der Nutzer lädt die Fassung hoch).
3. Abschnitt 12 (Empfänger) nennt Hetzner bereits; keine neue Zeile nötig. Abschnitt 13 („Derzeit stützen wir keine Verarbeitung auf eine Einwilligung“) bleibt richtig, weil die Einwilligung zur Erinnerung beim Betrieb liegt.
4. Die Kachel und die Leistungsbeschreibung „Terminplaner“ sowie der AVV „Terminplaner“ (Geschwister des AVV Website-Analyse) müssen vor dem Livegang vorliegen; dieser Abschnitt verweist auf sie nicht, weil die Datenschutzerklärung keine Vertragsunterlagen zitiert.
5. Test `test/termineRechtstexte.test.js` (Absicherung „6c nicht in datenschutz.html“) in TP2 entfernen.
## Abschnitt (HTML)
```html
<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>
<!-- Entwurf – vor Verwendung anwaltlich prüfen lassen (Rollenverteilung Auftragsverarbeiter, wie 6b) -->
<p>Sobald diese Funktion freigeschaltet ist, können Kunden mit Wartungs-Abo auf ihrer eigenen Website eine Online-Terminbuchung anbieten („Terminplaner“). Dabei verarbeiten wir die Daten der Personen, die dort einen Termin buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden einen Termin bucht, findet die Informationen zur Buchung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.</p>
<p>Für den Betrieb des Terminplaners speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und, wenn der Kunde diese Felder eingeschaltet hat, Telefonnummer, Nachricht und weitere Angaben des Gastes; die gewählte Leistung, Person oder den gewählten Platz und den Termin; den Stand der Buchung (zum Beispiel bestätigt, abgesagt oder widerrufen) mit den Zeitpunkten; als Nachweis den Zeitpunkt der Bestätigung und der Zustimmungen sowie die Fassung der angezeigten Hinweise; gegebenenfalls den Wunsch nach einer Erinnerung. Der Terminplaner ist nicht für Leistungen der Heilkunde und der Gesundheitsversorgung bestimmt; Gäste werden gebeten, keine Gesundheitsangaben zu machen.</p>
<p>Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.</p>
<p>Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.</p>
<p>Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.</p>
<p>Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Endet das Wartungs-Abo oder schaltet der Kunde die Funktion ab, löschen wir alle Termindaten des Kunden 30 Tage danach. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
```
## Für die Rechtsprüfung offen
1. **Rolle**: WEBklar als Auftragsverarbeiter für die Gäste, als Verantwortliche für die Betriebsdaten und die Missbrauchsabwehr (IP im Arbeitsspeicher). Wie bei 6b anwaltlich bestätigen lassen.
2. **IP-Adresse im Arbeitsspeicher**: Der Satz „mit der Buchung speichern wir die IP-Adresse nicht“ muss zur fertigen Buchungsroute passen (Feld `nachweis` ohne IP, kein IP-Log); beim Livegang gegen den Code prüfen.
3. **Kalender-Abo-Link**: Der Link enthält nur ein geheimes Token je Person oder Platz und liefert nur bestätigte Termine der nächsten 90 Tage. Prüfen, ob der Kunde seine Beschäftigten darüber informieren muss (Beschäftigtendaten: Namen und Zeiten).
4. **Sicherungen**: Die Datensicherung bei Hetzner behält gelöschte Daten noch einige Zeit; die Frist steht nicht im Text und ist vor dem Livegang zu klären (AVV Terminplaner Nr. 9).
5. **Zahlung (TP3)**: Wenn Online-Zahlung kommt, braucht dieser Abschnitt einen Satz zu Stripe (Abschnitt 7 und `recherche-recht-zahlung.md` 7.3 G).
6. **Geschäftsbriefe**: Bestätigungen mit Preis sind keine Rechnungen; die Aufbewahrung nach Abschnitt 9 betrifft den Terminplaner nicht.
7. **Ende des Wartungs-Abos**: Der Satz „Endet das Wartungs-Abo oder schaltet der Kunde die Funktion ab, löschen wir alle Termindaten des Kunden 30 Tage danach“ gilt in TP1 nur für das Abschalten (WOMS „Abschalten“, Kunde gelöscht). Ein Haken für das Ende des Wartungs-Abos kommt erst mit TP2; bis dahin den Satz anpassen oder den Haken liefern.
8. **Knopfmodus**: Dort lädt das Widget die Konfiguration (Texte und Leistungen, nicht die Termine) schon beim Laden der Seite, nicht erst beim Öffnen (wegen des dauerhaften Knopfs „Vertrag widerrufen“, R54). Die Formulierung „beim Öffnen der Terminbuchung zusätzlich die Termine und Texte“ danach prüfen; es geht an denselben Server wie das Skript, ein neuer Empfänger entsteht nicht.
@@ -0,0 +1,84 @@
ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen
# Datenschutz-Absatz „Online-Terminbuchung“ für die Website des Betriebs
Fassung 2026-10-02. Grundlage: `recherche-recht.md` Abschnitt 11.1. Der Absatz gehört in die Datenschutzerklärung der Website des **Betriebs** (der Betrieb ist Verantwortlicher, WEBklar Auftragsverarbeiter). Die Einbauanleitung für Mitarbeiter (`docs/funktionen/termine-einbau.md`, TP2) verweist hierher. Für die Datenschutzerklärung von WEBklar selbst gilt `termine-datenschutz-6c-entwurf.md`.
Zwei Fassungen weiter unten: Sie und Du. Nimm die Fassung, die zur Datenschutzerklärung der Website passt (in der Regel zur `anrede` des Betriebs).
## Platzhalter und optionale Sätze
- `{{Firma}}`, `{{Anschrift}}`, `{{E-Mail}}`, `{{Telefon}}`: Betriebsangaben aus der Einstellung. `{{Tage}}`: Aufbewahrung nach dem Termin (`aufbewahrungTage`, Vorgabe 90, einstellbar 14 bis 365).
- `[[wenn …: …]]`: Satz oder Satzteil nur aufnehmen, wenn die genannte Einstellung aktiv ist; sonst samt Klammern streichen.
## Löschfristen (Vorgaben des Löschlaufs, im Text unten verwendet)
| Daten | Frist |
|---|---|
| Nicht bestätigte Buchung (Status „verfallen“) | 48 Stunden nach Ablauf der Bestätigungsfrist (30 Minuten) |
| Abgesagter oder abgelehnter Termin | 30 Tage |
| Bestätigter, erledigter oder nicht wahrgenommener Termin | `aufbewahrungTage` (Vorgabe 90) nach dem Ende des Termins |
| Terminanfrage, deren Beginn verstrichen ist | gilt als abgelehnt (durch das System), dann 30 Tage |
| Funktion abgeschaltet (zum Beispiel Wartungs-Abo beendet) | alle Termindaten 30 Tage nach dem Abschalten |
| Versandwarteschlange für E-Mails | Empfängeradresse und Inhalt werden nach dem Versand geleert, der Eintrag nach 7 Tagen gelöscht; nicht zustellbare Einträge nach 48 Stunden |
## Abweichungen von Abschnitt 11.1 der Recherche
1. **Server-Protokolle**: 11.1 sagt, IP-Adresse und Browserangaben würden „nicht gespeichert“. Der Server von WEBklar protokolliert sie aber kurzzeitig (Datenschutzerklärung von WEBklar Abschnitt 3: 14 Tage). Der Absatz sagt das jetzt offen.
2. **Rechtsgrundlage**: Bei einer sofort verbindlichen Buchung kommt der Vertrag mit der Bestätigung zustande; „vorvertragliche Maßnahmen“ passt nur für Terminanfragen. Beide Fälle stehen im Text. Neu: Widerruf über die Widerrufsfunktion (Art. 6 Abs. 1 lit. c DSGVO, § 356a BGB).
3. **Aufruf**: Das Skript der Terminbuchung wird beim Aufruf der Seite geladen, nicht erst beim Klick auf den Knopf; der Text sagt das.
4. **Nachweise**: Gespeichert werden auch der Zeitpunkt der Zustimmungen und die Fassung der angezeigten Texte (Spezifikation, Feld `nachweis`).
5. **Löschfristen** angepasst (siehe Tabelle); 11.1 kannte nur „nicht bestätigt 48 Stunden“, „abgesagt 30 Tage“ und „bestätigt {{90}} Tage“.
## Für die Rechtsprüfung offen
1. **Server-Protokolle**: Frist (14 Tage) und Rechtsgrundlage (Art. 6 Abs. 1 lit. f DSGVO) müssen zur Datenschutzerklärung von WEBklar passen; bei Änderung dort hier nachziehen.
2. **Drittländer**: „Eine Übermittlung in Drittländer findet nicht statt“ gilt nur, solange Server und E-Mail-Versand bei Hetzner in Deutschland bleiben (WEBklar-Datenschutzerklärung Abschnitte 3 und 8).
3. **Löschlauf**: „48 Stunden“ und „30 Tage“ sind die frühesten Zeitpunkte, an denen der Löschlauf löscht; er läuft in Abständen.
4. **Kontakt der Gäste**: Anfragen nach Auskunft oder Löschung gehen an den Betrieb; WEBklar leitet Anfragen, die bei ihr eingehen, als Auftragsverarbeiter weiter (in der Datenschutzerklärung von WEBklar, 6c).
5. **Gesundheitsbereich**: Der Terminplaner ist dafür nicht bestimmt; der Satz „Bitte keine Angaben zur Gesundheit“ steht auch im Formular.
6. **Knopfmodus**: Das Widget lädt die Konfiguration (Texte und Leistungen) im Knopfmodus schon beim Laden der Seite (R54); „Aufruf“ (Abweichung 3) stimmt also auch für diesen Abruf. Prüfen, ob der Text zwischen Skript und Konfiguration unterscheiden muss; beides geht an denselben Server.
## Fassung Sie
> ### Online-Terminbuchung
>
> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> **Welche Daten?** Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz[[wenn Telefon-Feld aktiv: , auf Wunsch Ihre Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: und die Angaben, die Sie uns im Formular mitteilen]]. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben und welche Zustimmungen Sie erteilt haben[[wenn Erinnerung angeboten: , zum Beispiel ob Sie eine Erinnerung gewünscht haben]].
>
> **Wofür und auf welcher Grundlage?** Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage). [[wenn Erinnerung angeboten: Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.]] [[wenn Gäste Verbraucher sein können: Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).]] Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> **E-Mails.** Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung[[wenn Erinnerung angeboten: und, wenn Sie das angekreuzt haben, eine Erinnerung]]. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Firma}}, Ihre Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> **Wer verarbeitet die Daten in unserem Auftrag?** Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> **Beim Aufruf der Terminbuchung.** Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.
>
> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Sie können uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.
>
> **Ihre Rechte.** Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Firma}}, {{Anschrift}}, E-Mail {{E-Mail}}.
## Fassung Du
> ### Online-Terminbuchung
>
> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:
>
> **Welche Daten?** Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz[[wenn Telefon-Feld aktiv: , auf Wunsch deine Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: und die Angaben, die du uns im Formular mitteilst]]. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast und welche Zustimmungen du erteilt hast[[wenn Erinnerung angeboten: , zum Beispiel ob du eine Erinnerung gewünscht hast]].
>
> **Wofür und auf welcher Grundlage?** Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage). [[wenn Erinnerung angeboten: Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.]] [[wenn Gäste Verbraucher sein können: Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).]] Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).
>
> **E-Mails.** Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung[[wenn Erinnerung angeboten: und, wenn du das angekreuzt hast, eine Erinnerung]]. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. Absender ist {{Firma}}, deine Antworten gehen an uns. Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, nicht zustellbare E-Mails nach 48 Stunden.
>
> **Wer verarbeitet die Daten in unserem Auftrag?** Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. Eine Übermittlung in Drittländer findet nicht statt.
>
> **Beim Aufruf der Terminbuchung.** Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.
>
> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.
>
> **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Du kannst uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.
>
> **Deine Rechte.** Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist {{Firma}}, {{Anschrift}}, E-Mail {{E-Mail}}.
+85
View File
@@ -0,0 +1,85 @@
ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen
# Hinweise, Haken und Knopftexte für Gäste (Terminplaner)
Fassung 2026-10-02. Grundlage: `recherche-recht.md` Abschnitt 11.2 (Hinweis unter dem Formular) und 11.3, `recherche-recht-zahlung.md` Abschnitt 7.3 C bis E (Haken, Widerrufshinweis, Widerrufsfunktion). Die Texte in den Abschnitten 1 bis 3 sind die Fassung aus `server/services/termine/rechtstexte.js` (Platzhalter in doppelten geschweiften Klammern); `test/termineRechtstexte.test.js` prüft, dass sie hier wörtlich stehen. Ändere sie deshalb nur in `rechtstexte.js` und übernimm sie hierher. Die Abschnitte 4 bis 6 sind reine Entwurfstexte für das Widget und die Mails; sie stehen nicht im Code.
Platzhalter: `{{Firma}}` = Betriebsname, `{{Anschrift}}` = Anschrift des Betriebs, `{{n}}` = Stunden vor dem Termin (`erinnerung.stundenVorher`: 2, 12, 24 oder 48). Die Texte zeigen die Vorgabe von 90 Tagen Aufbewahrung nach dem Termin; der Wert kommt aus `aufbewahrungTage` (14 bis 365) der Einstellung.
## 1. Datenschutz-Hinweis unter dem Formular (Kurzfassung nach Art. 13 DSGVO)
Steht in der Zusammenfassung vor dem Absenden. Sie- oder Du-Fassung nach `anrede` des Betriebs. Neben dem Text zeigt das Widget einen Link auf `betrieb.datenschutzUrl` (die Datenschutzerklärung des Betriebs); die Betriebs-E-Mail steht bewusst nicht im Text. Es gibt keinen Haken dafür.
Fassung Sie:
```text
Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
```
Fassung Du:
```text
Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Firma}}, {{Anschrift}}.
```
Gegenüber Abschnitt 11.2 der Recherche: Die Löschfristen sind die des Löschlaufs (nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist, abgesagte Termine 30 Tage, vergangene Termine `aufbewahrungTage` nach dem Termin) und der Verantwortliche nennt zusätzlich die Anschrift.
## 2. Haken (Zustimmungen)
Wortlaut immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen: Es sind Rechtstexte. Keiner der Haken ist vorangekreuzt.
| Haken | Wann | Pflicht | Wortlaut |
|---|---|---|---|
| Vorzeitiger Beginn | Verbraucher, mit Preis: bei sofort buchbaren Leistungen, wenn der Terminbeginn nicht nach dem Ende der Widerrufsfrist liegt (14 Tage ab dem Tag nach dem Vertragsschluss, Berliner Kalender, §§ 187, 188, 193 BGB; gerechnet mit dem Zeitpunkt der Buchung plus Haltezeit; R29); bei bepreisten Anfragen immer, weil der Tag der Annahme nicht feststeht (R31) | ja | Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen 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). |
| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt | ja | Ich habe die Stornobedingungen gelesen. |
| Erinnerung | der Betrieb bietet Erinnerungen an | nein | Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. |
## 3. Hinweis über dem Nachrichtenfeld
Bitte keine Gesundheitsangaben.
Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Spezifikation, Vorgaben). Der Hinweis steht über jedem Freitextfeld.
## 4. Knopftexte (exakt)
| Fall | Knopf im Widget | Knopf auf der Bestätigungsseite |
|---|---|---|
| verbindlich, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |
| verbindlich, ohne Preis | Termin buchen | Termin bestätigen |
| Anfrage | Termin anfragen | Anfrage absenden |
| Widerruf (Funktion) | Vertrag widerrufen | Widerruf bestätigen |
## 5. Widerrufshinweis im Widget und in der Mail (Entwurf, Rahmen)
Er ersetzt nicht die Widerrufsbelehrung (`termine-widerrufsbelehrung-muster.md`), sondern führt zu ihr. Gilt für Buchungen von Verbrauchern, auch für Anfragen: Der Vertrag kommt mit der Annahme zustande, ist aber ebenfalls ein Fernabsatzvertrag, und die Frist läuft ab der Annahme (R55, R29). Die Widerrufsfunktion steht im Widget als zweiter Knopf im Fuß und im Knopfmodus dauerhaft neben dem Buchungsknopf (R54).
**Mit Widerrufsrecht** (Zusammenfassung vor dem Knopf und G02 vor der Belehrung):
> **Widerrufsrecht.** Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Dafür nutzen Sie die Schaltfläche „Vertrag widerrufen“ im Terminbereich auf unserer Website oder den Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular stehen in Ihrer Bestätigungs-E-Mail.
Maßgeblich für die Mail ist die Vorlage G02: Bis zum Beginn des Termins geht der Widerruf über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website des Betriebs oder per E-Mail an den Betrieb. Die Verwaltungsseite bietet den Knopf nur bis zum Beginn des Termins an.
**Freizeit-Dienstleistung mit festem Termin** (§ 312g Abs. 2 Nr. 9 BGB; `recht.freizeit`), kein Widerrufsrecht, keine Belehrung, kein Formular, keine Funktion:
> **Für diese Buchung besteht kein Widerrufsrecht.** Ihre Buchung ist verbindlich; Absagen richten sich nach den Stornobedingungen.
Gäste, die nur Unternehmen sind (`recht.gaesteSind = unternehmer`): kein Hinweis.
## 6. Widerrufsfunktion (Entwurf der Texte)
- Schaltfläche oder Link: **Vertrag widerrufen** (im Terminbereich der Website, auf der Verwaltungsseite hinter „Termin verwalten“).
- Formular ohne Anmeldung: Name · Buchungsnummer · E-Mail-Adresse für die Eingangsbestätigung. Schaltfläche: **Widerruf bestätigen**.
- Eingangsbestätigung per E-Mail (G08; die Mailvorlage ist maßgeblich, hier nur der Kern. Die Mail enthält keine Eingaben des Gastes, nur die Buchungsnummer und den Eingang):
> Betreff: Eingangsbestätigung Ihres Widerrufs
>
> Wir bestätigen den Eingang Ihres Widerrufs. Inhalt Ihrer Erklärung: Widerruf des Vertrags zur Buchung {{nummer}}. Eingegangen am {{eingang}} (Datum und Uhrzeit). {{Nur im Block „zugeordnet“: Ihr Termin ist abgesagt.}} Absender: {{Firma}}.
Der Eingang muss mit Datum und Uhrzeit bestätigt werden (§ 356a BGB); das ist auch der Text der Belehrung („Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs“).
## Für die Rechtsprüfung und das Widget offen
1. **Belehrung vor dem Vertragsschluss** (erledigt durch R14, R55): Das Widget zeigt die vollständige Belehrung samt Muster-Widerrufsformular (`texte.widerrufsbelehrung`, aufklappbar) in der Zusammenfassung vor dem Knopf, bei jeder Verbraucher-Buchung auch bei Anfragen; der Nachweis der Buchung hält ihren Hash fest. Die Bestätigungs-E-Mail wiederholt sie. Offen bleibt nur, ob der Rechtsprüfer die aufklappbare Darstellung genügen lässt.
2. **Widerruf der Einwilligung zur Erinnerung**: Der feste Wortlaut des Hakens (Abschnitt 2) enthält den Hinweis auf den Widerruf nicht; er steht im Datenschutz-Absatz (`termine-datenschutz-absatz.md`). Prüfen, ob ein kurzer Zusatz unter dem Haken nötig ist („Sie können diese Einwilligung jederzeit widerrufen.“).
3. **Link zur Datenschutzerklärung** neben dem Hinweis: `betrieb.datenschutzUrl` ist in der Einstellung optional. Ohne Link verweist der Hinweis auf eine Datenschutzerklärung, die der Gast nicht findet; das Portal sollte die Adresse vor dem Einschalten verlangen.
4. **Bestätigungsfrist**: Der Hinweis nennt „48 Stunden nach Ablauf der Bestätigungsfrist“. Der Löschlauf läuft in Abständen; „48 Stunden“ ist die früheste Löschung, nicht eine Sekundengarantie.
@@ -0,0 +1,73 @@
ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen
# Widerrufsbelehrung und Muster-Widerrufsformular (Terminplaner, Dienstleistung)
Fassung 2026-10-02. Quelle des Wortlauts: Anlage 1 und Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Fundstelle BGBl. I 2013, S. 3663 bis 3665), abgerufen am 2026-10-02 von gesetze-im-internet.de; die Textdateien liegen unverändert in `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`.
Der Text unten ist die Fassung, die `widerrufsbelehrung(betrieb)` in `server/services/termine/rechtstexte.js` erzeugt, mit Platzhaltern statt der Betriebsdaten. `test/termineRechtstexte.test.js` prüft, dass (a) jeder Absatz des Musters wörtlich aus den Quelldateien im Ergebnis steht und (b) der Textblock unten genau der Ausgabe der Funktion entspricht. Ändere den Text deshalb nur in `rechtstexte.js` und übernimm ihn hierher.
## Wie das Muster für einen Termin (Dienstleistung) ausgefüllt ist
| Gestaltungshinweis in Anlage 1 | Umsetzung |
|---|---|
| Frist: Textbaustein a) für Dienstleistungsverträge | „des Vertragsabschlusses.“ (Fristbeginn ist der Vertragsschluss: bei einer sofort verbindlichen Buchung die Bestätigung per Link, bei einer Anfrage die Annahme durch den Betrieb) |
| „Fügen Sie Ihren Namen, Ihre Anschrift, Ihre Telefonnummer und Ihre E-Mail-Adresse ein.“ | Name, Anschrift, Telefon, E-Mail aus den Betriebsangaben der Einstellung (`betrieb.name`, `adresse`, `telefon`, `email`); alle vier sind Pflicht, fehlt oder ist eine Angabe leer, wirft `widerrufsbelehrung()` einen TypeError |
| Widerrufsfunktion bei online geschlossenen Verträgen (§ 356a BGB) | Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben. Wenn Sie diese Online-Funktion nutzen, …“); in die Lücke „…“ kommt statt einer Internetadresse ein „anderer geeigneter Hinweis“: „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail); der Gesetzeswortlaut zur Eingangsbestätigung bleibt unverändert |
| Absatz zum elektronisch ausfüllbaren Webformular | ausgelassen: Es gibt keine eigene Webformular-Funktion außer der Widerrufsfunktion |
| Textbausteine zu Waren (Besitz, Rückzahlungsverweigerung, Abholung oder Rücksendung, Rücksendekosten, Wertverlust) | ausgelassen: Ein Termin hat keine Waren |
| Dienstleistungen: Wertersatz („Haben Sie verlangt, dass die Dienstleistungen …“) | enthalten; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen, wie der Gestaltungshinweis es vorsieht |
| Muster-Widerrufsformular (Anlage 2) | enthalten; in der Zeile „An …“ stehen Name, Anschrift und E-Mail des Betriebs; die (*)-Alternativen bleiben unverändert |
Anrede immer „Sie“, auch bei Betrieben, die ihre Gäste duzen: Es ist der gesetzliche Mustertext.
## Text (Platzhalter in doppelten geschweiften Klammern)
```text
Widerrufsbelehrung
Widerrufsrecht
Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.
Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.
Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.
Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.
Folgen des Widerrufs
Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.
Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.
Muster-Widerrufsformular
(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)
– An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}:
– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)
– Bestellt am (*)/erhalten am (*)
– Name des/der Verbraucher(s)
– Anschrift des/der Verbraucher(s)
– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)
– Datum
(*) Unzutreffendes streichen.
```
## Wo der Text erscheint
- G02 (Bestätigung an den Gast), Block `widerruf`, als `{{widerrufsbelehrung}}` (die Mailvorlage setzt den Text HTML-maskiert ein); nur wenn die Buchung ein Widerrufsrecht hat (verbindlich, Verbraucher).
- Bei einer Freizeit-Dienstleistung mit festem Termin (§ 312g Abs. 2 Nr. 9 BGB) und bei Gästen, die nur Unternehmen sind, entfallen Belehrung, Formular und Widerrufsfunktion; der Hinweis steht dann in `termine-hinweise.md`.
## Für die Rechtsprüfung offen
1. **Lieferkosten-Klammer** im Absatz „Folgen des Widerrufs“ („einschließlich der Lieferkosten (mit Ausnahme …)“) steht unverändert im Text. Anlage 1 enthält keinen Gestaltungshinweis, der sie bei reinen Dienstleistungen zu streichen erlaubt; sie ist deshalb nicht gekürzt. Prüfen, ob sie für Dienstleistungen entfallen darf.
2. **„… beginnen soll“**: Nach dem Streichen von „oder Lieferung von Wasser/Gas/Strom/Fernwärme“ steht „die Dienstleistungen … beginnen soll“ (Singular im Gesetzestext). Der Wortlaut ist bewusst nicht geglättet, damit das Muster unverändert bleibt. Prüfen, ob „sollen“ zulässig ist.
3. **Anschrift und Telefon**: Das Muster verlangt Anschrift und Telefonnummer. `widerrufsbelehrung()` wirft deshalb einen TypeError, wenn eine der vier Angaben fehlt. Die Einstellungsprüfung (späterer Task) soll Anschrift und Telefon verlangen, sobald Gäste ein Widerrufsrecht haben können (öffentlich `widerruf: true`).
4. **Wertersatz-Absatz** gilt nur, wenn der Gast den vorzeitigen Beginn verlangt hat (Zustimmung `ZUSTIMMUNG_VORZEITIG`, nur bei Terminbeginn innerhalb von 14 Tagen). Der Absatz steht trotzdem immer in der Belehrung, wie das Muster es für Dienstleistungen vorsieht.
5. **Hinweis statt Internetadresse**: „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ist als geeigneter Hinweis gewählt (Entscheidung R12). Prüfen, ob ein Hinweis ohne Internetadresse genügt oder die Adresse der Seite genannt werden muss.
6. **Gültigkeit der Gesetzlichkeitsfiktion**: Sie gilt nur, wenn das Muster inhaltlich und gestalterisch dem Gesetz entspricht. Jede Abweichung (auch Zeilenumbrüche, Überschriften, die „–“-Aufzählung im Formular) ist hier Darstellung, nicht Inhalt. Vor dem Einsatz bei fremden Betrieben durch eine Kanzlei prüfen lassen.
@@ -0,0 +1,255 @@
# Terminplaner TP1 Kern – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gäste buchen auf der Website eines Betriebs Termine (Person oder Platz, sofort oder als Anfrage, Bestätigung per E-Mail-Link, Absagen/Verschieben/Widerrufen per Link); der Betrieb verwaltet alles im Portal; Pilot mit dem Kunden „test“.
**Architecture:** Neue Funktion `termine` im Kundenportal (Express, Appwrite, vanilla JS). Reine Bausteine in `server/services/termine/` (Zeit, Feiertage, Einstellungen, Slots, Zellen gegen Doppelbuchung, ICS, Mails, Warteschlange, Ablauf, Jobs), dazu eine öffentliche, versionierte Schnittstelle `/api/public/termine/v1`, eine Kunden-Schnittstelle `/api/termine`, ein Einbau-Skript `embed/termine.js`, Gästeseiten `termin.html` und die Portal-Ansicht `#termine`. WOMS bekommt Aktivieren/Abschalten je Funktion.
**Tech Stack:** Node 20 (Produktion) / 24 (Host-Tests), Express 4, Appwrite 1.8.1 REST, nodemailer 9, `feiertagejs` 1.5.1 (neu), Playwright (`/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`, Chromium `~/.cache/ms-playwright/chromium-1234`), React 18 (WOMS).
**Spec:** `docs/superpowers/specs/2026-10-02-terminplaner-design.md` (Abschnitte „TP1 Kern“ 1–14). Recherche: `/root/reports/terminplaner-2026-10-02/`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine` (Branch `feature/terminplaner` von `origin/main` ff7fe35, eigenes `node_modules` per `npm ci`, Basis 1463 Tests grün). WOMS siehe V3 und Task 22.
## Global Constraints
- Funktionsschlüssel `termine`; Schalter `TERMINE_ENABLED` = `off` (Vorgabe) | `pilot` | `on`; `TERMINE_PILOT_EMAILS` Komma-Liste (klein, getrimmt, `*` = alle). TP1: `on` = `pilot` ohne E-Mail-Filter; kein Bestellen. Bei `off` laufen keine Termine-Jobs und keine Termine-Abfragen.
- `config.termine` wird bei **jedem** Aufruf gelesen (Tests ändern `config.termine.*` zur Laufzeit). Werte: `mode`, `pilotEmails`, `holdMinutes: 30`, `zelleMin: 5`, `mailsProStunde: 200`, `formMinSekunden: 3` (Env `TERMINE_FORM_MIN_SECONDS`).
- Neue Dateien unter `server/services/termine/`; nie `server/services/appointments.js` (Test `adminPanelGone`).
- Einstellungs-Attribut heißt **`einstellung`** (nie `data`: `appwriteAdmin.formatRequestBody` hält Bodies mit `data` für fertig verpackt).
- Zeit: Wanduhr `Europe/Berlin`, gespeichert UTC (`toISOString()`); Appwrite liefert `+00:00` ⇒ Datumswerte immer mit `new Date()` lesen. Nicht existierende Wanduhrzeiten (März) auslassen, doppelte (Oktober) = erstes Vorkommen. Alle `HH:MM` in Plänen, Sonderöffnungen, Abwesenheiten und Sperren sind Vielfache von 5 Minuten. `deadlines.berlinTime` nicht verwenden.
- Zellen à **5 Minuten**, Zell-ID `c_` + 34 Hex von `sha1(featureId|ressourceId|zelleIso)`, Feld `bis` = `blockEndsAt` der Buchung (nie Haltezeit). Alle neuen Zellen einer Buchung in **einem** Bulk-Create (≤ 100 ⇒ jede Belegung ≤ 500 Minuten, auch Sperren und manuelle Termine). Ob eine Zelle noch gilt, entscheidet allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; `gehalten` mit `heldUntil ≤ now` ⇒ frei).
- Status: `gehalten`, `angefragt`, `bestaetigt`, `erledigt`, `nichterschienen`, `abgesagt`, `abgelehnt`, `verfallen`. Belegend: `gehalten` (solange `heldUntil > now`), `angefragt`, `bestaetigt`.
- Tokens: **Bestätigungstoken** = 32 Zufallsbytes base64url, gespeichert nur `verifyHash` (SHA-256-Hex), wirksam einmal bis `heldUntil`. **Verwaltungstoken** = base64url(HMAC-SHA256(Schlüssel aus `config.sessionSecret` + `'termine-manage'`, bookingId)), gespeichert nur `manageHash`, jederzeit neu berechenbar, gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST. Vertragsschluss `vertragAt` = `bestaetigtAt` (sofort) bzw. `angenommenAt` (Anfrage).
- Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
- Zustimmungstexte exakt (Sie-Form, auch bei Du-Betrieben, weil Rechtstext): vorzeitiger Beginn (Pflicht bei verbindlich + Preis + Verbraucher + Terminbeginn ≤ Ende der Widerrufsfrist = deadlines.withdrawalDeadline(jetzt + Haltezeit).endsAt; bei bepreisten Verbraucher-Anfragen immer — Ruling R31): „Ich verlange ausdrücklich, dass {Betrieb} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen 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).“ · Storno: „Ich habe die Stornobedingungen gelesen.“ · Erinnerung (nie vorangekreuzt): „Ich möchte {n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.“ · über Freitext: „Bitte keine Gesundheitsangaben.“
- Verbraucher-Buchung ⇔ `recht.gaesteSind !== 'unternehmer' && !recht.freizeit` (beim Halten in `verbraucher` gespeichert). Widerruf möglich ⇔ verbindlich, `verbraucher`, Status `bestaetigt`, `now <= deadlines.withdrawalDeadline(vertragAt).endsAt` (§§ 187/188/193 BGB, Ruling R29), Termin noch nicht begonnen.
- Öffentliche Antworten: nie `customerId`, `projectId`, `featureId`, `$id`, Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Fehlerform immer `{ fehler: { <feld|_>: text }, code? }`. Unbekannter, inaktiver, abgeschalteter siteKey oder Modus `off` ⇒ `200 { aktiv: false }` (GET und POST unter `/:siteKey`); `/termin/*` (Token) bleibt für `disabled` nutzbar (Absagen, Widerruf).
- Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie `err.message`); `content-length` > 16 384 ⇒ 413 vorab; Honeypot-Feld `kontakt_url`; Formular-Token HMAC, `zu_schnell` (< `formMinSekunden`) oder Honeypot ⇒ gleiche Erfolgsantwort ohne Speichern/Mail, `abgelaufen` (> 2 h) ⇒ 400 „Bitte laden Sie die Seite neu.“; Grenzen (Modul `limits.js`, Speicher-Maps mit Obergrenze 10 000 Schlüssel, `resetTermineLimitsForTests()`): je IP (IPv6 je /64) 120 GET/min und 10 Buchungen/10 min; je siteKey 600 GET/min und 60 Haltungen/h; je E-Mail+Funktion `regeln.maxOffeneJeEmail` (Vorgabe 3) offene Termine und 5 G01/Tag; Widerruf 10/h je IP, 3 G08/Tag je Empfängeradresse, 20 G08/h je Funktion, 10 K21 „nicht zugeordnet“/h je Funktion. Grenze „offene Termine“ ⇒ dieselbe 201-Antwort, statt der Bestätigung geht G01-Block `zu_viele` an die Adresse (kein Orakel für Dritte). E-Mail-Adressen: genau ein Postfach (kein `,;<>"`, ≤ 254), getrimmt und klein.
- Mails: Gast `from: { name: betrieb.name, address: SMTP_FROM }`, `replyTo` = Betriebs-E-Mail; K20/K21 `from: { name: 'WEBklar', address }` an die Betriebs-E-Mail; **nie BCC**, kein Tracking. Betreff = Platzhalter `{{betreff}}` (je Anrede/Fall vom Server). G01 und G08 ohne Text, den der Gast eingegeben hat. Alles über `termineOutbox`: einziger Sendepfad `sendeEintrag` prüft Pause und Stundenbudget (200/h); `EAUTH`/535 ⇒ 15 min Pause für alle, Eintrag bleibt `wartet`; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dann `fehler`; 5xx (außer 535) ⇒ sofort `fehler`; nach `gesendet` werden `an`, `html`, `text`, `ics` geleert; Einträge 7 Tage nach `gesendet` bzw. 48 h nach `fehler` gelöscht.
- Löschfristen: `verfallen` 48 h nach `heldUntil`; `abgesagt`/`abgelehnt` 30 Tage; `erledigt`/`nichterschienen` `aufbewahrungTage` (Vorgabe 90, 14–365) nach Terminende; `angefragt` mit Beginn in der Vergangenheit ⇒ `abgelehnt` (System); Funktion `disabled` ⇒ alles 30 Tage nach `disabledAt`.
- Tests: Termine-Tests starten den Nachbau mit `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` (Task 2); feste `now` per Parameter; Mails mit `mailSink()` + `warteAufVersand()` prüfen; Kalenderdaten stehen im Sink als `icalEvent` (nicht als Anhang). Schwere Läufe (Playwright, `vite build`, Docker-Node-20) nacheinander, vorher `free -m`, mit `nice`.
- Portaltexte Du-Form, kein `confirm()`; Gästetexte Sie/Du nach `anrede`. Kundentext nur über `textContent`/`esc()`; `href` nur für geprüfte `https:`-URLs, mit `rel="noopener noreferrer"`.
- Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie `git add -A`.
## Review Focus
1. Gast-Browser in anderer Zeitzone (New York) sieht dieselben Daten und Uhrzeiten wie in Berlin; das Widget rechnet nie mit Browser-Datum. → Task 19 (Playwright `timezoneId`, statischer Test)
2. E-Mail mit Großbuchstaben/Leerzeichen (` Max@GMail.com `) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 12 (`email_normalisiert`)
3. Betrieb löscht Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, Termine bleiben. → Task 4 + 18
4. Bestätigungsklick genau während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Endzustand. → Task 15 (`verfall_und_bestaetigung_gleichzeitig`)
5. Zwei Widgets auf einer Seite (`data-leistung`) stören sich nicht (IDs, Stile einmal). → Task 19
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 Spike Bulk-Create** (Nutzer-Freigabe einholen): im Container (`docker exec -w /app webklar-kundenbereich-server node …`, Skript im Scratchpad) Wegwerf-Collection `termineSpike` (`bookingId` string 64), 50 parallele `POST …/collections/termineSpike/documents` mit `{ documents: [{ $id, bookingId }, …] }` (je 20 Zellen, Überlappungen) ⇒ je überlappender Gruppe genau ein 201, Rest 409 `document_already_exists`, keine Teil-Schreibungen; Collection löschen. Negativ ⇒ Task 2 baut `createDocuments` über die Transactions-API (`POST /databases/transactions`, Operationen, `PATCH {commit:true}`, 409 `transaction_conflict`).
- [ ] **V2 Gesetzestexte:** Anlage 1 und 2 zu Art. 246a § 1 EGBGB von `https://www.gesetze-im-internet.de/bgbeg/anlage_1.html` und `…/anlage_2.html` wörtlich nach `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt` (Kopfzeile mit Abrufdatum).
- [ ] **V3 WOMS-Worktree:** `git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test`, dort `git ls-files -z node_modules | xargs -0 git update-index --skip-worktree` und `npm install` (nie `git add -A`).
---
## Portal: Grundlagen
### Task 1: Schalter, Konfiguration, Schema
**Files:** Modify `server/config.js`, `test/support/schema.js`; Create `server/services/termine/schalter.js`, `server/services/termine/schema.js`, `scripts/termine-setup.mjs`, `test/termineSetup.test.js`, `test/termineSchalter.test.js`.
**Produces:**
- `config.collections.termineConfig|termineBookings|termineClaims|termineOutbox` (Env `APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX`); `config.termine` laut Global Constraints.
- `schalter.js`: `termineModus()`, `termineFuerKunde(email) ⇒ boolean`, `termineAus() ⇒ boolean`.
- `schema.js` (reine Daten, **kein** Import von `config.js`): `TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … }` exakt nach Spec 2 (mit `einstellung` statt `data`; `termineOutbox.icsMethod`); Attributtypen `string` (Größe), `integer`, `boolean`, `datetime`; Indizes `{ key, type: 'key', attributes }`; `TERMINE_DATETIMES = { collection: [keys] }`.
- `scripts/termine-setup.mjs` (Muster `analyse-setup.mjs`, `createAppwriteClient` von dort wiederverwenden; `createAttribute` nutzt `/attributes/<type>` für alle Typen): `main({ check, client, log })`, legt Fehlendes an, Indizes nur bei neuen Collections, `--check` ⇒ Exit 1 bei Fehlendem.
- `test/support/schema.js`: `schemaKeys(collection)` kennt auch `TERMINE_COLLECTIONS` (statischer Import aus `server/services/termine/schema.js`).
- [ ] Tests: Modus (leer/unbekannt ⇒ off; pilot + `KENSO.gri@gmail.com ` ⇒ true; `*`; Laufzeitänderung von `config.termine.mode` wirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an, `--check` legt nichts an; `schemaKeys('termineConfig')` enthält `einstellung`, nicht `data`; **Rundreise**: Nachbau mit `schemaKeys('termineConfig')`, `createDocument` + `updateDocument` mit `einstellung`/`version`/`updatedBy` und Zurücklesen.
- [ ] FAIL → implementieren → PASS (`node --test test/termineSetup.test.js test/termineSchalter.test.js`, dann `npm test`) → Commit „Terminplaner: Schalter, Konfiguration, Schema“
### Task 2: Appwrite-Wrapper und Nachbau
**Files:** Modify `server/services/appwriteAdmin.js`, `test/support/appwriteMock.js`, `test/support/testApp.js` (exportiert zusätzlich `send(method, path, opts)` = bisheriges privates `request`); Test `test/appwriteBulk.test.js`.
**Produces:**
- `Query.between`, `Query.greaterThanEqual`, `Query.lessThanEqual`, `Query.cursorAfter(id)`.
- `createDocuments(collectionId, documents: Array<{ $id, ...felder }>) ⇒ docs` (roher Body `{ documents }` über neue `adminFetch`-Option `raw: true`; 409 bei Duplikat; Spike-Ergebnis V1).
- `listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs` (`Query.limit(pageSize)`, `orderAsc('$id')`, `cursorAfter`, bis Seite < pageSize; über `max` ⇒ Fehler).
- Nachbau: `startAppwriteMock({ schema, datetimes = {}, defaultLimit = null })` – Bulk alles-oder-nichts (Duplikat ⇒ 409, unbekanntes Attribut je Dokument ⇒ 400, nichts geschrieben); neue Abfragen inkl. `cursorAfter`; Vergleich als Zeitpunkt nur, wenn beide Werte `^\d{4}-\d\d-\d\dT` sind; `datetimes`-Felder werden beim Schreiben auf `…+00:00` normalisiert, `''` ⇒ 400; `defaultLimit` (Termine-Tests: 25) gilt ohne `limit`-Abfrage wie Appwrite.
- [ ] Tests: Bulk 3 Dokumente; Bulk mit vorhandener ID ⇒ 409 und unverändert; `between` inklusiv; `listAllDocuments` über 3 Seiten; `defaultLimit` 25 liefert 25 von 30; Datum `…Z` kommt als `+00:00` zurück; alle bisherigen Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau“
### Task 3: Zeit und Feiertage
**Files:** Create `server/services/termine/zeit.js`, `server/services/termine/feiertage.js`, `test/termineZeit.test.js`; Modify `package.json`/`package-lock.json` (`npm install feiertagejs@1.5.1 --save-exact`).
**Produces:** `zeit.js` (nur `Intl`; `/root/reports/terminplaner-2026-10-02/spike-code/tz.mjs` nur als Vorlage für `offsetMs`): `TZ`; `zuUtc(tag, 'HH:MM') ⇒ Date | null` (null, wenn `ausUtc(Ergebnis)` nicht dieselbe Wanduhrzeit ergibt; `'24:00'` = Folgetag 00:00); `ausUtc(date) ⇒ { tag, uhrzeit }`; `wochentag(tag) ⇒ 1..7`; `plusTage(tag, n)`; `heute(now) ⇒ tag`; `monatInfo('YYYY-MM') ⇒ { label: 'Oktober 2026', ersterWochentag, anzahlTage, vorher, nachher }`; `datumLang(date) ⇒ 'Montag, 12.10.2026'`, `datumKurz(date) ⇒ '12.10.2026'`, `uhrzeit(date) ⇒ '10:30'`. `feiertage.js`: `BUNDESLAENDER` (16 Kürzel), `istFeiertag(tag, land)`.
- [ ] Tests: `zuUtc('2026-03-29','02:30') === null`; `zuUtc('2026-03-29','03:00')` = `01:00Z`; `zuUtc('2026-10-25','02:30')` = `00:30Z`; `ausUtc` rückwärts; `wochentag('2026-10-05') === 1`; `plusTage('2026-10-25', 1)`; `monatInfo('2026-02')` (28 Tage, So=7 als erster); Feiertage RP 2026-06-04 ja, BE 2026-06-04 nein, BE 2026-03-08 ja, SN 2026-11-18 ja, alle 2026-12-25 ja. Zusätzlich `docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zeit (Berlin, Umstellung) und Feiertage“
### Task 4: Einstellungen und Branchen-Vorlagen
**Files:** Create `server/services/termine/einstellungen.js`, `server/services/termine/vorlagen.js`, `test/termineEinstellungen.test.js`.
**Interfaces** (Datenform = Spec 2 `termineConfig.einstellung` + `betrieb.kleinunternehmer: boolean`, `betrieb.websiteUrl`):
- `neueId('r'|'s'|'a') ⇒ 'r_' + 8 [a-z0-9]`.
- `normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = { leistungen: {}, ressourcen: {} }, now }) ⇒ { einstellung } | { fehler: { [pfad]: text } }`: Vorgaben auffüllen; Steuerzeichen entfernen; Längen kürzen; Pflicht `betrieb.name` (1–80), `betrieb.email` (ein Postfach); URLs (`impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`) nur `https:`, ≤ 300, ohne Zugangsdaten; `farbe` `^#[0-9a-f]{6}$`; `anrede` sie|du; `bundesland` ∈ BUNDESLAENDER; IDs `^[rsa]_[a-z0-9]{8}$`, je Liste eindeutig, neue Einträge ohne ID ⇒ `neueId`; Obergrenzen 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderöffnungen, `JSON.stringify ≤ 200 000`; Fenster `HH:MM` auf 5 min, aufsteigend, ohne Überlappung, Ende > Beginn (24:00 erlaubt); `dauer` 5–480, `pufferNach` 0–120 (je Vielfache von 5), `dauer + pufferNach ≤ 500`; `preisArt` fest|ab|keiner, `preisCent` 0–1 000 000; `buchungsart` sofort|anfrage; aktive Leistung braucht ≥ 1 aktive Ressource; `regeln.raster` ∈ {5,10,15,20,30,60}, `vorlaufMin` 0–20160, `horizontTage` 1–365, `absageBisStd` 0–336, `maxOffeneJeEmail` 1–10; `gastFelder` aus|optional|pflicht, Zusatzfelder max. 5 (`{ key: ^[a-z0-9_]{1,20}$, label, typ: text|auswahl, optionen ≤ 10, pflicht }`); `erinnerung.stundenVorher` ∈ {2,12,24,48}; `zahlung.modus` nur `vorort` (sonst Fehler „Online-Zahlung kommt bald“), `vorOrtArten` ⊆ bar|ec|kreditkarte|ueberweisung|paypal; `aufbewahrungTage` 14–365; Abwesenheiten `von ≤ bis`. Entfernen einer ID mit künftigen Terminen ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“ (Ressource entsprechend); `storno.version` bei geändertem Text `YYYY-MM-DD-n` (aus `now`); `feedToken` nur aus `bisher` übernehmen, neue Ressourcen bekommen eines (32 Byte base64url), Eingabewerte werden ignoriert.
- `oeffentlicheEinstellung(einstellung, now) ⇒` Felder von `GET /config` außer `aktiv`, `hinweis`, `texte`, `formToken` (Form Task 16).
- `knopfText(leistung, { bestaetigen })`; `istVerbraucher(einstellung)`; `brauchtZustimmungVorzeitig(einstellung, leistung, start, now)`; `preisText(leistung) ⇒ '45,00 €' | 'ab 45,00 €' | ''`; `preisHinweis(einstellung) ⇒ 'inkl. USt' | 'kein Ausweis der USt nach § 19 UStG'`; `zahlungText(einstellung) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'`.
- `vorlagen.js`: `VORLAGEN` (`salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges`; je 2–5 Leistungen, Plan, `buchungsart` anfrage bei tattoo/handwerk/fotograf, werkstatt mit 2 Plätzen, Stornotext Variante 1 aus `recherche-recht-zahlung.md` 7.3 F), `vorlagenListe()`, `ausVorlage(key, betrieb, now) ⇒ einstellung` (valide).
- [ ] Tests: alle Vorlagen valide; Pflichtfelder; `javascript:alert(1)` als `impressumUrl` ⇒ Fehler am Pfad `betrieb.impressumUrl`; Fenster 09:07 ⇒ Fehler; Überlappung ⇒ Fehler am Pfad `ressourcen.0.plan.1`; Leistung ohne aktive Ressource; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; Client-`feedToken` wird ignoriert; `online` ⇒ Fehler; Knopftexte; Zustimmung vorzeitig (3 Tage ⇒ true, 20 Tage ⇒ false, Unternehmer/kein Preis ⇒ false); `oeffentlicheEinstellung` ohne `feedToken`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einstellungen und Branchen-Vorlagen“
### Task 5: Verfügbarkeit
**Files:** Create `server/services/termine/slots.js`, `test/termineSlots.test.js`.
**Produces:** `belegungAus(buchungen, now, { ohneBuchung = null }) ⇒ [{ ressourceId, start, ende }]` (belegende Status, `ende` = `blockEndsAt`, ohne die genannte Buchung); `freieZeiten({ einstellung, leistungId, ressourceId /* id|'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit, start, ressourceIds }]` (Sonderöffnung > Abwesenheit > Feiertag > Plan; Raster ab Fensterbeginn; `start + dauer + pufferNach` im Fenster; frei; `start ≥ now + vorlaufMin` und `tag ≤ heute + horizontTage` außer `ohneRegeln`; nie Vergangenheit); `freieTage({ …, monat }) ⇒ ['YYYY-MM-DD']`; `waehleRessource(ids, buchungenAmTag, einstellung)`; `blockEnde(start, leistung)`.
- [ ] Tests: Pause; Puffer am Pausenrand; Vorlauf; Horizont; Abwesenheit Ressource und Betrieb; Feiertag vs. Sonderöffnung; abgelaufene Haltung blockiert nicht; `egal` mit zwei Personen; `ohneBuchung` gibt die eigene Zeit frei; 2026-03-29 ohne `02:xx`; 2026-10-25 60-min-Termin um 02:00 dauert real 60 min; letzter Termin endet genau am Fensterende; Belegung, die am Vortag 22:00 beginnt und bis 06:00 läuft, blockiert 05:30.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Verfuegbarkeit“
### Task 6: Zellen, Tokens, Buchungsspeicher
**Files:** Create `server/services/termine/zellen.js`, `server/services/termine/token.js`, `server/services/termine/buchungen.js`, `test/termineZellen.test.js`.
**Consumes:** Task 2. **Produces:**
- `token.js`: `neuesToken() ⇒ { token, hash }`; `hashToken(t)`; `verwaltungsToken(bookingId) ⇒ token`; `buchungsNummer(bookingId) ⇒ 8 [A-Z0-9]`; `formToken(siteKey, now)`, `pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (Mindestalter `config.termine.formMinSekunden`).
- `zellen.js` (`spike-code/claims.mjs` nur als Vorlage für den Hash-Ansatz; Zellgröße/ID/Puffer wie hier): `zellenFuer(start, blockEnde) ⇒ Date[]`; `zellenId(featureId, ressourceId, zelle)`; `belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null }) ⇒ void` (Sperre `termine-res:<featureId>:<ressourceId>`, `waitMs 5000`; mit `ausser` zählen Zellen dieser Buchung als eigene: angelegt wird nur Neu − Eigen; bei 409 blockierende Zellen per ID lesen, Gültigkeit nach Global Constraints prüfen, ungültige löschen, genau ein zweiter Versuch, sonst Fehler `{ status: 409, code: 'SLOT_VERGEBEN' }`); `gibZellenFrei(bookingId, { behalte = [] })` (über `listAllDocuments`, behält die genannten Zellzeiten); `verwaisteZellen(now) ⇒ ids` (`bis < now − 1 Tag` oder Buchung nicht belegend).
- `buchungen.js` (nur Datenzugriff): `legeAn`, `hole`, `aendere` (setzt `updatedAt`), `imZeitraum(featureId, von, bis)` (`lessThan(startsAt, bis)` + `greaterThan(blockEndsAt, von)` + `featureId`, `listAllDocuments`), `perHash(feld, hash)`, `offeneFuerEmail(featureId, email, now)`, `perEmailUndNummer(featureId, email, nummer)`, `zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }`, `abgelaufeneHaltungen(now)`, `erinnerungsKandidaten(now)`, `zuErledigen(now)`, `vergangeneAnfragen(now)`, `loeschKandidaten(status, vor)`, `zaehleAnfragen(featureId)`.
- [ ] Tests (Nachbau, `defaultLimit 25`): zweite Buchung derselben Zeit ⇒ `SLOT_VERGEBEN`; 10 parallel ⇒ genau eine; abgelaufene Haltung und fehlende Buchung blockieren nicht; Verschieben 10:00→10:30 mit `ausser` gelingt, Fremdbuchung im neuen Bereich ⇒ `SLOT_VERGEBEN` und alte Zellen unverändert; `gibZellenFrei` löscht 40 Zellen (über 25er-Seiten); `imZeitraum` findet die Belegung vom Vortag; Verwaltungstoken stabil; Formular-Token zu jung/alt/gefälscht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“
### Task 7: Rechtstext-Bausteine für Gäste
**Files:** Create `docs/rechtliches/termine-widerrufsbelehrung-muster.md`, `docs/rechtliches/termine-datenschutz-absatz.md`, `docs/rechtliches/termine-hinweise.md`, `docs/rechtliches/termine-datenschutz-6c-entwurf.md`, `server/services/termine/rechtstexte.js`, `test/termineRechtstexte.test.js`.
**Consumes:** V2. **Produces:** Belehrung + Muster-Widerrufsformular für Dienstleistungen aus `docs/rechtliches/quellen/anlage-1.txt`/`anlage-2.txt` (Gestaltungshinweise angewendet: Dienstleistung, Betriebsdaten, Hinweis auf die Widerrufsfunktion), immer Sie-Form; Datenschutz-Absatz (Sie/Du) und Formular-Hinweis nach `recherche-recht.md` 11.1/11.2 mit den Löschfristen der Global Constraints; 6c-Entwurf (nicht einbauen). Alle Dateien mit Kopf „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“. `rechtstexte.js`: `RECHTSTEXTE_VERSION = '2026-10-02'`, `widerrufsbelehrung(betrieb) ⇒ string` (Text mit Zeilenumbrüchen), `formularHinweis(einstellung) ⇒ string`, `ZUSTIMMUNG_VORZEITIG(name)`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG(stunden)`, `GESUNDHEIT_HINWEIS`.
- [ ] Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift, keinen offenen Platzhalter und die unveränderten Kernsätze der Quelle (Absätze aus `anlage-1.txt` wörtlich enthalten); Zustimmungstexte wortgleich zu den Global Constraints.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)“
### Task 8: Kalenderdatei, Gästeversand, Vorlagen-Engine
**Files:** Create `server/services/termine/ics.js`, `test/termineIcs.test.js`; Modify `server/services/mailTemplates.js` (`[GKT]\d{2}` in `templateIds` und im Kopfkommentar-Regex), `server/services/mailer.js`, `test/mailTemplates.test.js` (Ordner-Test-Regex `[GKT]`; Logo-Prüfung nur K/T; G-Vorlagen: kein `webklar-logo.png`).
**Produces:** `ics.js`: `icsTermin({ uid, sequenz, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start, ende, titel, ort, beschreibung, organisator: { name, email }, teilnehmer: { name, email } | null, jetzt }) ⇒ string` (CRLF, Faltung 75 Oktette, Escaping `\ ; , \n`, CR/LF in Texten entfernt, `CN="…"` ohne `"` und Steuerzeichen, UTC, `RSVP=FALSE`, CANCEL mit `STATUS:CANCELLED`); `icsKalender(termine, { name, jetzt })` (`METHOD:PUBLISH`, `X-WR-CALNAME`, `X-PUBLISHED-TTL:PT15M`). `mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) ⇒ info` (wirft; `from: { name, address }`; `icalEvent: { method, filename: 'termin.ics', content }` nur mit `ics`; nie `bcc`).
- [ ] Tests: ICS-Zeilenlänge, `UID`/`SEQUENCE`, Escaping, CANCEL; `sendGuestMail` über `mailSink`: kein `bcc`, `replyTo`, From-Name = Betrieb, `sent[0].icalEvent.method === 'REQUEST'` und `content` enthält `BEGIN:VCALENDAR`, ohne `ics` kein `icalEvent`; bestehende Vorlagen-Tests grün (noch ohne neue Vorlagen).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen“
### Task 9: Mailvorlagen G01–G08, K20, K21
**Files:** Create `server/mail-templates/G01 Termin bestaetigen.html`, `G02 Termin bestaetigt.html`, `G03 Anfrage eingegangen.html`, `G04 Anfrage abgelehnt.html`, `G05 Erinnerung.html`, `G06 Termin abgesagt.html`, `G07 Termin verschoben.html`, `G08 Eingang Widerruf.html`, `K20 Neue Buchung.html`, `K21 Termin geaendert.html`; Modify `test/mailTemplates.test.js` (`EXPECTED` sortiert: G01–G08, K01–K21, T01–T18; Kommentar).
- Gerüst wie die vorhandenen Vorlagen: Kopfkommentar (`G01 · …`, `Betreff: {{betreff}}`, Platzhalter-, Block-Liste), `<title>{{betreff}}</title>`, `<!--[if mso]>`-Block, sonst keine Kommentare, jeder Platzhalter/Block deklariert und benutzt.
- G-Layout neutral: Kopfbalken in `{{farbe}}` mit `{{betrieb_name}}`, kein Logo; Fuß `{{betrieb_name}}`, `{{betrieb_adresse}}`, `{{betrieb_telefon}}`, `{{betrieb_email}}`, `{{impressum_url}}`, `{{datenschutz_url}}`, Satz „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke `sie`/`du` für jeden Anredetext.
- Platzhalter: G01 `leistung, datum, zeit, link` + Block `zu_viele` (Text: höchstmögliche Zahl offener Termine, absagen oder anrufen `{{betrieb_telefon}}`) – **kein** `gast_name`; G02 `gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort` + Blöcke `preis`, `person`, `storno` (`{{storno_text}}`), `widerruf` (`{{widerrufsbelehrung}}`, `{{widerruf_link}}`), `anfrage_angenommen`; G03 `gast_name, leistung, datum, zeit, link`; G04 `gast_name, leistung, datum, zeit` + Block `nachricht` (`{{nachricht}}`); G05 `gast_name, leistung, person, datum, zeit, link` + Block `person`; G06 `gast_name, leistung, datum, zeit, absage_von, grund` + Block `nachricht`; G07 `gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link`; G08 `eingang, nummer` + Blöcke `zugeordnet`, `nach_frist` – **kein** `gast_name`. K20 (Du-Form, WEBklar-Layout mit Logo): `kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link` + Block `anfrage`; K21: wie K20 + `alt_datum, alt_zeit, eingang` + Blöcke `abgesagt`, `verschoben`, `widerrufen`, `nicht_zugeordnet`, `nach_frist`.
- [ ] Tests: Vorlagen-Suite grün inkl. neuer IDs (Titel = Betreff, ein Kommentar, keine Reste, Text ohne Tags); `renderMail('G02', …, { du: true })` enthält im Text kein „ Sie “/„Ihr “; G01/G08 haben keinen Platzhalter `gast_name`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mailvorlagen G01–G08, K20, K21“
### Task 10: Mail-Warteschlange
**Files:** Create `server/services/termine/outbox.js`, `test/termineOutbox.test.js`.
**Consumes:** Task 8. **Produces:** `einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethod }) ⇒ doc` (rendert sofort, speichert `wartet`, ruft `sendeEintrag(id)` ohne zu warten); `sendeEintrag(id)` (einziger Sendepfad: Pause/Budget prüfen, Sperre `termine-mail:<id>`, Fehlerklassen laut Global Constraints, nach Erfolg Inhalt leeren); `verarbeite(now)`; `warteAufVersand()` (für Tests: wartet auf laufende Sendeversuche); `zustellFehler(bookingIds) ⇒ Set`; `raeumeAuf(now)`; `pausiertBis()`.
- [ ] Tests: Budget 2/h ⇒ dritter wartet; 4xx ⇒ 1/5/30 min, dann `fehler`; 550 ⇒ sofort `fehler`; EAUTH ⇒ Pause, während der Pause **keine** Sendeversuche (Fake-Transport zählt), danach weiter; zwei `verarbeite` parallel ⇒ eine Mail; nach Erfolg sind `an/html/text/ics` leer; Aufräumen 7 Tage / 48 h.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“
## Portal: Ablauf
### Task 11: Kontext, Einstellungs-Speicher, Mail-Werte
**Files:** Create `server/services/termine/kontext.js`, `server/services/termine/mails.js`, `test/termineKontext.test.js`.
**Consumes:** Tasks 4, 6, 7, 9, 10. **Produces:**
- `kontext.js`: `kontextPerSiteKey(siteKey) ⇒ ctx|null` (Funktion `termine`, `active`, Modus ≠ off; eigene Abfrage bis Task 17, dann `findActiveBySiteKey(siteKey, 'termine')`; 30 s Speicher-Cache je siteKey); `kontextPerFeature(featureId) ⇒ ctx|null` (auch `disabled`); `ctx = { feature, project, einstellung, version }`; `ladeEinstellung(featureId)`; `speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version }` (Sperre `termine-cfg:<featureId>`, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren); `websiteUrl(ctx)` (`einstellung.betrieb.websiteUrl` oder `https://` + erster Host aus `analyseHosts(project)`).
- `mails.js`: `BETREFFE` (Tabelle je Vorlage × Anrede × Fall, Sie/Du, G05 „Erinnerung: Ihr Termin am {datum} um {zeit}“); `werteFuer(art, ctx, buchung, extra) ⇒ { values, blocks }` (Links `${config.publicBaseUrl}/termin.html#v=<token>` bzw. `#m=<verwaltungsToken>`; `widerruf_link` = Verwaltungslink); `anGast(art, ctx, buchung, extra)`, `anBetrieb(art, ctx, buchung, { block, extra })` über `einreihen` (ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH).
- [ ] Tests: Speichern/Laden mit Version, Konflikt ⇒ 409; Cache wird beim Speichern geleert; Betreff je Anrede (Du ohne „Sie“); G02-Werte mit Widerrufsblock nur bei Verbraucher; Links zeigen auf `config.publicBaseUrl`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte“
### Task 12: Gast: halten, Vorschau, bestätigen
**Files:** Create `server/services/termine/ablauf.js`, `test/termineAblaufGast.test.js`.
**Consumes:** Tasks 5, 6, 11. **Produces** (jede Zustandsänderung unter `withLocks(['termine-buchung:'+id])` mit frischem Lesen):
- `halte(ctx, eingabe, now) ⇒ { status: 'gehalten'|'zu_viele', email }` – Eingabe `{ leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } }`; Feldfehler (400, `err.fehler`); Slot neu berechnen; Ressource bei `egal` wählen; **erst** Buchung `gehalten` anlegen (`heldUntil = now + holdMinutes`, `nachweis` = Fassungen/Hashes der Texte, Zeitpunkte, Knopftext, Anrede; `verbraucher`, `preisCent`, `preisArt`, `buchungsart`, `zahlart 'vorort'`, `zahlStatus 'keine'`, `icsSequenz 0`, `verifyHash`), **dann** Zellen; `SLOT_VERGEBEN` ⇒ Buchung `verfallen`, Fehler 409 Feld `zeit`; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Block `zu_viele`, Ergebnis `zu_viele`.
- `vorschau(token, now) ⇒ { art: 'bestaetigen'|'verwalten', status, termin: { nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl, websiteUrl }, gast: { name, email }, anrede, farbe, knopf, aktionen: { bestaetigen, absagen, verschieben, widerrufen }, hinweis }` (Token zuerst als Bestätigungs-, dann als Verwaltungstoken suchen; unbekannt/abgelaufen ⇒ 404).
- `bestaetige(token, now) ⇒ { status, schon? }` – `gehalten` + `heldUntil > now` ⇒ `sofort`: `bestaetigt` + `bestaetigtAt` + `manageHash` + G02 + K20; `anfrage`: `angefragt` + `manageHash` + G03 + K20-Block `anfrage`; schon bestätigt ⇒ `{ status, schon: true }`; abgelaufen ⇒ 410 `{ code: 'abgelaufen', betrieb: { name, telefon, websiteUrl } }`.
- [ ] Tests (Nachbau `defaultLimit 25`, `mailSink`, `warteAufVersand`): Halten ⇒ G01 ohne Gastname, Zellen; Bestätigen ⇒ G02 mit `icalEvent` und `#m=`-Link + K20 an den Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ `schon`; nach 31 min ⇒ 410; `email_normalisiert` (`' Max@GMail.com '` zählt gegen dieselbe Grenze); vierte offene Buchung ⇒ `zu_viele`, keine Buchung, G01-Block; Slot weg ⇒ 409, Buchung `verfallen`, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feld `vorzeitig`; `vorschau` mit Verwaltungstoken liefert `gast`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Buchung halten und bestaetigen“
### Task 13: Gast: absagen, verschieben, widerrufen
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufVerwalten.test.js`.
**Produces:** `sageAbGast(token, now)` (bis `absageBisStd` vor Beginn, sonst 409 Feld `_` „Absage nur telefonisch: {telefon}“; Zellen frei, G06 CANCEL, K21 `abgesagt`); `verschiebeGast(token, { tag, zeit }, now)` (gleiche Frist; Belegung `ohneBuchung`; `belegeZellen({ …, ausser: id })`, dann `gibZellenFrei(id, { behalte: neueZellen })`; `icsSequenz+1`; G07 + K21 `verschoben`); `zeitenFuerToken(token, { tag | monat }, now)` (für die Gästeseite, `ohneBuchung`); `widerrufeGast(token, { name }, now) ⇒ { eingang: { datum, uhrzeit } }` und `widerrufeOhneLink(ctx, { name, email, nummer }, now) ⇒ { eingang }` (Treffer mit Widerrufsrecht ⇒ `widerrufenAt`, `abgesagt` von `gast`, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`; Treffer nach Frist ⇒ nur speichern + G08 `nach_frist` + K21 `nach_frist`; kein Treffer ⇒ G08 + K21 `nicht_zugeordnet`; Grenzen aus Global Constraints; Antwort immer gleich, Eingang in Berliner Zeit als Strings).
- [ ] Tests: Absage vor/nach Frist; Verschieben 10:00→10:30 (überlappend) gelingt, ICS Sequenz 1; Verschieben auf belegte Zeit ⇒ 409, alte Zellen unverändert; Widerruf 2 h vor Termin trotz abgelaufener Stornofrist; Widerruf ohne Treffer = gleiche Antwort + K21 `nicht_zugeordnet`; vierter G08 an dieselbe Adresse am Tag wird nicht gesendet; nicht angenommene Anfrage ⇒ kein Widerruf, nur Absage.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste“
### Task 14: Betrieb: Aktionen, manuelle Termine, Sperren
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufBetrieb.test.js`.
**Produces:** `aktion(ctx, bookingId, { aktion: 'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen'|'anonymisieren', grund, nachricht, tag, zeit }, now)` (annehmen ⇒ `bestaetigt`, `angenommenAt`, G02 Block `anfrage_angenommen`; ablehnen ⇒ `abgelehnt`, G04, Zellen frei; absagen ⇒ G06 mit `absage_von` = Betriebsname; verschieben ⇒ wie Gast, ohne Frist, `ohneRegeln`; nichterschienen nur nach Beginn; anonymisieren ⇒ Gastfelder leeren, Status bleibt); `manuell(ctx, eingabe, now)` (`quelle 'manuell'`, `bestaetigt`, `verbraucher false`, Länge ≤ 500 min, G02 nur mit `bestaetigungSenden`); `sperre(ctx, { ressource, tag, von, bis, titel }, now)` (`quelle 'sperre'`, `bestaetigt`, `leistungName` = Titel, Länge ≤ 500 min sonst 400 Feld `bis` „Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.“); `entsperre(ctx, bookingId)`.
- [ ] Tests: annehmen ⇒ G02 mit Verwaltungslink (gleicher Link wie in G03); ablehnen ⇒ Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Sperre 09:00–17:30 ⇒ 400; Sperre 09:00–13:00 blockiert Slots; anonymisieren; `nichterschienen` vor Beginn ⇒ 409.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren“
### Task 15: Hintergrund-Jobs
**Files:** Create `server/services/termine/jobs.js`, `test/termineJobs.test.js`; Modify `server/index.js`.
**Produces:** jeder Job kehrt bei `termineAus()` sofort zurück (keine Abfrage); `collection_not_found` (404) ⇒ höchstens eine Warnung je Stunde. `minutenJob(now)` (abgelaufene Haltungen ⇒ `verfallen`, Zellen frei; `outbox.verarbeite`); `fuenfMinutenJob(now)` (Erinnerungen nach Spec 4: Marker zuerst, dann G05; `erledigt`; vergangene Anfragen ⇒ `abgelehnt`); `taeglicherJob(now)` (Löschfristen, Outbox aufräumen, verwaiste Zellen, `disabled`-Funktionen > 30 Tage: Buchungen, Zellen, Einstellung). In `server/index.js` wie die vorhandenen Jobs (`setTimeout(…).unref()` + `setInterval(…).unref()`, eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s).
- [ ] Tests: Modus off ⇒ keine Appwrite-Anfrage; Verfall; Erinnerung genau einmal (zwei Läufe), nicht bei Buchung kürzer als `stundenVorher` vor Beginn, nachgeholt nach 20 min Jobausfall; `erledigt`; vergangene Anfrage ⇒ `abgelehnt`, Zellen frei; Löschfristen je Status; `verfall_und_bestaetigung_gleichzeitig` (Job und `bestaetige` parallel ⇒ ein Endzustand, keine G02 nach `verfallen`); fehlende Collection ⇒ eine Warnung.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Hintergrund-Jobs“
## Portal: Schnittstellen
### Task 16: Öffentliche Schnittstelle
**Files:** Create `server/services/termine/limits.js`, `server/routes/publicTermine.js`, `test/publicTermine.test.js`, `test/publicTermineRohling.test.js`; Modify `server/index.js` (nach `kundenansichtGuard`, vor dem globalen `express.json`), `server/middleware/kundenansichtGuard.js` (`PASS_PREFIXES` + `'/api/public/'`), `test/kundenansichtGuard.test.js`.
**Consumes:** Tasks 11–14, `analyseDomains(project)` aus `server/services/analyse.js`.
**Routen** (`/api/public/termine/v1`; feste Pfade vor `/:siteKey`):
- `GET /:siteKey/config` ⇒ `{ aktiv: true, heute, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen: [{ id, name, beschreibung, dauer, preis, preisArt, buchungsart, knopf, verbraucher, ressourcen }], ressourcen: [{ id, name, typ }], personWaehlbar, regeln: { horizontTage, absageBisStd }, zahlung: { modus: 'vorort', text }, preisHinweis, storno: { text, version } | null, widerruf, erinnerung: { anbieten, stundenVorher }, gastFelder, hinweis, texte: { storno, erinnerung, vorzeitig, gesundheit }, formToken }` (`public, max-age=60`, ohne ETag).
- `GET /:siteKey/tage?leistung&ressource&monat` ⇒ `{ tage, monat: monatInfo }` (`max-age=15`); `GET /:siteKey/zeiten?leistung&ressource&tag` ⇒ `{ zeiten: ['09:00'], datumLang, vorzeitigNoetig }` (`no-store`; `vorzeitigNoetig` serverseitig für diesen Tag).
- `POST /:siteKey/buchungen` (Eingabe von `halte` + `formToken`, `kontakt_url`) ⇒ 201 `{ ok: true, email: 'm***@gmail.com', minuten: 30 }`.
- `POST /:siteKey/widerruf` `{ name, email, nummer }` ⇒ `{ ok: true, eingang: { datum, uhrzeit } }`.
- `POST /termin/vorschau|bestaetigen|absagen` `{ token }`, `POST /termin/verschieben` `{ token, tag, zeit }`, `POST /termin/widerruf` `{ token, name }`, `POST /termin/tage` `{ token, monat }`, `POST /termin/zeiten` `{ token, tag }`.
- `GET /kalender/:siteKey/:feedToken.ics` (konstantzeitiger Vergleich mit den `feedToken` der Ressourcen; falsch ⇒ 404 ohne Hinweis; `text/calendar; charset=utf-8`, `max-age=300`; bestätigte Termine + Sperren der Ressource, −7 bis +90 Tage).
- CORS: GET `*`; POST/OPTIONS erlaubt, wenn Origin-Host ∈ `analyseDomains(project)` ∪ {Host von `config.publicBaseUrl`} oder `new URL(origin).host === req.get('host')` (gleiche Herkunft); für `/termin/*` nur die beiden letzten; dann `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; sonst 403 `{ fehler: { _: 'Nicht erlaubt' } }`; Preflight 204 (`GET, POST, OPTIONS`, `Content-Type`, `Max-Age 86400`). Router-Parser + Fehlermiddleware laut Global Constraints; `/tage` und `/zeiten` 10 s Speicher-Cache je (siteKey, Parameter).
- [ ] `publicTermine.test.js` (startTestApp + Nachbau + `mailSink`; Origin/`X-Forwarded-For` je Test, `resetTermineLimitsForTests()`, `config.termine.formMinSekunden = 0`): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒ `{aktiv:false}` (GET und POST); fremde Origin ⇒ 403, gleiche Herkunft und `liveDomain`-Origin erlaubt; Preflight (`send('OPTIONS', …)`); Honeypot und `zu_schnell` (mit `formMinSekunden = 3`) ⇒ 201 ohne Buchung; `abgelaufen` ⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall in `config`; Ablauf halten → vorschau → bestätigen → verschieben → absagen über HTTP; Widerruf mit/ohne Treffer gleiche Antwort; Kalender mit falschem Token 404, richtigem enthält den bestätigten, nicht den gehaltenen Termin; `failNext` ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
- [ ] `publicTermineRohling.test.js` (nacktes `express()` nur mit dem Router): 20 KB ohne `content-length` (chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: in `server/index.js` steht `app.use(kundenansichtGuard)` vor `'/api/public/termine/v1'` vor `app.use(express.json(`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“
### Task 17: Funktionen verallgemeinern, Team-Route, Kundenansicht
**Files:** Modify `server/services/siteFeatures.js`, `server/routes/admin/funktionenAdmin.js`, `server/routes/funktionen.js`, `server/middleware/kundenansichtGuard.js`, `server/services/customerViewLog.js`, `server/services/customerAdmin.js`, `server/services/termine/kontext.js`; Tests `test/funktionenRoutes.test.js`, `test/siteFeatures.test.js`, `test/kundenansichtGuard.test.js` (Erlaubnisliste exakt; `MOUNTS` + `/api/termine` erst in Task 18), `test/customerViewLog.test.js` (zehn Aktionen), neu `test/funktionenTermine.test.js`.
**Produces:** `FEATURE_KEYS = ['news','termine']`; `FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' } }`; `findActiveBySiteKey(siteKey, featureKey = 'news')`; `activateClosedTickets` unverändert (TP2). Team: `POST /api/team/funktionen/:customerId/:key` (`key` ∉ FEATURE_KEYS ⇒ 404; Meldung „Für dieses Projekt gibt es noch {keine}“); `adminView` lädt die Funktionsdokumente des Kunden einmal. `GET /api/funktionen` + `termine: { verfuegbar: termineFuerKunde(req.session.email) }`. `KUNDENANSICHT_ALLOWED` + `['POST', /^\/api\/termine\/.+$/]`, `['PATCH', /^\/api\/termine\/einstellungen\/?$/]`, `['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/]`; `VIEW_ACTIONS` + `termin_einstellungen`, `termin_aktion`, `termin_manuell`. `deleteCustomerWithPortalAccess` schaltet Terminplaner-Funktionen des Kunden ab (Notiz „Kunde gelöscht“). `kontext.js` nutzt nun `findActiveBySiteKey(…, 'termine')`.
- [ ] Tests: Team aktiviert `termine` für Projekt von c1, News unverändert; `/:customerId/kalender` ⇒ 404; Meldungen grammatisch; `GET /api/funktionen` mit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. Negativfall `POST /api/termineX`); Kunde löschen ⇒ `disabled`; bisherige Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“
### Task 18: Kunden-Schnittstelle `/api/termine`
**Files:** Create `server/routes/termine.js`, `test/termineRoutes.test.js`; Modify `server/index.js` (`app.use('/api/termine', termineRoutes)`), `test/kundenansichtGuard.test.js` (`MOUNTS` + `['/api/termine', termineRoutes]`).
**Consumes:** Tasks 4, 6, 11–14, 17. Alle Routen: `requireSession`; `projectId` (Query/Body); `assertOwnProject`; `termineFuerKunde(session.email)` sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion `off`/fehlt ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; `disabled` ⇒ nur Lesen + Export; Schreiblimit 120/h je Kunde; Protokoll: PATCH einstellungen, POST vorlage, POST feed ⇒ `termin_einstellungen`; POST aktion, DELETE ⇒ `termin_aktion`; POST buchungen ⇒ `termin_manuell`.
**Routen:** `GET /einstellungen?projectId` ⇒ `{ state, aktivSeit, einstellung | null, version, vorlagen, feeds: { [ressourceId]: url }, siteKey, einbau: { snippet }, anfragenOffen }`; `POST /einstellungen/vorlage` `{ projectId, vorlage }` (nur ohne Einstellung, sonst 409; Betrieb vorbefüllt aus `analysePrivacyData(customer)` (Name/Firma, Anschrift, E-Mail) + `phone`); `PATCH /einstellungen` `{ projectId, version, einstellung }` ⇒ `{ einstellung, version }` | 400 `{ fehler }` | 409; `GET /buchungen?projectId&von&bis&status=a,b` (Zeitraum ≤ 62 Tage) ⇒ `{ buchungen: [{ id, nummer, status, quelle, startsAt, endsAt, datum, zeit, leistungId, leistung, dauer, titel, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, angenommenAt, widerrufenAt, abgesagtVon, absageGrund }] }`; `GET /zeiten?projectId&leistung&ressource&tag[&ohne=bookingId]` (`ohneRegeln`); `POST /buchungen` `{ projectId, art: 'manuell'|'sperre', … }` ⇒ 201; `POST /buchungen/:id/aktion`; `DELETE /buchungen/:id?projectId` (nur Sperren); `POST /ressourcen/:id/feed` ⇒ `{ url }`; `GET /export.csv?projectId` (`text/csv; charset=utf-8`, BOM, `;`, Kopf `Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht`, Zellen mit `= + - @` am Anfang ⇒ `'` voran).
- [ ] Tests (`send` für PATCH/DELETE): fremdes Projekt 403; ohne Pilot 409; Vorlage zweimal ⇒ 409; PATCH alte Version ⇒ 409; Löschen mit künftigem Termin ⇒ 400 mit Anzahl; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH/DELETE (mit Header) und es wird protokolliert; Guard-Schleife „jede Schreib-Route außerhalb der Liste ⇒ 403“ grün mit dem neuen Router; `disabled` ⇒ PATCH 409, Export 200; CSV entschärft.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“
## Portal: Oberflächen
**UI-Testserver (Tasks 19–21):** `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` + `startFakeStripe()` + `useTestEnv({ appwriteUrl, stripeUrl })`, dann dynamische Imports; `startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/': express.static('public') })`; danach `config.publicBaseUrl = app.url`, `config.termine.mode = 'pilot'`, `config.termine.pilotEmails = ['*']`, `config.termine.formMinSekunden = 0`; Seed: Projekt + aktive Funktion + Einstellung aus Vorlage `salon` mit Plan Mo–So 00:00–24:00, `feiertageGeschlossen false`, `vorlaufMin 0`, `maxOffeneJeEmail 10`; `mailSink`. Screenshots nach `/root/reports/terminplaner-ui/`.
### Task 19: Einbau-Skript, Demo, Einbauanleitung
**Files:** Create `public/embed/termine.js`, `public/embed/termine-demo.html`, `docs/funktionen/termine-einbau.md`, `test/ui/termine-widget.ui.mjs`, `test/termineEmbedStatic.test.js`.
**Consumes:** Task 16 (Antwortformen). **Produces:** Einbau nach Spec 7 (`data-webklar-termine`, `data-api`, `data-modus inline|knopf`, `data-knopf`, `data-leistung`); Klassen `wkt-*`; `<style id="wkt-style">` einmal, `@layer wkt`; CSS-Variablen `--wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel`; Schritte Spec 4; Monatsraster aus `monat` (APG: Pfeile, Pos1/Ende, Bild↑/↓, `aria-selected`, `aria-disabled`), Zeiten als Knöpfe; `aria-live="polite"`; Texte Sie/Du nach `anrede`, Haken-Texte aus `texte`, Knopf aus `leistungen[].knopf`; Honeypot `kontakt_url` (unsichtbar, `autocomplete="off"`, `tabindex="-1"`, `aria-hidden`); `config` neu laden, wenn älter als 100 min; Fuß „Vertrag widerrufen“ (Formular → `POST /widerruf`), „Datenschutz“, „Terminbuchung über WEBklar“; Knopfmodus lädt beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. Einbauanleitung: Snippet, Variablen, drei Stilbeispiele, Datenschutz-Absatz (Task 7), Abnahme-Checkliste, API-Kurzreferenz.
- [ ] `termineEmbedStatic.test.js`: Quelltext ohne `localStorage`, `sessionStorage`, `document.cookie`, `Date`, `getDay`, `toLocale`, `Intl.`; `innerHTML` nur mit Konstanten.
- [ ] Playwright (UI-Testserver): inline bis „Fast geschafft“ + G01 im Sink; Knopfmodus mit `<dialog>`; nur Tastatur; 390 px; `timezoneId: 'America/New_York'` zeigt dieselben Uhrzeiten; Anrede Du; zwei Widgets; Server gestoppt ⇒ Ausweichtext.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“
### Task 20: Gästeseiten
**Files:** Create `public/termin.html`, `public/termin.js`, `public/termin.css`, `test/ui/termin-seiten.ui.mjs`.
**Produces:** liest `#v=`/`#m=`, entfernt den Hash per `history.replaceState`, ruft `/termin/vorschau`; Ansichten Bestätigen (Zusammenfassung + `knopf`), Bestätigt/Angefragt, Abgelaufen (Link `websiteUrl`, Telefon), Verwalten (Absagen mit Rückfrage in der Seite, Verschieben über `/termin/tage|zeiten`, „Vertrag widerrufen“ → Formular nach § 356a mit vorbefülltem Namen/Buchungsnummer/E-Mail, Knopf „Widerruf bestätigen“, Eingang mit `eingang.datum`/`uhrzeit`); Kopf in `farbe`; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (`https://webklar.com/impressum`); `noindex`; keine Cookies; Sie/Du.
- [ ] Playwright: G01-Link aus dem Sink öffnen ⇒ bestätigen ⇒ G02 im Sink; zweiter Aufruf „bereits bestätigt“; abgelaufen; absagen; verschieben; widerrufen (`timezoneId: 'America/New_York'` zeigt Berliner Eingangszeit); 390 px.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“
### Task 21a: Portal-Ansicht – Gerüst, Kachel, Termine
**Files:** Create `public/termine.js`, `public/termine.css`, `public/termine-view.js`, `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs`; Modify `public/funktionen.js`, `public/app.js` (`VIEW_HASHES.termine = 'termine'`, `viewRefreshers.termine`, `APP_PARENT.termine = 'funktionen'`, `FEATURE_LABELS.termine = 'Terminplaner'`), `public/dashboard.html` (`<section class="kb-view" id="view-termine"><header class="kb-head"><h1>Terminplaner</h1><p>…</p></header><div id="termine-body">…`, Stylesheet), `test/funktionenTiles.test.js`.
**Produces:** Kachel (`termine?.verfuegbar` fehlt/false ⇒ „Kommt bald“; `project.features?.termine || { state: 'off' }`; *Aus* „Wir schalten den Terminplaner für dich frei.“ + Chat / *Aktiv* „Termine öffnen“ + „{n} Anfragen warten“ aus `anfragenOffen` / *Abgeschaltet*); `termine-view.js` (rein): `fensterParsen('09:00–12:30, 13:30–18:00')`, `fensterText`, `statusLabel`, `wocheVon(tag)`, `gruppiereNachTag(buchungen)`; `termine.js` `initTermine(root, { projectId })`: Projektauswahl, Vorlagenauswahl ohne Einstellung, Reiter-Gerüst, Reiter **Termine** (Tag/Woche/Monat mit `von/bis`, Filter → Statuslisten, Aktionen mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“ mit 500-Minuten-Hinweis, Hinweis „Bestätigung nicht zugestellt“), `disabled` ⇒ nur Lesen.
- [ ] Tests reine Helfer; Kachelzustände; Playwright (Fixture `page.route` und ein Lauf gegen den UI-Testserver): Vorlage wählen, Liste mit allen Status, Anfrage annehmen, App-Ansicht 390 px als Unterseite von Funktionen (Titel „Terminplaner“).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht, Kachel, Termine“
### Task 21b: Portal-Ansicht – Leistungen, Team & Plätze, Einstellungen
**Files:** Modify `public/termine.js`, `public/termine.css`, `test/ui/termine-portal.ui.mjs`.
**Produces:** Reiter **Leistungen** (Liste, Reihenfolge, Felder Task 4, Gesundheits-Hinweis), **Team & Plätze** (Wochenplan je Tag mit `fensterParsen`, Leistungszuordnung, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), **Einstellungen** (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster gekennzeichnet; Export-Link); Speichern mit `version`, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld.
- [ ] Playwright: Leistung anlegen, Wochenplan ändern, Speichern, Konflikt (zweiter Tab), Feldfehler (`javascript:`-URL), Feed-Link neu erzeugen.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen“
## WOMS
### Task 22: Funktionen-Reiter für mehrere Funktionen
**Repo:** `/home/webklar/apps/ticket.webklar.com/dev-termine` (aus V3; existiert er nicht, Schritte aus V3 zuerst ausführen).
**Files:** Create `src/lib/funktionenView.js`, `tests/unit/funktionenView.test.mjs`; Modify `src/lib/teamApi.js` (`setFeature(customerId, key, { projectId, action, note })`; `setNewsFeature` als Wrapper), `src/components/customer/CustomerFunktionen.jsx`, `src/lib/viewLog.js` (+ `termin_einstellungen: 'Terminplaner-Einstellungen geändert'`, `termin_aktion: 'Termin bearbeitet'`, `termin_manuell: 'Termin eingetragen'`), `tests/unit/viewLog.test.mjs`.
**Produces:** `FUNKTIONEN = [{ key: 'news', label: 'News', bestaetigen: { activate: 'News ohne Zahlung aktivieren?', disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' } }, { key: 'termine', label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.' } }]`, `ZUSTAND` (bisheriges `STATE`), `funktionOf(project, key)`, `aktionen(state)`, `mergeFunktion(projects, projectId, key, aktualisiert)`; Reiter zeigt je Projekt eine Zeile je Funktion, Ticketnummer als Link `/tickets?woid=`, Antwort übernimmt nur `features[key]`.
- [ ] Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); `npm test`; `nice npx vite build` (vorher `free -m`).
- [ ] FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“
---
## Abschluss
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und Global Constraints; Critical/Important beheben.
- [ ] `npm test` (Node 24); reine Termine-Tests unter Node 20 (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] Ledger nach `/root/reports/terminplaner-tp1/`.
## Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht `main`)
1. **L1 Portal:** auf aktuelles `origin/main` rebasen, `npm test`, Push `feature/terminplaner:main` (Deploy installiert `feiertagejs`). Schalter `off` ⇒ keine Jobs, nichts sichtbar. Prüfen: `/api/public/termine/v1/aaaaaaaaaaaaaaaa/config` ⇒ `{"aktiv":false}`, `/api/termine/einstellungen` ohne Login 401, Log ohne Fehler.
2. **L2 Schema:** `docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check`, dann ohne `--check`; erneut `--check` ⇒ nichts fehlt.
3. **L3 Schalter:** Compose (`kundenbereich-server`): `TERMINE_ENABLED=${TERMINE_ENABLED:-off}`, `TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}`; `/home/webklar/apps/.env` (Sicherung `.bak-termine`): `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`; `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` (nie `preview-portal`).
4. **L4 WOMS:** Push `feature/terminplaner:test`; Bundle enthält „Terminplaner“.
5. **L5 Pilot:** WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; `WEBklar/test-website`: Seite „Termin buchen“ mit Widget + Datenschutz-Absatz-Entwurf, Push ⇒ Deploy; Testbuchungen: bestätigen, Anfrage annehmen/ablehnen, Erinnerung (Termin knapp über der Erinnerungszeit), absagen, verschieben, widerrufen, Kalender-Abo in Google.
6. **L6:** Nutzertest; Testbuchungen nach Wunsch löschen.
@@ -0,0 +1,608 @@
# Terminplaner – Design
Stand: 2026-10-02 · Status: im Chat freigegeben (Entscheidungen E1–E8), Spec zur Durchsicht
Teilprojekte: **TP1 Kern** (diese Spec im Detail, erster Plan) · **TP2 Verkauf** · **TP3 Online-Zahlung**
(TP2 und TP3 sind hier verbindlich umrissen und bekommen je einen eigenen Plan; Regel des Nutzers:
ein Teilprojekt live, vom Nutzer getestet, erst dann das nächste).
Recherche (nur lesend, Quellen dort): `/root/reports/terminplaner-2026-10-02/`
`code-funktionen.md`, `code-kern.md`, `code-woms.md`, `recherche-branchen.md`, `recherche-recht.md`,
`recherche-technik.md` (+ `spike-code/`), `recherche-stripe-connect.md`, `recherche-recht-zahlung.md`.
## Ziel
Betriebe (unsere Kunden) bekommen eine Online-Terminbuchung auf ihrer eigenen Website. Der Teil auf der
Website sieht bei jedem Kunden anders aus (Design der Kundenseite), die Verwaltung im Kundenportal ist
für alle gleich. Ein Fragebogen (TP2) klassifiziert den Betrieb und erzeugt für WEBklar eine fertige
Einbauanleitung: wir bauen ein, schließen das Ticket, der Terminplaner läuft.
Die Funktion ersetzt die Kachel „Terminplaner · Kommt bald“ (`public/funktionen.js:151`).
## Entscheidungen (vom Nutzer)
| # | Entscheidung |
|---|---|
| E1 | Abrechnung: **einmalige Einrichtung + nur solange ein Wartungs-Abo läuft** (wie Website-Analyse). Endet das Abo, endet der Terminplaner. Hosting-Abo reicht nicht. |
| E2 | Einrichtungspreis **149 €** (brutto, einmalig, Stripe Checkout wie News). |
| E3 | Umfang V1: **Personen und Plätze** (Einzelperson; Team mit Wahl der Person oder „egal“; buchbare Plätze wie Kabine, Hebebühne). Pro Leistung: **sofort verbindlich** oder **Anfrage** (Betrieb bestätigt). Gruppen/Kurse/Tische später. |
| E4 | Der Gast **bestätigt seine E-Mail-Adresse per Link**, bevor der Termin gilt; der Slot ist so lange 30 Minuten reserviert. |
| E5 | **Ein gemeinsames Widget**, je Kunde gestaltet (Farben, Schrift, Rundungen, Texte, Position) + offene, versionierte Schnittstelle für Sonderfälle. |
| E6 | Der **Fragebogen kommt vor der Zahlung**; das Ticket enthält sofort die Einbauanleitung (TP2). |
| E7 | **Zahlungsmodus legt der Betrieb fest:** nur vor Ort · nur online · online oder vor Ort (dann wählt der Gast). Online-Geld geht **direkt an den Betrieb** (Stripe Connect, WEBklar berührt kein Geld). TP1 kann nur „vor Ort“, TP3 bringt „online“. |
| E8 | Reihenfolge: TP1 Kern (Pilot mit Kunde „test“) → TP2 Verkauf → TP3 Online-Zahlung. |
Vorgaben aus dem Entwurf, denen der Nutzer nicht widersprochen hat:
- Gebaut **im Kundenportal** (kein eigener Container, keine Fremdsoftware), Daten in Appwrite `woms-database`.
- **Ein** Buchungssystem für alle Branchen; Branchen-Vorlagen sind Daten, kein Code je Nische.
- **Gesundheitsbereich ausgeschlossen** (Arzt, Zahnarzt, Physio/Ergo/Logo/Podologie, Psychotherapie,
Heilpraktiker, medizinische Kosmetik/Massage). Freitext beim Gast standardmäßig aus.
- Erinnerung an den Gast **nur per nicht vorangekreuztem Haken**.
- Gästemails: Anzeigename = Betrieb, Antworten an den Betrieb, **keine Team-Kopie** (`bccTeam` aus), kein
Tracking, keine Werbung.
- Mailversand über eine **Warteschlange** (Hetzner-Grenze 500 Mails/h für alles).
- Löschfristen: unbestätigt 48 h, abgesagt 30 Tage, vergangen 90 Tage (einstellbar 14–365 Tage).
- Schalter `TERMINE_ENABLED` = `off` | `pilot` | `on`.
## Begriffe
- **Betrieb** = unser Kunde (Portal-Kunde, Inhaber der Website). **Gast** = wer auf der Website bucht.
- **Ressource** = was gebucht wird: eine **Person** oder ein **Platz** (je Kapazität 1; zwei Hebebühnen =
zwei Plätze). **Leistung** = was gebucht wird (Dauer, Puffer, Preis, Buchungsart).
- **Funktion** `termine` = Dokument in `portalFeatures` (`featureKey: 'termine'`), mit eigenem `siteKey`.
---
## TP1 Kern
### 1. Lebenszyklus in TP1
- `TERMINE_ENABLED=off` (Standard): Kachel „Kommt bald“, öffentliche Schnittstelle antwortet für jeden
Schlüssel „nicht aktiv“, Routen des Kunden 409.
- `pilot`: Kachel und Ansicht nur für Kunden, deren E-Mail in `TERMINE_PILOT_EMAILS` steht (Komma-Liste,
Muster `ABO_PILOT_EMAILS`). Bestellen gibt es in TP1 nicht; die Funktion wird im Ticketsystem mit
„Aktivieren (ohne Zahlung)“ eingeschaltet (vorhandenes `adminActivate`, auf Schlüssel verallgemeinert).
- `on` kommt mit TP2 (Bestellung für alle mit Wartungs-Abo).
- Zustände der Funktion wie bei News: `off → active → disabled` (TP1); `ordered/setup` folgen mit TP2.
`disabled` ⇒ Widget zeigt den Ausweichtext, keine neuen Buchungen; bestehende Termine bleiben im Portal
sichtbar, Gäste können weiter absagen und widerrufen.
### 2. Datenmodell (Appwrite, neues Setup-Skript `scripts/termine-setup.mjs`)
Muster von `scripts/analyse-setup.mjs` (exportierte Attributlisten, injizierbarer Client, `--check`,
legt nur Fehlendes an: fehlende Attribute und Indizes auch in schon vorhandenen Collections, R6). Alle Collections `permissions: []`,
`documentSecurity: false` (nur Server-Schlüssel). `test/support/schema.js` liest das neue Skript mit.
**`termineConfig`** – eine Einstellung je Funktion; Dokument-ID = ID des `portalFeatures`-Dokuments.
`customerId`, `projectId` (64), `einstellung` (Text, JSON bis 200 000 Zeichen; nicht `data` – das Wort
behandelt `appwriteAdmin.formatRequestBody` als fertig verpackten Body), `version` (Ganzzahl),
`updatedAt`, `updatedBy` (100). Inhalt von `einstellung` (validiert in `services/termine/einstellungen.js`):
```jsonc
{
"schema": 1,
"vorlage": "salon", // Branchen-Vorlage, nur Herkunft
"betrieb": { "name": "Salon Müller", "email": "info@salon.de", "telefon": "06301 1234", "kleinunternehmer": false,
"adresse": "Hauptstr. 1, 67722 Winnweiler",
"impressumUrl": "https://…", "datenschutzUrl": "https://…", "agbUrl": null },
"anrede": "sie", // sie | du (Widget, Gästeseiten, Gästemails)
"farbe": "#7c3aed", // Akzent für Gästeseiten und -mails
"bundesland": "RP", "feiertageGeschlossen": true,
"sonderoeffnungen": { "2026-12-24": [["09:00","13:00"]] }, // Datum → Fenster, schlägt Plan/Feiertag
"abwesenheiten": [ { "id": "a1", "ressourceId": null, "von": "2026-12-27", "bis": "2026-12-31", "titel": "Betriebsurlaub" } ],
"ressourcen": [ { "id": "r1", "typ": "person", "name": "Anna", "aktiv": true,
"plan": { "1": [["09:00","12:30"],["13:30","18:00"]], "6": [["09:00","13:00"]] },
"feedToken": "…" } ], // ISO-Wochentag 1=Mo … 7=So; Pausen = Lücken
"leistungen": [ { "id": "s1", "name": "Damenschnitt", "beschreibung": "", "dauer": 45, "pufferNach": 5,
"preisArt": "fest", "preisCent": 4500, // fest | ab | keiner
"buchungsart": "sofort", // sofort | anfrage
"ressourcen": ["r1"], "aktiv": true, "reihenfolge": 1 } ], // reihenfolge: der Server setzt i+1 in Listenreihenfolge
"regeln": { "raster": 15, "vorlaufMin": 120, "horizontTage": 60, "absageBisStd": 24,
"maxOffeneJeEmail": 3, "personWaehlbar": true },
"gastFelder": { "telefon": "optional", "nachricht": "aus", "zusatz": [] }, // aus | optional | pflicht
"erinnerung": { "anbieten": true, "stundenVorher": 24 },
"storno": { "text": "Kostenlose Absage bis 24 Stunden vor dem Termin …", "version": "2026-10-02-1" },
"recht": { "gaesteSind": "verbraucher", // verbraucher | unternehmer | gemischt
"freizeit": false }, // true ⇒ kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9), nur nach Prüfung
"zahlung": { "modus": "vorort", "vorOrtArten": ["bar","ec","kreditkarte"] }, // online/beides erst TP3
"aufbewahrungTage": 90
}
```
IDs von Ressourcen/Leistungen/Abwesenheiten sind kurze Zufalls-IDs (keine Appwrite-IDs). Änderungen
sind optimistisch gesperrt: `PATCH` schickt `version`, der Server schreibt unter
`withLocks(['termine-cfg:<featureId>'])` und antwortet bei abweichender Version 409 „Die Einstellungen
wurden inzwischen geändert. Bitte neu laden.“
**`termineBookings`** – eine Buchung je Dokument (ID `randomUUID`).
Felder: `featureId`, `customerId`, `projectId` (64); `leistungId`, `leistungName` (120, Schnappschuss),
`ressourceId`, `ressourceName` (80); `startsAt`, `endsAt`, `blockEndsAt` (datetime UTC; `blockEndsAt` =
Ende + Puffer); `status` (16, siehe 4); `quelle` (16: `online` | `manuell` | `sperre`);
`gastName` (100), `gastEmail` (254), `gastTelefon` (40), `gastNachricht` (500), `gastZusatz` (2000, JSON);
`erinnerung` (bool), `erinnerungGesendetAt`; `heldUntil`; `verifyHash`, `manageHash` (64, SHA-256 der
Tokens); `bestaetigtAt`, `angenommenAt`, `abgesagtAt`, `abgesagtVon` (16: `gast`|`betrieb`|`system`),
`absageGrund` (300), `widerrufenAt`; `nachweis` (4000, JSON: Fassung und Wortlaut-Hash von Storno-,
Datenschutz- und Widerrufstext, Zeitpunkt der Zustimmungen, Knopftext, Anrede); `preisCent`,
`preisArt` (8), `buchungsart` (8), `verbraucher` (bool); `zahlart` (8: `vorort` | `online`),
`zahlStatus` (16: `keine`|`offen`|`bezahlt`|`erstattet`|`teilerstattet`|`fehlgeschlagen`; TP1 immer
`keine`), `stripeSessionId` (128), `stripePaymentIntent` (64) (TP3); `icsSequenz` (Ganzzahl);
`createdAt`, `updatedAt`.
Indizes: `idx_feature_start(featureId, startsAt)`, `idx_status_held(status, heldUntil)`,
`idx_feature_email(featureId, gastEmail)`, `idx_verify(verifyHash)`, `idx_manage(manageHash)`,
`idx_status_start(status, startsAt)`.
**`termineClaims`** – Belegungszellen gegen Doppelbuchung (siehe 5). Dokument-ID deterministisch
`c_` + erste 34 Hex von `sha1(featureId|ressourceId|zellenStartUtcIso)`. Felder `featureId`,
`ressourceId`, `bookingId`, `zelle` (datetime), `bis` (datetime, = `blockEndsAt` der Buchung, nie die Haltezeit).
Index `idx_booking(bookingId)`, `idx_bis(bis)`.
**`termineOutbox`** – Mail-Warteschlange. Felder `featureId`, `bookingId`, `art` (16), `an` (254),
`fromName` (120), `replyTo` (254), `subject` (300), `html` (Text 100 000), `text` (Text 50 000),
`ics` (Text 20 000), `icsMethod` (8), `status` (`wartet`|`gesendet`|`fehler`), `versuche`,
`naechsterVersuch`, `gesendetAt`, `fehler` (300), `createdAt`. Index `idx_status_next(status, naechsterVersuch)`.
Nach dem Versand werden `an`, `html`, `text`, `ics` geleert (personenbezogene Daten); gesendete Einträge werden 7 Tage nach dem Versand
gelöscht, endgültig gescheiterte (`fehler`) 48 Stunden nach dem Fehler.
`portalFeatures` bekommt **keine** neuen Attribute; `featureKey 'termine'` nutzt die vorhandenen.
### 3. Verfügbarkeit (reine Funktionen, `server/services/termine/`)
- **Zeit** (`zeit.js`, ohne Abhängigkeit, `Intl`): Wanduhr Europe/Berlin ↔ UTC; Wochentag; Tage addieren.
Nicht existierende Zeiten (Umstellung März, 02:00–02:59) werden **ausgelassen**, doppelte Zeiten
(Oktober) nehmen das **erste** Vorkommen. Tests für 2026-03-29 und 2026-10-25. `deadlines.berlinTime`
wird dafür **nicht** benutzt (kennt die Lücke nicht).
- **Feiertage** (`feiertage.js`): Paket `feiertagejs` (MIT, ohne Abhängigkeiten) je Bundesland;
`sonderoeffnungen` schlagen Feiertag und Plan, `abwesenheiten` schlagen alles, auch eine Sonderöffnung (R11).
- **Slots** (`slots.js`, `now` wird übergeben): je Ressource und Berliner Kalendertag die Öffnungsfenster
aus `plan` (oder `sonderoeffnungen`), minus Feiertag (wenn geschlossen), minus Abwesenheiten, minus
belegte Zeiten (Buchungen mit Status `gehalten|angefragt|bestaetigt` und Sperren, jeweils bis
`blockEndsAt`). Startzeiten im `raster` ab Fensterbeginn; Termin + `pufferNach` muss ganz ins Fenster
passen; nicht vor `now + vorlaufMin`; nicht nach `horizontTage`. „Egal“ = Vereinigung über alle
Ressourcen der Leistung; beim Buchen wird die Ressource mit den wenigsten Buchungen an dem Tag gewählt
(bei Gleichstand Reihenfolge der Liste).
- Ein Abruf liefert höchstens 31 Tage; der Server lädt dafür die Buchungen des Zeitraums per Indexabfrage
(`featureId` + `startsAt` zwischen, mit Seitenweise-Laden über `cursorAfter`), nie „alles laden“.
### 4. Buchungsablauf und Zustände
Zustände (`status`): `gehalten` (Formular abgeschickt, wartet auf E-Mail-Bestätigung) → `angefragt`
(Leistung „Anfrage“, wartet auf den Betrieb) oder `bestaetigt` → `erledigt` (automatisch 1 h nach Ende) ·
`nichterschienen` (Betrieb) · `abgesagt` (Gast, Betrieb, System) · `abgelehnt` (Betrieb lehnt Anfrage ab) ·
`verfallen` (Link nicht innerhalb von 30 Minuten bestätigt). Sperren (`quelle: sperre`) sind immer
`bestaetigt` und haben keinen Gast.
**Gast im Widget**
1. Leistung wählen (Name, Dauer, Preis „45 €“ / „ab 45 €“ / ohne, Kurzbeschreibung). Zahlungsart steht
schon hier („Bezahlung vor Ort: bar, EC-Karte, Kreditkarte“, § 312j Abs. 1 BGB).
2. Person wählen, wenn mehr als eine Ressource infrage kommt und `personWaehlbar` (mit „Egal“ als erster
Option); bei Plätzen kein Schritt.
3. Tag (Monatsraster, nur Tage mit freien Zeiten aktiv) und Uhrzeit.
4. Daten: Name, E-Mail, Telefon/Nachricht/Zusatzfelder nach `gastFelder`. Über der Nachricht steht
„Bitte keine Gesundheitsangaben.“
5. Zusammenfassung: Leistung, Person/Platz, Datum, Uhrzeit, Preis inkl. USt (bzw. Hinweis Kleinunternehmer
aus dem Einstellungsfeld des Betriebs), Ort, Zahlungsart, Stornoregel im Klartext + Link, Hinweis zum
Datenschutz (Text 11.2 aus `recherche-recht.md`, Link auf `datenschutzUrl`), bei Verbraucher-Gästen die
vollständige Widerrufsbelehrung samt Muster-Widerrufsformular (aufklappbar, bei jeder sichtbaren Leistung,
auch bei Anfragen; R14, R55), Haken:
- Pflicht: „Ich habe die Stornobedingungen gelesen.“ (nur wenn `storno.text` gesetzt)
- Pflicht bei verbindlicher Leistung mit Preis für Verbraucher, wenn der Termin vor dem Ende der
Widerrufsfrist liegt (Frist nach §§ 187, 188, 193 BGB ab Vertragsschluss, Berliner Zeit), und bei
bepreisten Anfragen für Verbraucher immer: Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB,
Wortlaut im Rechtsteil).
- Freiwillig, nicht vorangekreuzt (nur wenn `erinnerung.anbieten`): „Ich möchte {24} Stunden vorher eine
Erinnerung per E-Mail.“
Knopf: **„Zahlungspflichtig buchen“** (verbindlich + Preis), **„Termin buchen“** (verbindlich, ohne
Preis), **„Termin anfragen“** (Buchungsart Anfrage).
6. Ergebnis: „Fast geschafft: Bitte bestätigen Sie den Termin über den Link, den wir an m***@gmail.com
geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.“
**Server beim Abschicken** (`POST …/buchungen`): Prüfungen (Abschnitt 8), Slot neu berechnen, unter
`withLocks(['termine-res:<featureId>:<ressourceId>'], {waitMs: 5000})` die Buchung `gehalten` mit
`heldUntil = now + 30 min` anlegen und danach die Zellen belegen (5; die Gültigkeit einer Zelle hängt allein an ihrer
Buchung), Mail G01 in die Warteschlange (sofort senden).
**Bestätigungslink** `https://project.webklar.com/termin.html#v=<token>`: Die Seite liest das Token aus
dem Fragment (geht nicht an Server-Logs), zeigt per `POST …/vorschau` die Zusammenfassung und einen Knopf
mit demselben Wortlaut wie im Widget (bei Preis „Zahlungspflichtig bestätigen“). Erst `POST …/bestaetigen`
ändert den Zustand (Mailscanner öffnen nur Links): `gehalten` → `bestaetigt` (G02 an Gast mit .ics,
Verwaltungslink und ggf. Widerrufsbelehrung; K20 an den Betrieb mit .ics) bzw. → `angefragt` (G03 an Gast,
K20-Block „Anfrage“ an den Betrieb). Abgelaufen ⇒ „Diese Reservierung ist abgelaufen“ + Link zur Website.
Ein Token gilt genau einmal.
**Verwaltungslink** `termin.html#m=<token>` (gültig bis Terminende + 1 Tag; Token = HMAC über die
Buchungs-ID, damit spätere Mails wie die Erinnerung denselben Link enthalten; gespeichert nur sein Hash):
zeigt den Termin und je nach
Lage
- „Termin absagen“ bis `absageBisStd` vor Beginn (danach Text „Absage nur telefonisch: …“) ⇒ `abgesagt`,
Zellen frei, G06 an Gast (ICS CANCEL), K21 an den Betrieb;
- „Termin verschieben“ bis zur selben Frist ⇒ neue Zeit wählen; neuer Slot wird belegt, alter freigegeben
(eine Sperre über beide Ressourcen), Status bleibt, `icsSequenz + 1`, G07 + K21; das gilt auch für `angefragt`
(R26: G03 enthält nicht nur „Absage“, die Seite bietet auch „Termin verschieben“);
- **„Vertrag widerrufen“**, solange ein Widerrufsrecht besteht (verbindlich, Verbraucher, nicht `freizeit`,
Status `bestaetigt`, Termin noch nicht begonnen und die Widerrufsfrist läuft: 14 Tage ab dem Tag nach dem
Vertragsschluss, bei Wochenende oder Feiertag am letzten Tag bis zum nächsten Werktag, bis 23:59:59 Berliner
Zeit, §§ 187, 188, 193 BGB; Vertragsschluss ist `bestaetigtAt` bei einer sofort buchbaren Leistung und
`angenommenAt` bei einer vom Betrieb angenommenen Anfrage; R29, R55) – unabhängig von der Stornofrist
(§ 361 BGB) ⇒ Formular nach § 356a BGB (Name, Buchungsnummer, E-Mail für die Eingangsbestätigung vorbefüllt,
Knopf „Widerruf bestätigen“) ⇒ `widerrufenAt`, `abgesagt` (von `gast`), G08 Eingangsbestätigung mit Datum und
Uhrzeit, K21-Block „Widerruf“. Nach Ablauf der Frist oder nach Beginn des Termins bleibt das Formular der Weg
für eine Erklärung: sie wird nur vermerkt und dem Betrieb gemeldet (G08/K21 `nach_frist` bzw. `begonnen`),
der Betrieb prüft (R27). Die Verwaltungsseite bietet den Knopf nur bis zum Beginn des Termins an; G02 sagt
das und nennt danach das Formular auf der Website des Betriebs oder die E-Mail an den Betrieb.
**Widerruf ohne Link** (§ 356a: auf der Oberfläche, über die der Vertrag geschlossen wurde): Das Widget
zeigt im Fuß dauerhaft „Vertrag widerrufen“ als zweiten Knopf (mindestens 44 px, kein Textlink), sobald
Verbraucher-Gäste bei dem Betrieb buchen oder anfragen können, also bei jeder sichtbaren Leistung, auch bei
Anfragen (R54, R55; Anschrift des Betriebs ist dann Pflicht). Im Modus `knopf` steht der Fuß nur im geöffneten
Dialog: Dort lädt das Widget schon beim Laden der Seite die Konfiguration (ein einziger Abruf) und zeigt bei
Widerrufsrecht dauerhaft einen zweiten Knopf „Vertrag widerrufen“ neben dem Buchungsknopf, der den Dialog gleich
beim Widerrufsformular öffnet (R54). Formular: Name, E-Mail, Buchungsnummer (steht in G02), Knopf „Widerruf bestätigen“. Der Server
antwortet immer gleich („Ihre Erklärung ist eingegangen“), speichert die Erklärung an der passenden
Buchung (E-Mail + Nummer), schickt G08 an die angegebene Adresse und leitet die Erklärung per K21 an den
Betrieb weiter – auch wenn keine Buchung passt (dann Block „nicht zugeordnet“, der Betrieb prüft).
**Betrieb im Portal**: Anfrage annehmen (`angefragt` → `bestaetigt`, G02) oder ablehnen (`abgelehnt`, G04,
Zellen frei); bestätigten Termin absagen (Pflicht-Grund-Auswahl + optionale Nachricht, G06) oder verschieben
(G07); „nicht erschienen“; Gastdaten auf Wunsch löschen (anonymisieren: Gastfelder und `absageGrund` werden
geleert, noch nicht versandte Mails der Buchung aus der Warteschlange gelöscht); Termin von Hand eintragen (`quelle: manuell`, Gast-E-Mail optional, G02 nur wenn
der Haken „Bestätigung an den Gast senden“ gesetzt ist; ohne Widerrufsbelehrung durch das System, die ist
bei telefonischen Buchungen Sache des Betriebs); Zeit sperren (`quelle: sperre`, höchstens 500 Minuten am Stück; längeres über Abwesenheiten).
**Hintergrund-Jobs** (`server/index.js`, Muster der vorhandenen `setInterval`-Jobs, je Job eigene Fehlerbehandlung):
- jede Minute: abgelaufene `gehalten` ⇒ `verfallen` + Zellen löschen; Warteschlange senden (6).
- alle 5 Minuten: Erinnerungen (`bestaetigt`, `erinnerung`, kein `erinnerungGesendetAt`,
`now + 1 h < startsAt ≤ now + stundenVorher` und Vertragsschluss vor `startsAt − stundenVorher`, damit
ein Ausfall des Jobs keine Erinnerung verschluckt; erst Marker setzen, dann einreihen);
`bestaetigt` mit `endsAt < now − 1 h` ⇒ `erledigt`; `angefragt` mit `startsAt < now` ⇒ `abgelehnt`
(vom System, Zellen frei).
- täglich: Löschfristen (11), verwaiste Zellen (Buchung fehlt oder nicht mehr belegend).
### 5. Doppelbuchung verhindern
- Zellen à **5 Minuten** von Beginn bis `blockEndsAt`. Jede Zelle ist ein `termineClaims`-Dokument mit
deterministischer ID; eine zweite Belegung derselben Zelle scheitert in der Datenbank mit 409.
- Alle Zellen einer Buchung werden in **einem** Aufruf angelegt (Appwrite 1.8.1 Bulk-Create
`POST …/documents` mit `documents: [...]`, eine Transaktion, höchstens 100 Dokumente ⇒ Termine bis
500 Minuten). 409 ⇒ „Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.“
- Zusätzlich `withLocks` je Ressource im Prozess (Reihenfolge, keine Fehlalarme bei Doppelklick).
- Reihenfolge beim Buchen: erst die Buchung (`gehalten`), dann die Zellen. Ob eine Zelle noch gilt, entscheidet
allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; `gehalten` mit abgelaufenem `heldUntil` ⇒ frei).
Sperrenfolge einheitlich Gast → Buchung → Ressource.
- **Spike vor dem Bau (Pflicht, Plan-Aufgabe 1):** auf einer Wegwerf-Collection in der Produktions-Appwrite
50 parallele Bulk-Creates mit überlappenden IDs ⇒ genau einer 201, Rest 409, keine Teil-Schreibungen;
danach Collection löschen. Braucht die Freigabe des Nutzers. Fällt der Spike negativ aus: Fallback
Transactions-API (`/v1/databases/transactions`, ebenfalls in 1.8.1), sonst Einzel-Creates mit Rückbau
unter der Prozess-Sperre.
- Der Test-Nachbau (`test/support/appwriteMock.js`) lernt Bulk-Create mit Alles-oder-nichts und 409,
`between`/`greaterThanEqual`/`lessThanEqual`/`cursorAfter`.
### 6. Mails
**Versand**: neue Funktion `sendGuestMail` in `mailer.js` (nutzt den vorhandenen Transport): From
`"<betrieb.name>" <SMTP_FROM>` (Anzeigename = Betrieb, Adresse bleibt `noreply@webklar.com`, also keine
DNS-/Postfach-Änderung), `replyTo = betrieb.email`, **nie BCC**, `icalEvent` für Kalenderdaten. Alle
Termine-Mails laufen über `termineOutbox` (Muster „erst speichern, dann senden“): sofortiger Sendeversuch
nach dem Einreihen, sonst Job; höchstens **200 Mails/Stunde** für den Terminplaner (Rest des
Hetzner-Budgets bleibt Portal/Abo); Wiederholung nach 1, 5, 30 Minuten, dann `fehler` und Hinweis im
Portal an der Buchung („Bestätigung konnte nicht zugestellt werden“). **Sicherung:** bei einem
SMTP-Anmeldefehler pausiert die Warteschlange 15 Minuten (Hetzner sperrt die IP nach mehreren
Fehlversuchen für ~10 Minuten).
**Vorlagen** (Engine `mailTemplates.js`; Kennungen um `G` erweitern, Test `mailTemplates.test.js`
`EXPECTED` mitpflegen). Gästemails haben ein **neutrales Layout** ohne WEBklar-Logo: Kopf mit Betriebsname
in `farbe`, Fuß mit Betrieb (Name, Adresse, Telefon, E-Mail, Links Impressum/Datenschutz) und dem Satz
„Diese E-Mail wurde im Auftrag von {Betrieb} über den Terminplaner von WEBklar versendet.“ Jede Vorlage hat
Blöcke `sie`/`du`.
Der Betreff ist je Vorlage der Platzhalter `{{betreff}}`; der Server setzt ihn je Anrede und Fall (Du-Form
sinngemäß: „Bitte bestätige deinen Termin …“). G01 und G08 enthalten keinen vom Gast eingegebenen Text
(kein Missbrauch als Mail-Relais).
| ID | an | Betreff (Sie-Form) | Inhalt |
|---|---|---|---|
| G01 | Gast | Bitte bestätigen Sie Ihren Termin bei {betrieb} | Datum, Uhrzeit, Leistung, Knopf „Termin bestätigen“, „Das waren Sie nicht? Dann ignorieren Sie diese E-Mail.“ Nichts Werbliches. |
| G02 | Gast | Ihr Termin bei {betrieb} am {datum} | Zusammenfassung, Buchungsnummer, Zahlungsart, Stornoregel (Wortlaut), Verwaltungslink, .ics (REQUEST); bei Widerrufsrecht: Widerrufsbelehrung + Muster-Widerrufsformular + Hinweis auf die Widerrufsfunktion (§ 312f Abs. 2 BGB): Bis zum Beginn des Termins über den Knopf „Vertrag widerrufen“, danach über das Formular auf der Website des Betriebs oder per E-Mail an den Betrieb |
| G03 | Gast | Ihre Terminanfrage bei {betrieb} | „Der Betrieb meldet sich“, Verwaltungslink (Absage und Verschieben, R26) |
| G04 | Gast | Ihre Terminanfrage bei {betrieb} | abgelehnt, optionale Nachricht des Betriebs |
| G05 | Gast | Erinnerung: Ihr Termin am {datum} um {zeit} | Termin, Verwaltungslink; sonst nichts |
| G06 | Gast | Ihr Termin am {datum} wurde abgesagt | wer abgesagt hat, Grund/Nachricht, .ics (CANCEL) |
| G07 | Gast | Ihr Termin wurde verschoben | alte/neue Zeit, .ics (REQUEST, Sequenz + 1) |
| G08 | Gast | Eingangsbestätigung Ihres Widerrufs | Inhalt der Erklärung, Datum und Uhrzeit des Eingangs |
| K20 | Betrieb | Neue Buchung: {leistung} am {datum} um {zeit} (Block „anfrage“: Neue Terminanfrage …) | Gastdaten, Link ins Portal, .ics (PUBLISH) |
| K21 | Betrieb | Termin abgesagt / verschoben / widerrufen: … | Block je Fall, Link ins Portal |
K20/K21 im WEBklar-Layout (Du-Form wie K15–K19), Empfänger `betrieb.email`, **ohne** Team-BCC (enthalten
Gästedaten).
**ICS** (`ics.js`, eigener Schreiber, ~50 Zeilen, Zeilenfaltung/Escaping nach RFC 5545): `UID =
<bookingId>@termine.webklar.com` (stabil), `SEQUENCE = icsSequenz`, `DTSTART/DTEND` in UTC, `SUMMARY` =
„{Leistung} – {Betrieb}“ (an den Betrieb: „{Leistung}: {Gastname}“), `LOCATION` = Adresse, `ORGANIZER` =
Betrieb, `ATTENDEE` = Gast (`RSVP=FALSE`), `METHOD` REQUEST/CANCEL. Keine „In Google eintragen“-Links.
**Kalender-Abo für den Betrieb**: je Ressource geheimer Link `…/v1/kalender/<siteKey>/<feedToken>.ics` (nur
bestätigte Termine und Sperren der nächsten 90 Tage, `X-PUBLISHED-TTL: PT15M`; Google aktualisiert nur
alle paar Stunden – steht so im Portal). Link neu erzeugen macht den alten ungültig.
### 7. Website-Teil
**Einbau** (vom Mitarbeiter, je Kunde gestaltet):
```html
<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
<p>Termine telefonisch unter 06301 1234.</p> <!-- Ausweichtext, bleibt ohne JS/bei Fehlern stehen -->
</div>
<script src="https://project.webklar.com/embed/termine.js" defer></script>
```
`data-modus`: `inline` (Buchungsstrecke direkt in der Seite) oder `knopf` (Knopf mit Text aus
`data-knopf`, öffnet ein `<dialog>`; beim Laden der Seite ruft das Widget einmal die Konfiguration ab, für den
dauerhaften Knopf „Vertrag widerrufen“ (R54), alles Weitere erst beim Klick). Optional
`data-leistung="s1"` (vorausgewählt, z. B. Knopf neben einer Leistung).
**`public/embed/termine.js`** (ohne Abhängigkeiten, ohne Build, IIFE wie `news.js`):
- Light DOM mit Klassenpräfix `wkt-`, Standard-Stile in `@layer wkt`, Gestaltung über CSS-Variablen der
Seite: `--wkt-akzent`, `--wkt-akzent-text`, `--wkt-hintergrund`, `--wkt-text`, `--wkt-rand`,
`--wkt-radius`, `--wkt-schrift`, `--wkt-schrift-titel`; Containerabfragen für schmale Spalten/Handy.
- Kein Cookie, kein Web-Storage, kein Auslesen von Referrer/Bildschirm/Zeitzone, keine Drittanfragen;
Text nur per `textContent`.
- Barrierefrei nach WCAG 2.1 AA: echte Formularelemente, sichtbarer Fokus, Monatsraster nach dem
APG-Muster „Date Picker Dialog“ (Pfeiltasten, `aria-selected`, Tage ohne Zeiten `aria-disabled`),
Fehler über `aria-live`, Zielgröße ≥ 24 px, kein Zeitlimit im Widget (die 30 Minuten gelten erst nach
dem Absenden).
- Deutsche Texte in Sie- oder Du-Form nach `anrede`. Fuß: „Vertrag widerrufen“ als zweiter Knopf (wenn nötig,
siehe 4; im Modus `knopf` zusätzlich dauerhaft neben dem Buchungsknopf) und „Datenschutz“ (Link
`datenschutzUrl`), klein „Terminbuchung über WEBklar“.
- Zeitfalle (R53): `GET /config` nennt `formMinSekunden`. Vor jedem Absenden wartet das Widget, bis das
Formular-Token (gemessen mit `performance.now()` ab Eintreffen der Konfiguration) so alt ist plus 0,5 Sekunden
(Anzeige „Einen Moment …“, Knopf bleibt gesperrt); sonst würde der Server eine zu junge Buchung wie einen Automaten
stillschweigend verwerfen. Gilt auch nach dem Neuladen der Konfiguration und beim Wiederholen nach `neu_laden`.
- Die Telefonnummer des Betriebs (freier Text) wird nur zum `tel:`-Link, wenn sie nur aus Ziffern, Leerzeichen und
`+ - ( ) / .` besteht; die nationale Null „(0)“ hinter der Landesvorwahl entfällt. Sonst bleibt sie Text (Widget
und Gästeseite, dieselbe Regel).
- Fehler/inaktiv/Abo beendet: Ausweichtext bleibt sichtbar; bei Netzfehler mitten in der Strecke „Gerade
nicht erreichbar. Bitte rufen Sie an: {telefon}“.
- Demo `public/embed/termine-demo.html`, Einbauanleitung für Mitarbeiter `docs/funktionen/termine-einbau.md`
(Snippet, Variablen-Tabelle, Beispiele für drei Stile, Datenschutz-Absatz, Abnahme-Checkliste). `siteKey` und
fertigen Einbau-Code finden Mitarbeiter im Portal (Kundenansicht → Terminplaner → Einstellungen, Kasten „Einbau auf
deiner Website“) und den `siteKey` im WOMS (Kunde → Funktionen).
**Gästeseiten** `public/termin.html` + `public/termin.js` + `public/termin.css`: Bestätigen, Verwalten,
Widerrufen (Token im Fragment). Kopf mit Betriebsname in `farbe`, Fuß mit Links auf Impressum und
Datenschutz des Betriebs **und** auf das Impressum von WEBklar (`https://webklar.com/impressum`, R46; das
Portal hat kein `/impressum.html`). Der Widerruf steht dort als gleichrangiger Knopf (R47). `noindex`, keine Cookies.
### 8. Öffentliche Schnittstelle `/api/public/termine/v1/:siteKey/…`
Eigener Router (`server/routes/publicTermine.js`), in `server/index.js` **vor** dem globalen
`express.json` eingehängt (eigener Parser `limit: '16kb'`). Unbekannter oder inaktiver Schlüssel ⇒
immer dieselbe Antwort `{ aktiv: false }` (200), nie ein Hinweis, ob der Schlüssel existiert. Antworten
enthalten **nie** `customerId`, `projectId`, `featureId`, Appwrite-, Stripe- oder Ticket-IDs; nur
Leistungs-/Ressourcen-IDs aus der Einstellung und die Buchungsnummer (8 Zeichen, aus der Buchungs-ID
abgeleitet, nur zur Anzeige).
| Methode, Pfad | Zweck | Cache |
|---|---|---|
| `GET /:siteKey/config` | Betriebsname, Anrede, Farbe, Leistungen (ohne interne Felder), wählbare Ressourcen, Regeln für die Anzeige, Zahlungsarten, Stornotext + Version, Rechtsflags (Widerruf ja/nein, mit Belehrung), Formular-Token (HMAC über siteKey + Zeit), `formMinSekunden` (R53) | `public, max-age=60`, kein ETag (das Formular-Token ist bei jedem Abruf neu) |
| `GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM` | Tage mit freien Zeiten | `public, max-age=15` |
| `GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD` | freie Startzeiten; bei „egal“ ohne Angabe der Person | `no-store` |
| `POST /:siteKey/buchungen` | Formular abschicken ⇒ `gehalten`, G01 | – |
| `POST /:siteKey/widerruf` `{name, email, nummer}` | Widerrufsfunktion ohne Link (tokenlos) | – |
| `POST /termin/vorschau`, `/termin/bestaetigen`, `/termin/absagen`, `/termin/verschieben`, `/termin/widerruf`, `/termin/tage`, `/termin/zeiten` `{token, …}` | Gästeseiten `termin.html`: Zusammenfassung, E-Mail bestätigen, Verwaltung durch den Gast (Widerruf mit Link), Tage/Zeiten zum Verschieben; der Token kommt aus dem Link der Mail; der Betrieb wird über den Token gefunden, nicht über den `siteKey` | – |
| `GET /kalender/:siteKey/:feedToken.ics` | Kalender-Abo des Betriebs (Token je Ressource, konstantzeitig verglichen) | `private, max-age=300` (enthält Gastnamen) |
Schutz (ohne Captcha-Dienst):
- **CORS**: GETs `Access-Control-Allow-Origin: *`; POSTs nur von erlaubten Origins = Hosts des Projekts
(Subdomain unter `project.webklar.com`, `liveDomain` + www-Gegenstück, wie `analyseHosts`) und
`https://project.webklar.com` (Gästeseiten); Preflight erlaubt `Content-Type`.
- **Honeypot** (`kontakt_url`, unsichtbar; R38) und **Zeitfalle** (Formular-Token muss ≥ `formMinSekunden`,
Vorgabe 3 s, und ≤ 2 h alt sein) ⇒ bei Treffer dieselbe Erfolgsantwort, aber nichts gespeichert, keine Mail.
Ein Token älter als 2 h ⇒ 400 mit Code `neu_laden`; das Widget holt dann eine neue Konfiguration. Das Widget wartet
vor dem Absenden selbst, bis das Token alt genug ist (7).
- **Mengen** (im Speicher, mit Obergrenze der Schlüssel wie `pushTime.js`): je IP (IPv6 je /64) 10
Buchungen/10 min und 120 GET/min; je Funktion 60 gehaltene Buchungen/h; je E-Mail-Adresse und
Funktion höchstens `maxOffeneJeEmail` offene Termine und 5 Bestätigungsmails/Tag; `widerruf` 10/h je IP.
- Eingaben streng prüfen (Längen, E-Mail-Syntax, Telefon nur Ziffern/Leerzeichen/+/-/(), Zusatzfelder
nur bekannte Schlüssel); Fehler als Feldfehler `{ fehler: { feld: text } }`.
- Appwrite nicht erreichbar ⇒ 503 `{ fehler: { _: 'Gerade nicht erreichbar' } }`, nie Rohfehler.
### 9. Verwaltung im Portal (für alle gleich)
Neue Ansicht `#view-termine` (Hash `#termine`), Kachel „Terminplaner“ im Funktionen-Tab mit Pille *Aus* /
*Aktiv* / *Abgeschaltet* (TP1) und Knopf „Termine öffnen“. In der App-Ansicht Unterseite von Funktionen
(`APP_PARENT.termine = 'funktionen'`). Mehrere Website-Projekte ⇒ Projektauswahl oben (wie News). Eigenes
Modul `public/termine.js` + `public/termine.css` (nicht in das schon 794 Zeilen lange `funktionen.js`).
Reiter:
1. **Termine** – Liste nach Tagen (heute, morgen, …; „Woche“ / „Monat“ / Datumssprung), Filter
offen/angefragt/bestätigt/abgesagt; Zeile: Uhrzeit, Leistung, Person/Platz, Gast, Status, Hinweise
(„Bestätigung nicht zugestellt“). Aktionen siehe 4; „+ Termin eintragen“, „Zeit sperren“. Zähler
„{n} Anfragen warten“ auf der Kachel.
2. **Leistungen** – Liste mit Reihenfolge; Felder wie im Datenmodell; Hinweis, dass Leistungsnamen keine
Gesundheitsangaben enthalten dürfen.
3. **Team & Plätze** – Ressourcen mit Wochenplan-Editor (je Tag mehrere Zeitfenster), Zuordnung der
Leistungen, Abwesenheiten/Urlaub, Bundesland + „an Feiertagen geschlossen“, Sonderöffnungen,
Kalender-Abo-Link je Ressource (Kopieren, Neu erzeugen).
4. **Einstellungen** – Betriebsangaben für Gäste (vorbefüllt aus dem Kundenkonto), Anrede, Farbe, Regeln,
Gastfelder, Erinnerung, Stornotext (Mustertext als Vorschlag, deutlich als Muster gekennzeichnet),
Rechtliches („Ihre Gäste sind: Privatpersonen / Unternehmen / beides“; Hinweis, dass der Widerruf die
Stornofrist schlägt), Zahlung (TP1: nur „vor Ort“ mit Zahlarten; „online“ ausgegraut „kommt bald“),
Aufbewahrung, **Einbau auf deiner Website** (Einbau-Code aus `GET /einstellungen` `einbau.snippet`, nur lesen,
mit Kopierknopf), **Export** (CSV der Buchungen), Status „aktiv seit …“.
- **Erstes Öffnen** ohne Einstellung: Auswahl einer Branchen-Vorlage (`salon`, `kosmetik`, `beratung`,
`werkstatt`, `handwerk`, `tattoo`, `fotograf`, `hundesalon`, `sonstiges`; Daten in
`server/services/termine/vorlagen.js`, die TP2 für den Fragebogen wiederverwendet), danach Bearbeiten.
Kunden-API `server/routes/termine.js` unter `/api/termine` (`requireSession`, `assertOwnProject`, Funktion
`active` außer für reines Lesen, Schreiblimit 120/h je Kunde): `GET/PATCH /einstellungen` (die Antwort nennt auch
`siteKey`, `einbau.snippet`, `feeds`, `fehlt`, `anfragenOffen`), `POST /einstellungen/vorlage`,
`GET /buchungen?von&bis&status`, `GET /zeiten` (freie Zeiten für Termine von Hand und Verschieben),
`POST /buchungen` (manuell/Sperre), `DELETE /buchungen/:id` (Sperre aufheben), `POST /buchungen/:id/aktion`
(`annehmen|ablehnen|absagen|verschieben|nichterschienen|anonymisieren`), `POST /ressourcen/:id/feed` (neu erzeugen),
`GET /export.csv`.
**Kundenansicht** (Mitarbeiter als Kunde): Lesen und Schreiben auf `/api/termine/*` erlaubt (GET, POST auf alles
unter `/api/termine/`, PATCH `/einstellungen`, DELETE `/buchungen/:id`; Einstellungen und Termine kosten nichts,
sind keine Erklärungen im Namen des Kunden); Einträge in
`KUNDENANSICHT_ALLOWED` + `test/kundenansichtGuard.test.js`, Protokoll `termin_einstellungen`,
`termin_aktion`, `termin_manuell` (Portal `VIEW_ACTIONS`, WOMS `src/lib/viewLog.js`).
### 10. Ticketsystem (WOMS) in TP1
- Portal: `FEATURE_KEYS = ['news','termine']`; `findActiveBySiteKey(siteKey, featureKey)`;
`activateClosedTickets` bleibt in TP1 bei News (TP2 verallgemeinert es samt Mails); `funktionenAdmin.js` Route
`POST /api/team/funktionen/:customerId/:key` (`key` ∈ `FEATURE_KEYS`; `/news` bleibt gleichwertig) und
`adminView` mit allen Schlüsseln (Ticket-Lookups gebündelt statt N+1).
- WOMS: `src/lib/funktionenView.js` (Register der Funktionen, Zustandstexte, Bestätigungstexte),
`CustomerFunktionen.jsx` zeigt je Projekt **News** und **Terminplaner** (eigene Zeilen), Aktionen
„Aktivieren (ohne Zahlung)“ / „Abschalten“ je Funktion, Ticketnummer als Link `/tickets?woid=`, beim Terminplaner
zusätzlich der `siteKey` in kleiner Schrift mit Kopierknopf (für den Einbau);
`teamApi.setFeature(customerId, key, …)`. `viewLog.js` um die neuen Aktionen.
### 11. Recht und Datenschutz in TP1 (Pilot)
TP1 läuft nur im Pilot mit dem eigenen Testkunden; echte Gäste fremder Betriebe erst nach TP2
(AVV + Leistungsbeschreibung + Freigabe der Texte durch den Nutzer). TP1 liefert als **Entwürfe**
(Kennzeichnung „Entwurf“, Dateien in `docs/rechtliches/` bzw. `docs/funktionen/`):
- Datenschutz-Absatz „Terminbuchung“ für die Website des Betriebs (Sie/Du), Hinweis unter dem Formular,
Stornotext-Muster, Widerrufsbelehrung + Muster-Widerrufsformular für Dienstleistungen (Anlage 1/2
EGBGB, Platzhalter des Betriebs) – Grundlage `recherche-recht.md` 11.x und `recherche-recht-zahlung.md` 7.3.
- Abschnitt „6c Terminplaner“ für `public/datenschutz.html` (WEBklar als Auftragsverarbeiter; was das
Portal selbst verarbeitet) – nicht veröffentlicht vor TP2.
- Zitat-Korrektur im Bestand („§ 356 Abs. 4“ ⇒ „§ 356 Abs. 5 Nr. 2 BGB“ in `config.funktionen.consentText`,
`funktionen-leistungsbeschreibung.html`, Test) erfolgt in TP2 mit dem neuen Einwilligungstext je Funktion.
**Löschfristen** (täglicher Job im Terminplaner selbst, unabhängig von `retention.js`, weil die Fristen je
Buchung laufen und nicht am Vertragsende des Kunden hängen): `verfallen` und unbestätigte nach 48 h; `abgesagt`/`abgelehnt` nach 30 Tagen;
`erledigt`/`nichterschienen` nach `aufbewahrungTage` (Standard 90, erlaubt 14–365) ab Terminende;
Warteschlange: gesendet 7 Tage nach dem Versand, gescheitert 48 Stunden nach dem Fehler; Zellen mit der Buchung.
Funktion `disabled` ⇒ neue Buchungen aus, nach 30 Tagen (Zeit für den Export) Löschung aller Buchungen, Zellen,
Einstellung. Kunde gelöscht ⇒ Funktion `disabled` (Haken in `customerAdmin.deleteCustomerWithPortalAccess`, wie
Analyse). In TP1 löst nur `disabled` die Löschung aus (WOMS „Abschalten“, Kunde gelöscht); ein Haken für das Ende des
Wartungs-Abos kommt erst mit TP2, obwohl der 6c-Entwurf das Abo-Ende schon nennt.
### 12. Fehlerfälle
- Slot inzwischen weg (409 beim Belegen) ⇒ Meldung im Widget, Zeiten neu laden.
- Bestätigungslink nach Ablauf ⇒ „abgelaufen“; zweiter Klick auf denselben Link ⇒ „bereits bestätigt“ +
Verwaltungslink-Hinweis.
- Mail scheitert endgültig ⇒ Hinweis an der Buchung im Portal; Buchung bleibt gültig.
- Einstellung ungültig (z. B. Leistung ohne Ressource) ⇒ Speichern mit Feldfehler abgelehnt; Leistung ohne
aktive Ressource wird im Widget nicht angezeigt.
- Leistung oder Ressource gelöscht, während Termine bestehen ⇒ nur deaktivieren erlaubt, solange künftige
Termine daran hängen (Meldung mit Anzahl).
- Funktion abgeschaltet ⇒ öffentliche Schreibrouten `{ aktiv: false }`, Gästeseiten erlauben weiter
Absagen/Widerruf bestehender Termine.
- Uhr-Umstellung ⇒ keine Slots in der fehlenden Stunde; Termine über die Umstellung behalten ihre
echte Dauer.
### 13. Tests TP1
- `npm test` (node --test): Zeit (beide Umstellungstage), Feiertage je Land (Stichproben 2026/2027),
Slots (Pausen, Puffer, Vorlauf, Horizont, Abwesenheit, Feiertag, Sonderöffnung, „egal“), Zellen/Bulk
(Mock: Alles-oder-nichts), Tokens (einmalig, Ablauf), ICS (Faltung, Escaping, CANCEL), Vorlagen
(alle G/K-Vorlagen rendern ohne `problems`, `EXPECTED`), Einstellungs-Validierung, öffentliche Routen
(keine internen IDs, `{aktiv:false}` einheitlich, CORS/Origin, Honeypot, Zeitfalle, Mengen, Knopftexte
je Fall, Widerruf mit/ohne Treffer), Kunden-Routen (Rechte, fremdes Projekt 403, Versionskonflikt 409,
Kundenansicht-Liste), Jobs (Verfall, Erinnerung genau einmal, Erledigt, Löschfristen), Warteschlange
(Rate, Wiederholung, Pause nach Anmeldefehler).
- Playwright (Fixture-Server, wie `test/ui/*.ui.mjs`): Widget inline und Knopf auf einer Testseite
(Desktop, Handy, nur Tastatur, Ausweichtext bei Fehler), Gästeseiten (bestätigen, absagen, verschieben,
widerrufen), Portal-Ansicht (alle Reiter, Vorlage wählen, Wochenplan-Editor) – Screenshots nach
`/root/reports/terminplaner-ui/`.
- WOMS: `npm test` (Register, Zustände), `vite build`.
- Live-Spike Bulk-Create (5) vor dem Bau.
### 14. Livegang TP1 (je Schritt Freigabe des Nutzers)
1. L1 Portal: Push `feature/terminplaner` → `main` (der Nutzer pusht), Schalter bleibt `off`; Abhängigkeit
`feiertagejs` kommt mit dem Deploy (`deploy.sh` führt `npm install` aus).
2. L2 Schema: `termine-setup.mjs --check`, dann anlegen (im Container; das Skript kommt mit L1). Danach
Compose: `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`, Container neu erstellen
(`docker compose up -d --no-deps kundenbereich-server`, nie `preview-portal`).
3. L3 WOMS: Push auf `test`.
4. L4 Pilot: im WOMS beim Kunden „test“ den Terminplaner für „Test-Website“ aktivieren; im Portal (Login
des Testkunden) Vorlage wählen, Leistungen/Zeiten pflegen; Widget in `WEBklar/test-website` einbauen
(`siteKey` im WOMS, fertiger Einbau-Code im Portal unter Einstellungen)
(Push → Deploy) inkl. Datenschutz-Absatz-Entwurf; Testbuchungen mit kenso.gri@gmail.com: bestätigen,
Erinnerung, absagen, verschieben, widerrufen, Anfrage annehmen/ablehnen, Kalender-Abo in Google.
5. L5 Nutzertest; Aufräumen der Testbuchungen nach Wunsch.
**Betrieb und Livegang – Hinweise**
- `SESSION_SECRET` darf nie rotiert werden: Verwaltungs-, Formular- und Feed-Links hängen daran
(`token.js`, Schlüssel mit Zweckbindung; Compose fällt auf `PREVIEW_SESSION_SECRET` zurück). Eine Rotation
entwertet alle schon versandten Links, auch den Widerrufs-Link in G02.
- Der Modus `off` stoppt auch die Löschjobs (die Aufbewahrungsfristen laufen nicht mehr). Pilotbuchungen vor dem
endgültigen Abschalten löschen (Funktion abschalten und Löschfrist abwarten, oder die Daten von Hand löschen);
alternativ `TERMINE_PILOT_EMAILS=` leer lassen, statt `off` zu setzen.
- Die tägliche Zellbereinigung liest alle Zellen (Deckel 100 000, `idx_bis` ungenutzt). Vor TP2 muss sie über
`idx_bis` (`lessThan('bis', …)`) und seitenweise begrenzt werden, sonst steht sie bei vielen Betrieben still
(`list_limit_exceeded`). Gleiches Muster: `GET /export.csv` und `loescheAbgeschaltete` (Standarddeckel 20 000).
- `verarbeite` (Warteschlange) listet jede Minute alle fälligen Einträge samt Inhalt, auch wenn das Stundenbudget
leer ist. In TP1 unkritisch; vor TP2 Frühausstieg bei leerem Budget.
---
## TP2 Verkauf (eigener Plan nach TP1)
- **Fragebogen** im Portal (Kachel „Terminplaner einrichten · 149 €“, nur mit Wartungs-Abo): 8 Schritte
(Branche/Vorlage mit Gesundheits-Ausschluss · wer wird gebucht + Namen · Leistungen · Zeiten, Bundesland,
Urlaub · Regeln + sofort/Anfrage · Gastfelder · Benachrichtigungen, Anrede, Erinnerung · Website: wo
buchen, Knopf/inline, Gestaltungswunsch, Ausweichtelefon, Links Impressum/Datenschutz), Zwischenspeichern
als Entwurf in `termineConfig`. Ergebnis = dieselbe Einstellung wie TP1 + Einbauwünsche.
- **Bestellung**: Zusammenfassung, Haken Leistungsbeschreibung + AVV Terminplaner (Fassung), „kein
Gesundheitsbereich“, Widerrufs-Zustimmung für Verbraucher (§ 356 Abs. 5 Nr. 2 BGB), Knopf
„Zahlungspflichtig bestellen · 149 €“ ⇒ Stripe Checkout (einmalig, Katalogeintrag `termineSetup`
14900 Cent). Verallgemeinerung des Funktionen-Checkouts: Register `config.features` (Preis,
Einwilligungstext, Zeiteinschätzung, Ticket-Text, Mails, Rücksprung-Hash) statt fester News-Werte;
K15/K16/T16 mit Platzhalter `{{funktion}}`; Widerrufsformular-Beschriftung.
- **Ticket mit Einbauanleitung** (`services/termine/einbauanleitung.js`, deterministisch aus der
Einstellung): Kurzfassung in `ticket.details` (≤ 8000 Zeichen), vollständig im WOMS-Reiter Funktionen
(Kopieren-Knopf) über `GET /api/team/funktionen/:customerId/termine/anleitung`. Inhalt: Modell,
Leistungen, Ressourcen + Zeiten, Regeln, Gastfelder, Benachrichtigungen, Einbau (Snippet mit siteKey,
Modus, CSS-Variablen-Vorschlag, Stelle auf der Website), Datenschutz-Absatz, Abnahme-Checkliste,
Abrechnung („Einrichtung bezahlt, nicht auf Abo-Stunden“).
- **Abo-Kopplung**: Bestellen nur mit laufendem Wartungs-Abo (gemeinsame Prüfung aus `analyse.js`
herausgelöst); täglicher Abgleich: Abo beendet ⇒ `disabled`, Mail an den Kunden, Löschung nach 30 Tagen.
- **Rechtstexte**: Leistungsbeschreibung Terminplaner, AVV Terminplaner (Geschwister des AVV
Website-Analyse), Datenschutz 6c, Gästetexte final, Karten in WOMS „Rechtliches“; Prüfrunde wie bei der
Analyse; danach `TERMINE_ENABLED=on`.
## TP3 Online-Zahlung (eigener Plan nach TP2)
- **Stripe Connect**, verbundene Konten mit Standard-Verhalten (`controller`: volles Dashboard,
`losses.payments=stripe`, `fees.payer=account`, `requirement_collection=stripe`), Fähigkeit
`card_payments`, Rahmenvertrag `full`; Einrichtung per **Account Link** (von Stripe gehostet); Status über
`account.updated` + Rücksprung. Sandbox-Plattformeinrichtung ist erledigt (Testkonto zeigt
Negativsalden/Anforderungen/Gebühren = Stripe); Live-Plattformeinrichtung vor dem Livegang.
- **Direct Charges** über von Stripe gehostetes Checkout auf dem Konto des Betriebs (`Stripe-Account`),
`mode: payment`, nur Festpreise ≥ 0,50 €, keine Plattformgebühr, SEPA ausgeschlossen (verzögert),
PayPal nicht verfügbar (Stripe unterstützt es bei Direct Charges nicht), `expires_at` ≥ 30 Minuten ⇒
Haltezeit bei Online-Zahlung = Sitzungsdauer + 2 Minuten.
- Zahlungsmodus je Betrieb `vorort | online | beides` (beides ⇒ Frage „Wie möchten Sie bezahlen?“ ohne
Vorauswahl, gleicher Preis); online nur, wenn das verbundene Konto `charges_enabled` hat, sonst
automatisch zurück auf vor Ort + Hinweis.
- Ablauf online (Vorschlag, in TP3 zu bestätigen): Widget „Zahlungspflichtig buchen“ ⇒ Bestätigungslink
⇒ Weiterleitung zu Stripe ⇒ Zahlung ⇒ `bestaetigt` per Webhook; späte Zahlung bei vergebenem Slot ⇒
sofortige Erstattung.
- **Eigener Connect-Webhook** (eigenes Secret, `event.account` + `livemode` prüfen, idempotent je Sitzung),
nicht in `routes/abo.js`.
- **Erstattungsregeln** (`recherche-recht-zahlung.md` 3.5): Betrieb sagt ab ⇒ voll; Gast in der Frist ⇒
voll; Widerruf ⇒ voll binnen 14 Tagen; zu spät/nicht erschienen ⇒ nach Regel des Betriebs; fehlendes
Guthaben ⇒ „Erstattung ausstehend“ im Portal + Mail.
- Rechtliches: Stripe-Absatz für die Datenschutzerklärung des Betriebs, AVV/Leistungsbeschreibung
ergänzen, keine Kassenfunktion, kein Rechnungsdruck.
## Nicht Teil (V1)
Gruppen/Kurse/Tischreservierung · Person + Platz gleichzeitig · Mehrere Leistungen in einer Buchung ·
Varianten (30/60/90) · Warteliste · SMS · Zwei-Wege-Kalender-Sync (Google/Outlook) · Anzahlungen ·
Kundenkonten für Gäste · Bewertungs- oder Werbemails · Mehrsprachigkeit · Gesundheitsbereich ·
Wechsel des Portal-Containers auf Node 24 (eigene Aufgabe; Node 20 ist seit 30.04.2026 ohne Pflege).
## Offene Punkte
1. Spike Bulk-Create (TP1, Aufgabe 1) – Freigabe für Wegwerf-Collection in der Produktions-Appwrite.
2. Rechtstexte: alle als Entwurf; Prüfung vor TP2-Livegang (wie Website-Analyse), u. a. Einstufung
Massage/Fotograf/Coaching als Widerrufsfälle und ob der Bestätigungslink die Bestellung ist.
3. TP3: Stripe-Live-Plattformeinrichtung durch den Nutzer; Ablauf „Bestätigungslink vor Zahlung“ bestätigen.
+10
View File
@@ -13,6 +13,7 @@
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"feiertagejs": "1.5.1",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3",
@@ -875,6 +876,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/feiertagejs": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/feiertagejs/-/feiertagejs-1.5.1.tgz",
"integrity": "sha512-K+erJKNcWGMmy2j9Ayn0MeinEt6LUXyQlUCjIkVR22jlPHJcI42227O+KF5/AB8MURuTUqO/kQSJwYoK/JaQHw==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+1
View File
@@ -20,6 +20,7 @@
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"feiertagejs": "1.5.1",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3",
+4 -3
View File
@@ -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' }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
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', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
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' }
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
@@ -1918,6 +1918,7 @@ const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
}
const loadedViews = new Set()
@@ -2110,7 +2111,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', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
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])'
+10
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/termine.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -185,6 +186,15 @@
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Terminplaner: Vorlage, Termine, Leistungen, Team & Plätze, Einstellungen (termine.js, initTermine); Unterseite von Funktionen -->
<section class="kb-view" id="view-termine">
<header class="kb-head">
<h1>Terminplaner</h1>
<p>Termine, die deine Kundschaft über deine Website bucht – und was du selbst einträgst.</p>
</header>
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Rechnungen -->
<section class="kb-view" id="view-invoices">
<header class="kb-head">
+37
View File
@@ -0,0 +1,37 @@
<!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>Terminplaner 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}
.fallback{margin:0;padding:16px;border:2px dashed #94a3b8;border-radius:12px;background:#fff}
/* Gestaltung ueber CSS-Variablen der Seite (alle optional, siehe docs/funktionen/termine-einbau.md):
[data-webklar-termine]{ --wkt-akzent:#0f766e; --wkt-radius:4px; } */
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h1>Termin buchen</h1>
<h2>Strecke direkt in der Seite (inline)</h2>
<!-- data-api weglassen = Herkunft des Skripts; der Ausweichtext bleibt ohne JavaScript und bei Fehlern stehen -->
<div data-webklar-termine="abcdefghijklmnop" data-modus="inline">
<p class="fallback" id="ausweich-inline">Termine telefonisch unter 06301 1234.</p>
</div>
<h2>Als Knopf (Dialog, lädt erst beim Klick)</h2>
<div data-webklar-termine="abcdefghijklmnop" data-modus="knopf" data-knopf="Termin online buchen">
<p class="fallback" id="ausweich-knopf">Termine telefonisch unter 06301 1234.</p>
</div>
</main>
<footer>Fußbereich</footer>
<script src="/embed/termine.js" defer></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+62 -2
View File
@@ -136,6 +136,60 @@ function analyseTile(projects) {
</article>`
}
/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
const termineOf = (project) => project.features?.termine || { state: 'off' }
// Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js
// mit einem umgebogenen Import). Aenderungen an beiden Stellen machen.
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
function pickTermineProject(projects) {
return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
}
function termineTile(projects) {
const states = projects.map((p) => termineOf(p).state)
const active = states.filter((s) => s === 'active').length
const target = pickTermineProject(projects)
const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
let extra
let action
if (active) {
extra = '<p class="fn-count" data-termine-count hidden></p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine öffnen</button>`
} else if (disabled) {
extra = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine ansehen</button>`
} else {
extra = '<p>Wir schalten den Terminplaner für dich frei.</p>'
action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
}
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="termine">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Terminplaner</h3>
<p>${TERMINE_TEXT}</p>
${extra}
${count}
${action}
</article>`
}
// "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen
async function showTermineCount(root, projects) {
const el = root.querySelector('[data-termine-count]')
const active = projects.filter((p) => termineOf(p).state === 'active')
if (!el || !active.length) return
const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0)))
const open = counts.reduce((sum, n) => sum + n, 0)
if (open < 1) return
el.textContent = anfragenText(open)
el.hidden = false
}
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>
@@ -145,15 +199,21 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [] } = await request('/api/funktionen')
const { projects = [], termine } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
</div>`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
// Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine)
root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('termine-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
if (termine?.verfuegbar) await showTermineCount(root, projects)
}
/* ---------------------------------------------------------------- News --- */
+145
View File
@@ -0,0 +1,145 @@
/* Gästeseite des Terminplaners (termin.html). Der Kopf trägt die Farbe des Betriebs (--t-akzent, Schrift --t-akzent-text,
vom Skript gesetzt, Schrift immer mit mindestens 4,5 : 1); Systemschrift, keine Fremdressourcen. */
:root {
color-scheme: light;
--t-akzent: #1f2937;
--t-akzent-text: #ffffff;
--t-akzent-rand: #1f2937;
--t-text: #1f2937;
--t-grund: #f4f5f7;
--t-karte: #ffffff;
--t-rand: #c9ced6;
--t-weich: #f3f4f6;
--t-fehler: #b91c1c;
--t-ok: #166534;
--t-radius: 12px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--t-grund);
color: var(--t-text);
font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.t-innen { width: 100%; max-width: 40rem; margin: 0 auto; padding: 0 16px; }
/* Kopf in der Farbe des Betriebs */
.t-kopf { background: var(--t-akzent); color: var(--t-akzent-text); padding: 14px 0; padding-top: max(14px, env(safe-area-inset-top)); }
.t-betrieb { margin: 0; font-size: 1.15rem; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; min-height: 1.5em; }
.t-haupt { flex: 1 0 auto; padding: 20px 0 8px; }
.t-karte { background: var(--t-karte); border: 1px solid var(--t-rand); border-radius: var(--t-radius); padding: 22px 20px; }
.t-titel { margin: 0 0 .75rem; font-size: 1.45rem; line-height: 1.25; font-weight: 700; }
.t-karte p { margin: 0 0 .85rem; }
.t-laden { opacity: .8; }
/* Zusammenfassung des Termins */
.t-zusammen { margin: 0 0 1.1rem; padding: .85rem 1rem; border-radius: var(--t-radius); background: var(--t-weich); display: grid; gap: .4rem; }
.t-zeile { display: grid; grid-template-columns: 9.5rem 1fr; gap: .15rem 1rem; }
.t-zeile dt { font-weight: 600; }
.t-zeile dd { margin: 0; overflow-wrap: anywhere; }
.t-neu dd { font-weight: 700; }
/* Status */
.t-status { margin: 0 0 .85rem; }
.t-status-text { display: inline-block; padding: .15rem .7rem; border-radius: 999px; font-size: .9rem; font-weight: 700; background: var(--t-weich); border: 1px solid var(--t-rand); }
.t-status-bestaetigt .t-status-text { background: #dcfce7; border-color: #86c79b; color: #14532d; }
.t-status-angefragt .t-status-text { background: #fef9c3; border-color: #d9c55a; color: #713f12; }
.t-status-abgesagt .t-status-text, .t-status-abgelehnt .t-status-text { background: #fee2e2; border-color: #e0a0a0; color: #7f1d1d; }
/* Meldungen und Hinweise */
.t-meldung { margin: 0 0 .85rem; padding: .65rem .85rem; border-left: 4px solid var(--t-fehler); background: var(--t-weich); font-weight: 600; }
.t-meldung-ok { border-left-color: var(--t-ok); }
.t-box { padding: .65rem .85rem; border-radius: var(--t-radius); background: var(--t-weich); }
.t-hinweis { font-size: .9rem; opacity: .9; }
.t-meldungen:empty { display: none; }
/* Knöpfe */
.t-nav { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.1rem; }
.t-btn {
font: inherit; font-weight: 600; line-height: 1.25; min-height: 44px; padding: .55rem 1.25rem; border-radius: var(--t-radius);
border: 2px solid var(--t-akzent-rand); background: var(--t-akzent); color: var(--t-akzent-text); cursor: pointer;
}
.t-btn:hover { filter: brightness(1.08); }
.t-btn-sek { background: var(--t-karte); color: var(--t-text); border-color: #858f9e; }
.t-btn-sek:hover { background: var(--t-weich); filter: none; }
.t-btn-warn { background: #b91c1c; color: #ffffff; border-color: #b91c1c; }
.t-btn[disabled] { opacity: .6; cursor: default; }
.t-karte :focus-visible, .t-fuss :focus-visible { outline: 3px solid var(--t-text); outline-offset: 2px; }
/* Die Ueberschrift wird nur per Skript angesprungen (tabindex -1), sie ist kein Bedienelement: kein Rahmen */
.t-karte .t-titel:focus, .t-karte .t-titel:focus-visible { outline: none; }
/* Kontakt (abgelaufen) */
.t-kontakt { margin: 0 0 .85rem; padding: 0; list-style: none; display: grid; gap: .4rem; }
.t-kontakt a { color: inherit; display: inline-block; padding: .25rem 0; min-height: 24px; }
/* Kalender (Verschieben) */
.t-kal-kopf { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .5rem; }
.t-monat { flex: 1; margin: 0; text-align: center; font-size: 1.1rem; font-weight: 700; }
.t-pfeil { font: inherit; font-size: 1.4rem; line-height: 1; min-width: 44px; min-height: 44px; border-radius: var(--t-radius); border: 1px solid #858f9e; background: var(--t-karte); color: inherit; cursor: pointer; }
.t-pfeil[disabled] { opacity: .35; cursor: default; }
.t-raster { width: 100%; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
.t-raster th { padding: .25rem 0; font-size: .8rem; font-weight: 600; text-align: center; }
.t-raster td { padding: 0; text-align: center; }
.t-tag { width: 100%; height: 44px; padding: 0; font: inherit; border-radius: 8px; border: 1px solid transparent; background: transparent; color: inherit; opacity: .45; cursor: default; }
.t-tag:not([disabled]) { opacity: 1; font-weight: 700; border-color: var(--t-akzent-rand); cursor: pointer; }
.t-tag:not([disabled]):hover { background: var(--t-weich); }
.t-tag[aria-pressed="true"], .t-tag[aria-pressed="true"]:hover { background: var(--t-akzent); color: var(--t-akzent-text); }
.t-zeiten { margin-top: 1rem; }
.t-zeiten-titel { margin: 0 0 .5rem; font-size: 1rem; font-weight: 700; }
.t-zeiten-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: .5rem; }
.t-zeit { font: inherit; font-weight: 600; min-height: 44px; border-radius: var(--t-radius); border: 1px solid var(--t-akzent-rand); background: var(--t-karte); color: inherit; cursor: pointer; }
.t-zeit:hover { background: var(--t-akzent); color: var(--t-akzent-text); }
/* Widerruf-Formular */
.t-form { margin: 0; }
.t-feld { margin: 0 0 1rem; }
.t-label { display: block; margin-bottom: .25rem; font-weight: 600; }
.t-eingabe { display: block; width: 100%; min-height: 44px; font: inherit; color: inherit; background: var(--t-karte); border: 1px solid #858f9e; border-radius: 8px; padding: .5rem .7rem; }
.t-eingabe[aria-invalid="true"] { border-color: var(--t-fehler); border-width: 2px; }
.t-fest { background: var(--t-weich); }
.t-feldfehler { margin: .3rem 0 0; color: var(--t-fehler); font-weight: 600; font-size: .9rem; }
.t-feldfehler:empty { display: none; }
.t-erklaerung { margin: 0 0 1rem; padding: .65rem .85rem; border-left: 4px solid var(--t-akzent-rand); background: var(--t-weich); }
.t-eingang { font-size: 1.1rem; }
/* Fuß */
.t-fuss { margin-top: 16px; padding: 14px 0 22px; padding-bottom: max(22px, env(safe-area-inset-bottom)); font-size: .875rem; }
.t-fuss a { color: inherit; display: inline-block; padding: .2rem 0; min-height: 24px; }
.t-links { display: flex; flex-wrap: wrap; gap: .25rem 1.25rem; margin: 0 0 .35rem; }
.t-links:empty { display: none; }
.t-powered { margin: 0; opacity: .8; font-size: .8rem; }
.t-vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 30rem) {
.t-karte { padding: 18px 16px; }
.t-zeile { grid-template-columns: 1fr; gap: 0; }
.t-zeile dd { margin-bottom: .35rem; }
.t-nav { flex-direction: column; align-items: stretch; } /* Reihenfolge im DOM = Reihenfolge auf dem Bildschirm */
.t-nav .t-btn { width: 100%; }
.t-raster { border-spacing: 2px; }
.t-tag { height: 40px; }
.t-zeiten-liste { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (forced-colors: active) {
.t-tag[aria-pressed="true"] { outline: 2px solid Highlight; }
.t-btn { border-color: ButtonText; }
}
@media print {
.t-kopf, .t-nav { display: none; }
body { background: #fff; }
.t-karte { border: 0; }
}
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light">
<title>Ihr Termin</title>
<link rel="stylesheet" href="/termin.css">
</head>
<body>
<!-- Gästeseite des Terminplaners: Links aus den Terminmails (…/termin.html#v=<Token> bestätigen, #m=<Token> verwalten).
Das Skript liest den Token aus dem Hash und entfernt ihn sofort aus der Adresszeile. Keine Cookies, kein Browser-Speicher. -->
<header class="t-kopf" id="kopf">
<div class="t-innen"><p class="t-betrieb" id="betrieb"></p></div>
</header>
<main class="t-haupt" id="inhalt">
<div class="t-innen">
<div class="t-karte" id="karte">
<noscript><p class="t-meldung">Bitte aktivieren Sie JavaScript, um Ihren Termin zu bestätigen oder zu verwalten.</p></noscript>
<p class="t-laden">Wird geladen …</p>
</div>
</div>
</main>
<div class="t-vh" id="ansage" aria-live="polite"></div>
<footer class="t-fuss" id="fuss">
<div class="t-innen">
<p class="t-links" id="fuss-links"></p>
<p class="t-powered"><a href="https://webklar.com/impressum" target="_blank" rel="noopener noreferrer">Impressum WEBklar</a> · Terminbuchung über WEBklar</p>
</div>
</footer>
<script src="/termin.js" defer></script>
</body>
</html>
+960
View File
@@ -0,0 +1,960 @@
/*! WEBklar Terminplaner: Gaesteseite (termin.html). Links aus den Terminmails:
* termin.html#v=<Token> Termin bestaetigen (G01) termin.html#m=<Token> Termin verwalten (G02, G03, G05, G07)
* Der Token wird sofort aus der Adresszeile entfernt (history.replaceState) und nur im Speicher der Seite gehalten.
* Keine Cookies, kein Browser-Speicher, keine Drittanfragen: alle Anfragen gehen als POST an /api/public/termine/v1/termin/*.
* Alle Texte (auch die des Betriebs) nur ueber textContent. Die Seite rechnet nie mit Datum oder Zeitzone des Browsers:
* Datums- und Uhrzeittexte, Kalenderdaten und der Eingang eines Widerrufs kommen vom Server (Berliner Zeit). */
(function () {
'use strict'
const API_PFAD = '/api/public/termine/v1/termin/'
const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const MONAT_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const ZEIT_RE = /^\d{2}:\d{2}$/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const TIMEOUT_MS = 20000
const WOCHE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WOCHE_LANG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const MONATE_FENSTER = 12 // Verschieben: so viele Monate vor und nach dem Monat des Termins sind waehlbar
const KOPF_VORGABE = '#1f2937'
const karte = document.getElementById('karte')
const kopfBetrieb = document.getElementById('betrieb')
const fussLinks = document.getElementById('fuss-links')
const ansageBox = document.getElementById('ansage')
// Zustand der Seite (nur im Speicher): Token, Antwort der Vorschau, Stand des Verschiebens
const S = { token: '', v: null, du: false, verschieben: null }
// ---------------------------------------------------------------- kleine Helfer
function T (sie, du) { return S.du ? du : sie }
function el (tag, cls, text) {
const e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
function knopf (cls, text, onClick) {
const b = el('button', cls, text)
b.type = 'button'
if (onClick) b.addEventListener('click', onClick)
return b
}
function istText (v) { return typeof v === 'string' }
function pad2 (n) { return n < 10 ? '0' + n : String(n) }
// Nur geprueftes https wird zum Link (Betriebsangaben sind Kundentext)
function httpsUrl (u) {
if (!istText(u)) return ''
try {
const p = new URL(u)
return p.protocol === 'https:' ? p.href : ''
} catch (e) { return '' }
}
// @telefon-anfang (reine Funktion, test/termineSeiteStatic.test.js fuehrt sie einzeln aus; dieselbe Regel in public/embed/termine.js)
// Telefonnummer des Betriebs (freier Text) als tel:-Adresse. Steht ein Buchstabe oder ein anderes Zeichen als Ziffern, Leerzeichen,
// + - ( ) / . darin ("06301 1234 (ab 9 Uhr)", "0171 1, 0171 2"), ist es keine Nummer, die man waehlen kann: dann '' und der Text bleibt
// Text. Sonst bleiben ein fuehrendes + und die Ziffern; die nationale Null "(0)" hinter der Landesvorwahl entfaellt.
function telefonLink (nr) {
if (typeof nr !== 'string') return ''
var s = nr.replace(/^\s+|\s+$/g, '')
if (!/^[0-9 +()\/.-]+$/.test(s)) return ''
var lv = /^((?:\+|00)\s*\d{1,3})[\s.\/-]*\(\s*0\s*\)/.exec(s)
if (lv) s = lv[1] + s.slice(lv[0].length)
var ziffern = s.replace(/[^0-9]/g, '')
if (ziffern.length < 3) return ''
return 'tel:' + (s.charAt(0) === '+' ? '+' : '') + ziffern
}
// @telefon-ende
function externerLink (href, text) {
const a = el('a', '', text)
a.href = href
a.target = '_blank'
a.rel = 'noopener noreferrer'
return a
}
function ansage (text) {
ansageBox.textContent = ''
window.setTimeout(function () { ansageBox.textContent = text }, 50)
}
// @farben-anfang (reine Funktionen, test/termineSeiteStatic.test.js fuehrt sie einzeln aus)
// Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb'.
function luminanz (farbe) {
const rgb = [1, 3, 5].map(function (i) {
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
}
// Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21).
function kontrast (a, b) {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
// Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
// (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe).
function textFarbeAuf (farbe) {
const l = luminanz(farbe)
const weiss = kontrast(l, 1)
const dunkel = kontrast(l, luminanz('#111827'))
const beste = Math.max(weiss, dunkel)
if (beste < 4.5 && kontrast(l, 0) > beste) return '#000000'
return weiss >= dunkel ? '#ffffff' : '#111827'
}
// @farben-ende
// Kopf in der Farbe des Betriebs; ohne gueltige Farbe das dunkle Grau
function setzeKopf (name, farbe) {
const f = FARBE_RE.test(farbe || '') ? farbe : KOPF_VORGABE
const text = textFarbeAuf(f)
const wurzel = document.documentElement.style
wurzel.setProperty('--t-akzent', f)
wurzel.setProperty('--t-akzent-text', text)
wurzel.setProperty('--t-akzent-rand', text === '#ffffff' ? f : text)
kopfBetrieb.textContent = istText(name) ? name : ''
}
// Impressum und Datenschutz des Betriebs im Fuss (nur https-Adressen, in neuem Tab: der Token ist aus der Adresse entfernt)
function setzeFuss (betrieb) {
fussLinks.textContent = ''
if (!betrieb) return
const imp = httpsUrl(betrieb.impressumUrl)
const ds = httpsUrl(betrieb.datenschutzUrl)
if (imp) fussLinks.appendChild(externerLink(imp, 'Impressum'))
if (ds) fussLinks.appendChild(externerLink(ds, 'Datenschutz'))
}
function titelSetzen (text) {
const name = S.v && S.v.betrieb && istText(S.v.betrieb.name) ? S.v.betrieb.name : ''
document.title = text + (name ? ' – ' + name : '')
}
// ---------------------------------------------------------------- Aufbau einer Ansicht
function leere () {
karte.textContent = ''
}
function add (node) {
karte.appendChild(node)
return node
}
function titel (text) {
const h = el('h1', 't-titel', text)
h.tabIndex = -1
titelSetzen(text)
return h
}
// Ansicht aufbauen: Inhalt leeren, fn fuellt ihn, danach die Ueberschrift fokussieren (Bildschirmleser, Tastatur)
function zeichne (fn, optionen) {
leere()
fn()
const h = karte.querySelector('h1')
if (h && !(optionen && optionen.ohneFokus)) h.focus({ preventScroll: false })
}
function meldung (text, art) {
const m = el('p', 't-meldung' + (art === 'ok' ? ' t-meldung-ok' : ''), text)
m.setAttribute('role', art === 'ok' ? 'status' : 'alert')
return m
}
// Zusammenfassung des Termins (alles vom Server, nur Texte)
function zusammenfassung (t) {
const dl = el('dl', 't-zusammen')
const zeilen = [
['Leistung', t.leistung],
['Bei', t.person],
['Datum', t.datum],
['Uhrzeit', istText(t.zeit) && t.zeit ? t.zeit + ' Uhr' : ''],
['Dauer', t.dauer],
['Preis', t.preis],
['Zahlung', t.zahlung],
['Ort', t.ort],
['Buchungsnummer', t.nummer]
]
zeilen.forEach(function (z) {
if (!istText(z[1]) || z[1] === '') return
const zeile = el('div', 't-zeile')
zeile.appendChild(el('dt', '', z[0]))
zeile.appendChild(el('dd', '', z[1]))
dl.appendChild(zeile)
})
return dl
}
// ---------------------------------------------------------------- Anfragen
function textObjekt (o) {
const r = {}
Object.keys(o).forEach(function (k) { if (istText(o[k])) r[k] = o[k] })
return r
}
// Fehlerobjekt aus einer Antwort: { status, fehler{feld:text}, code, betrieb|null, netz }
function fehlerAus (r) {
const b = r.body && typeof r.body === 'object' ? r.body : {}
return {
status: r.status,
fehler: b.fehler && typeof b.fehler === 'object' ? textObjekt(b.fehler) : {},
code: istText(b.code) ? b.code : '',
betrieb: b.betrieb && typeof b.betrieb === 'object' ? textObjekt(b.betrieb) : null,
netz: r.status === 0 || r.status >= 500 || r.status === 403
}
}
// POST an /termin/<aktion> mit dem Token; liefert den JSON-Body oder wirft ein Fehlerobjekt
function post (aktion, body) {
const ctrl = new AbortController()
const timer = window.setTimeout(function () { ctrl.abort() }, TIMEOUT_MS)
return fetch(API_PFAD + aktion, {
method: 'POST',
credentials: 'omit',
cache: 'no-store',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(Object.assign({ token: S.token }, body || {})),
signal: ctrl.signal
}).then(function (r) {
return r.json().then(
function (d) { return { status: r.status, ok: r.ok, body: d } },
function () { return { status: r.status, ok: r.ok, body: null } }
)
}, function () {
throw { netz: true, fehler: {}, code: '', status: 0 }
}).then(function (r) {
window.clearTimeout(timer)
if (r.ok && r.body && typeof r.body === 'object') return r.body
throw fehlerAus(r)
}, function (err) {
window.clearTimeout(timer)
throw err
})
}
// ---------------------------------------------------------------- Fehleransichten
function ersterText (fehler, vorgabe) {
const k = fehler ? Object.keys(fehler) : []
return k.length ? fehler[k[0]] : vorgabe
}
// Kopf und Fuss einer Fehleransicht: Ist die Vorschau schon geladen (der Link laeuft mitten in der Sitzung ab), bleiben
// Farbe, Anrede und Fusslinks, wie der Gast sie gesehen hat; sonst neutraler Kopf, Sie-Form, kein Betriebsfuss.
function fehlerKopf (name) {
if (S.v) {
setzeKopf(name || S.v.betrieb.name, S.v.farbe)
setzeFuss(S.v.betrieb)
return
}
S.du = false
setzeKopf(name, '')
setzeFuss(null)
}
// Link unbekannt, unvollstaendig oder ohne Hash
function zeigeUngueltig (text, hinweis) {
fehlerKopf('')
zeichne(function () {
add(titel(text))
add(el('p', '', hinweis))
})
}
// Abgelaufen: die Antwort traegt Name, Telefon und Website des Betriebs
function zeigeAbgelaufen (err) {
const b = err.betrieb || {}
fehlerKopf(b.name)
zeichne(function () {
add(titel(ersterText(err.fehler, 'Diese Reservierung ist abgelaufen.')))
add(el('p', '', T('Bitte buchen Sie Ihren Termin noch einmal oder rufen Sie uns an.', 'Bitte buche deinen Termin noch einmal oder ruf uns an.')))
const liste = el('ul', 't-kontakt')
const seite = httpsUrl(b.websiteUrl)
if (seite) {
const li = el('li')
li.appendChild(externerLink(seite, 'Zurück zur Website' + (b.name ? ' von ' + b.name : '')))
liste.appendChild(li)
}
if (istText(b.telefon) && b.telefon) {
const li = el('li')
li.appendChild(document.createTextNode('Telefon: '))
const tel = telefonLink(b.telefon)
if (tel) {
const a = el('a', '', b.telefon)
a.href = tel
li.appendChild(a)
} else {
li.appendChild(document.createTextNode(b.telefon))
}
liste.appendChild(li)
}
if (liste.childNodes.length) add(liste)
})
}
// Netz weg, Server nicht erreichbar oder Grenze erreicht: Meldung und "Erneut versuchen"
function zeigeAusfall (text, wiederhole) {
zeichne(function () {
add(titel('Das hat leider nicht geklappt'))
add(meldung(text))
if (wiederhole) {
const nav = el('div', 't-nav')
nav.appendChild(knopf('t-btn', 'Erneut versuchen', wiederhole))
add(nav)
}
})
}
// Gemeinsame Behandlung: unbekannt, abgelaufen, Ausfall; alles andere (Fehler des Gastes) uebernimmt fachlich(err)
function behandle (err, wiederhole, fachlich) {
if (err && err.code === 'unbekannt') return zeigeUngueltig(ersterText(err.fehler, 'Dieser Link ist ungültig oder abgelaufen.'), T('Bitte öffnen Sie den Link aus Ihrer E-Mail noch einmal.', 'Bitte öffne den Link aus deiner E-Mail noch einmal.'))
if (err && err.code === 'abgelaufen') return zeigeAbgelaufen(err)
if (!err || err.netz || err.status === 404 || err.status === 429 || !fachlich) {
const text = err && err.status === 429 ? ersterText(err.fehler, 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.')
: T('Gerade nicht erreichbar. Bitte versuchen Sie es in einem Moment noch einmal.', 'Gerade nicht erreichbar. Bitte versuche es in einem Moment noch einmal.')
return zeigeAusfall(text, wiederhole)
}
return fachlich(err)
}
// ---------------------------------------------------------------- Start
function start () {
const roh = window.location.hash
S.v = null
S.verschieben = null
// Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile;
// wirft der Browser dabei (eingeschraenkte Umgebung), laeuft die Seite trotzdem weiter
if (roh) {
try {
window.history.replaceState(null, '', window.location.pathname + window.location.search)
} catch (e) { /* Token bleibt dann in der Adresszeile, die Seite funktioniert */ }
}
if (!roh) {
zeigeUngueltig('Bitte öffnen Sie den Link aus Ihrer E-Mail', 'Diese Seite wird über den Link in der E-Mail zu Ihrem Termin aufgerufen. Bitte öffnen Sie diese E-Mail und klicken Sie dort noch einmal auf den Link.')
return
}
const m = /^#[vm]=([A-Za-z0-9_-]*)$/.exec(roh)
if (!m || !TOKEN_RE.test(m[1])) {
zeigeUngueltig('Dieser Link ist unvollständig oder ungültig', 'Bitte öffnen Sie den Link aus Ihrer E-Mail noch einmal. Manche E-Mail-Programme brechen lange Links um: dann hilft es, den ganzen Link zu kopieren und in die Adresszeile einzufügen.')
return
}
S.token = m[1]
lade()
}
function vorschauGueltig (d) {
return d && (d.art === 'bestaetigen' || d.art === 'verwalten') && istText(d.status) &&
d.termin && typeof d.termin === 'object' && d.betrieb && typeof d.betrieb === 'object' &&
d.aktionen && typeof d.aktionen === 'object'
}
// Vorschau holen und die passende Ansicht zeigen; notiz = Hinweis, der oben in der Ansicht steht
function lade (notiz) {
zeichne(function () { add(el('p', 't-laden', 'Wird geladen …')) }, { ohneFokus: true })
post('vorschau').then(function (d) {
if (!vorschauGueltig(d)) throw { netz: true, fehler: {}, code: '', status: 0 }
S.v = d
S.du = d.anrede === 'du'
S.verschieben = null
setzeKopf(d.betrieb.name, d.farbe)
setzeFuss(d.betrieb)
if (d.art === 'bestaetigen' && d.status === 'gehalten' && d.aktionen.bestaetigen === true) zeigeBestaetigen()
else if (d.art === 'bestaetigen') zeigeStatus(false)
else zeigeVerwalten(notiz)
}).catch(function (err) {
behandle(err, function () { lade(notiz) })
})
}
// ---------------------------------------------------------------- Bestaetigen (G01)
function zeigeBestaetigen () {
const v = S.v
const anfrage = istText(v.knopf) && v.knopf.indexOf('Anfrage') === 0
zeichne(function () {
add(titel(anfrage ? 'Anfrage bestätigen' : 'Termin bestätigen'))
add(el('p', '', T('Bitte prüfen Sie die Angaben und bestätigen Sie mit dem Knopf unten.', 'Bitte prüfe die Angaben und bestätige mit dem Knopf unten.')))
add(zusammenfassung(v.termin))
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
const btn = knopf('t-btn', istText(v.knopf) && v.knopf ? v.knopf : 'Termin bestätigen', function () {
if (btn.disabled) return
btn.disabled = true
melde.textContent = ''
post('bestaetigen').then(function (d) {
if (!d || d.ok !== true || !istText(d.status)) throw { netz: true, fehler: {}, code: '', status: 0 }
S.v.status = d.status
S.v.aktionen.bestaetigen = false
zeigeStatus(d.schon !== true)
}).catch(function (err) {
btn.disabled = false
behandle(err, function () { zeigeBestaetigen() }, function (e) {
melde.appendChild(meldung(ersterText(e.fehler, 'Das hat leider nicht geklappt.')))
})
})
})
nav.appendChild(btn)
})
}
// Zustand nach dem Bestaetigen (neu = gerade bestaetigt) oder beim erneuten Aufruf des Links (bereits bestaetigt, abgesagt, ...)
function zeigeStatus (neu) {
const v = S.v
const betrieb = v.betrieb.name
let t = ''
let text = ''
let ok = false
switch (v.status) {
case 'bestaetigt':
ok = true
t = neu ? T('Ihr Termin ist bestätigt.', 'Dein Termin ist bestätigt.') : 'Dieser Termin ist bereits bestätigt.'
text = neu
? T('Wir haben Ihnen eine Bestätigung mit allen Angaben per E-Mail geschickt. Dort finden Sie auch den Link, mit dem Sie den Termin absagen oder verschieben können.',
'Wir haben dir eine Bestätigung mit allen Angaben per E-Mail geschickt. Dort findest du auch den Link, mit dem du den Termin absagen oder verschieben kannst.')
: T('Sie müssen nichts weiter tun. Den Link zum Absagen oder Verschieben finden Sie in Ihrer Bestätigungs-E-Mail.', 'Du musst nichts weiter tun. Den Link zum Absagen oder Verschieben findest du in deiner Bestätigungs-E-Mail.')
break
case 'angefragt':
ok = true
t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : T('Ihre Anfrage wurde bereits abgesendet.', 'Deine Anfrage wurde bereits abgesendet.')
text = neu
? T(betrieb + ' prüft Ihre Anfrage und meldet sich bei Ihnen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.',
betrieb + ' prüft deine Anfrage und meldet sich bei dir. Eine Eingangsbestätigung haben wir dir per E-Mail geschickt.')
: T('Sie erhalten eine E-Mail, sobald ' + betrieb + ' sie annimmt.', 'Du erhältst eine E-Mail, sobald ' + betrieb + ' sie annimmt.')
break
case 'abgesagt':
t = 'Dieser Termin wurde abgesagt.'
break
case 'abgelehnt':
t = 'Diese Anfrage wurde abgelehnt.'
break
case 'erledigt':
t = 'Dieser Termin hat bereits stattgefunden.'
break
default:
t = 'Dieser Termin kann hier nicht mehr bestätigt werden.'
}
zeichne(function () {
add(titel(t))
if (text) add(meldung(text, 'ok'))
add(zusammenfassung(v.termin))
if (!ok && v.betrieb.telefon) add(el('p', '', T('Bei Fragen erreichen Sie uns unter ', 'Bei Fragen erreichst du uns unter ') + v.betrieb.telefon + '.'))
})
if (neu) ansage(t)
}
// ---------------------------------------------------------------- Verwalten (G02, G03, G05, G07)
const STATUS_TEXT = {
bestaetigt: 'Bestätigt',
angefragt: 'Angefragt',
abgesagt: 'Abgesagt',
abgelehnt: 'Abgelehnt',
erledigt: 'Stattgefunden',
nichterschienen: 'Vorbei'
}
function zeigeVerwalten (notiz) {
const v = S.v
const a = v.aktionen
zeichne(function () {
add(titel(T('Ihr Termin', 'Dein Termin')))
const status = el('p', 't-status t-status-' + v.status.replace(/[^a-z]/g, ''))
status.appendChild(el('span', 't-status-text', STATUS_TEXT[v.status] || v.status))
add(status)
if (notiz) add(meldung(notiz))
add(zusammenfassung(v.termin))
if (v.status === 'angefragt') {
add(el('p', 't-box', T(v.betrieb.name + ' hat diese Anfrage noch nicht angenommen. Sie erhalten eine E-Mail, sobald der Termin bestätigt oder abgelehnt ist.',
v.betrieb.name + ' hat diese Anfrage noch nicht angenommen. Du bekommst eine E-Mail, sobald der Termin bestätigt oder abgelehnt ist.')))
}
if (istText(v.hinweis) && v.hinweis) add(el('p', 't-box', v.hinweis))
const nav = el('div', 't-nav')
if (a.verschieben) nav.appendChild(knopf('t-btn', 'Termin verschieben', function () { zeigeVerschieben() }))
if (a.absagen) nav.appendChild(knopf('t-btn t-btn-sek', 'Termin absagen', zeigeAbsageFrage))
// § 356a BGB: die Widerrufsfunktion ist gut sichtbar und leicht erreichbar, ein gleichrangiger Knopf in derselben Reihe
if (a.widerrufen) nav.appendChild(knopf('t-btn t-btn-sek', 'Vertrag widerrufen', zeigeWiderruf))
if (nav.childNodes.length) add(nav)
const offen = v.status === 'bestaetigt' || v.status === 'angefragt'
if (offen && !a.absagen && !a.verschieben && !a.widerrufen && !(istText(v.hinweis) && v.hinweis)) {
add(el('p', 't-box', 'Dieser Termin kann online nicht mehr geändert werden.' +
(v.betrieb.telefon ? ' ' + T('Bitte rufen Sie uns an: ', 'Bitte ruf uns an: ') + v.betrieb.telefon : '')))
}
})
}
// ---- Absagen (mit Rueckfrage in der Seite)
function zeigeAbsageFrage () {
const v = S.v
zeichne(function () {
add(titel('Termin wirklich absagen?'))
add(zusammenfassung(v.termin))
add(el('p', '', 'Der Termin wird freigegeben. Das lässt sich nicht rückgängig machen.'))
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Nein, Termin behalten', function () { zeigeVerwalten() }))
const ja = knopf('t-btn t-btn-warn', 'Ja, Termin absagen', function () {
if (ja.disabled) return
ja.disabled = true
melde.textContent = ''
post('absagen').then(function (d) {
if (!d || d.ok !== true) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeAbgesagt()
}).catch(function (err) {
ja.disabled = false
behandle(err, zeigeAbsageFrage, function (e) {
// Frist vorbei oder nicht mehr moeglich: Stand neu laden, die Meldung des Servers steht oben
lade(ersterText(e.fehler, 'Der Termin kann nicht mehr abgesagt werden.'))
})
})
})
nav.appendChild(ja)
})
}
function zeigeAbgesagt () {
const v = S.v
v.status = 'abgesagt'
v.aktionen = { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false }
zeichne(function () {
const t = T('Ihr Termin wurde abgesagt.', 'Dein Termin wurde abgesagt.')
add(titel(t))
add(meldung(T('Wir haben Ihnen eine Bestätigung per E-Mail geschickt.', 'Wir haben dir eine Bestätigung per E-Mail geschickt.'), 'ok'))
add(zusammenfassung(v.termin))
ansage(t)
})
}
// ---- Verschieben (Kalender und Uhrzeiten wie im Widget, Daten von /termin/tage und /termin/zeiten)
// 'Montag, 12.10.2026' aus dem Tag 'YYYY-MM-DD' und den Kalenderdaten des Monats (ersterWochentag kommt vom Server)
function tagText (tag, info) {
const d = parseInt(tag.slice(8, 10), 10)
const wochentag = (info.ersterWochentag - 1 + d - 1) % 7
return WOCHE_LANG[wochentag] + ', ' + pad2(d) + '.' + tag.slice(5, 7) + '.' + tag.slice(0, 4)
}
function monatNr (monat) { return parseInt(monat.slice(0, 4), 10) * 12 + parseInt(monat.slice(5, 7), 10) - 1 }
// Monat des bisherigen Termins aus dem Datumstext des Servers ('Montag, 12.10.2026')
function terminMonat (datum) {
const m = /(\d{2})\.(\d{2})\.(\d{4})\s*$/.exec(datum || '')
return m ? m[3] + '-' + m[2] : ''
}
function verschiebeStand () {
if (!S.verschieben) {
const start = terminMonat(S.v.termin.datum)
const nr = start ? monatNr(start) : 0
S.verschieben = { start: start, monat: '', tag: '', zeit: '', cache: {}, minNr: nr - MONATE_FENSTER, maxNr: nr + MONATE_FENSTER, zeitSeq: 0, seq: 0 }
}
return S.verschieben
}
function zeigeVerschieben (notiz) {
const t = S.v.termin
const V = verschiebeStand()
if (!V.start) {
zeigeAusfall(T('Gerade nicht erreichbar. Bitte versuchen Sie es in einem Moment noch einmal.', 'Gerade nicht erreichbar. Bitte versuche es in einem Moment noch einmal.'), function () { zeigeVerschieben() })
return
}
V.cache = {} // Tage immer frisch beim Betreten der Ansicht
const seq = ++V.seq
let aktuell = null // { monat, info, frei, anzahl }
let zurueck, vor, monLabel, tbody, zeitenBox
zeichne(function () {
add(titel('Termin verschieben'))
add(el('p', '', T('Ihr bisheriger Termin: ', 'Dein bisheriger Termin: ') + t.datum + ', ' + t.zeit + ' Uhr' + (t.leistung ? ' (' + t.leistung + ')' : '')))
add(el('p', 't-hinweis', T('Bitte wählen Sie einen Tag mit freien Zeiten und danach die neue Uhrzeit.', 'Bitte wähle einen Tag mit freien Zeiten und danach die neue Uhrzeit.')))
if (notiz) add(meldung(notiz))
const kal = add(el('div', 't-kal'))
const kopf = el('div', 't-kal-kopf')
zurueck = knopf('t-pfeil', '‹')
zurueck.setAttribute('aria-label', 'Vorheriger Monat')
monLabel = el('h2', 't-monat')
monLabel.id = 't-monat'
vor = knopf('t-pfeil', '›')
vor.setAttribute('aria-label', 'Nächster Monat')
kopf.appendChild(zurueck)
kopf.appendChild(monLabel)
kopf.appendChild(vor)
kal.appendChild(kopf)
const tabelle = el('table', 't-raster')
tabelle.setAttribute('aria-labelledby', 't-monat')
const thead = el('thead')
const tr = el('tr')
WOCHE_KURZ.forEach(function (k, i) {
const th = el('th', '', k)
th.scope = 'col'
th.abbr = WOCHE_LANG[i]
th.setAttribute('aria-label', WOCHE_LANG[i])
tr.appendChild(th)
})
thead.appendChild(tr)
tbody = el('tbody')
tabelle.appendChild(thead)
tabelle.appendChild(tbody)
kal.appendChild(tabelle)
zeitenBox = add(el('div', 't-zeiten'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Zurück', function () { V.seq++; zeigeVerwalten() }))
})
function ausfall (err) {
if (seq !== V.seq) return
behandle(err, function () { zeigeVerschieben() }, function (e) {
// Frist vorbei, nicht mehr moeglich: Stand neu laden
lade(ersterText(e.fehler, 'Der Termin kann nicht mehr verschoben werden.'))
})
}
function holeTage (monat) {
if (V.cache[monat]) return Promise.resolve(V.cache[monat])
return post('tage', { monat: monat }).then(function (d) {
const i = d && d.monat
const ok = d && Array.isArray(d.tage) && i && istText(i.label) && i.ersterWochentag >= 1 && i.ersterWochentag <= 7 &&
i.anzahlTage >= 28 && i.anzahlTage <= 31 && MONAT_RE.test(i.vorher || '') && MONAT_RE.test(i.nachher || '')
if (!ok) throw { netz: true, fehler: {}, code: '', status: 0 }
const frei = {}
d.tage.forEach(function (x) { if (istText(x) && TAG_RE.test(x)) frei[x] = true })
V.cache[monat] = { monat: monat, info: i, frei: frei, anzahl: Object.keys(frei).length }
return V.cache[monat]
})
}
function baueRaster (m) {
aktuell = m
V.monat = m.monat
monLabel.textContent = m.info.label
const hatteFokus = document.activeElement
zurueck.disabled = monatNr(m.info.vorher) < V.minNr
vor.disabled = monatNr(m.info.nachher) > V.maxNr
tbody.textContent = ''
let zeile = null
let spalte = 0
function neueZeile () {
zeile = el('tr')
tbody.appendChild(zeile)
spalte = 0
}
neueZeile()
for (let i = 1; i < m.info.ersterWochentag; i++) {
const leer = el('td', 't-leer')
leer.setAttribute('aria-hidden', 'true')
zeile.appendChild(leer)
spalte++
}
for (let d = 1; d <= m.info.anzahlTage; d++) {
if (spalte === 7) neueZeile()
const tag = m.monat + '-' + pad2(d)
const frei = m.frei[tag] === true
const td = el('td', 't-zelle')
const b = el('button', 't-tag', String(d))
b.type = 'button'
b.setAttribute('data-tag', tag)
b.setAttribute('aria-label', d + '. ' + m.info.label + (frei ? '' : ', keine freien Zeiten'))
if (frei) b.setAttribute('aria-pressed', V.tag === tag ? 'true' : 'false')
else b.disabled = true
td.appendChild(b)
zeile.appendChild(td)
spalte++
}
// Ein Pfeilknopf, der gerade gesperrt wurde, darf den Fokus nicht verlieren
if ((hatteFokus === zurueck && zurueck.disabled) || (hatteFokus === vor && vor.disabled)) {
const anderer = hatteFokus === zurueck ? vor : zurueck
if (!anderer.disabled) anderer.focus()
}
}
// auto: ohne freien Tag zum naechsten Monat (hoechstens drei Mal), damit nicht ein leerer Monat am Anfang steht
function zeigeMonat (monat, auto, versuch) {
return holeTage(monat).then(function (m) {
if (seq !== V.seq) return null
if (auto && m.anzahl === 0 && versuch < 3 && monatNr(m.info.nachher) <= V.maxNr) return zeigeMonat(m.info.nachher, true, versuch + 1)
baueRaster(m)
ansage(m.anzahl === 0 ? 'In diesem Monat gibt es keine freien Termine.' : m.info.label)
if (m.anzahl === 0) {
zeitenBox.textContent = ''
zeitenBox.appendChild(el('p', '', 'In diesem Monat gibt es keine freien Termine.'))
}
return m
}, ausfall)
}
function wechsle (monat) {
const nr = monatNr(monat)
if (nr < V.minNr || nr > V.maxNr) return
zeitenBox.textContent = ''
holeTage(monat).then(function (m) {
if (seq !== V.seq) return
baueRaster(m)
ansage(m.info.label)
if (m.anzahl === 0) zeitenBox.appendChild(el('p', '', 'In diesem Monat gibt es keine freien Termine.'))
else if (V.tag && V.tag.slice(0, 7) === m.monat) holeZeiten(V.tag, m)
}, ausfall)
}
zurueck.addEventListener('click', function () { if (aktuell) wechsle(aktuell.info.vorher) })
vor.addEventListener('click', function () { if (aktuell) wechsle(aktuell.info.nachher) })
tbody.addEventListener('click', function (e) {
const b = e.target && e.target.closest ? e.target.closest('button[data-tag]') : null
if (!b || b.disabled || !aktuell) return
const tag = b.getAttribute('data-tag')
V.tag = tag
V.zeit = ''
const alle = tbody.querySelectorAll('button[data-tag]')
for (let i = 0; i < alle.length; i++) if (!alle[i].disabled) alle[i].setAttribute('aria-pressed', alle[i] === b ? 'true' : 'false')
holeZeiten(tag, aktuell)
})
function holeZeiten (tag, m) {
const nr = ++V.zeitSeq
const text = tagText(tag, m.info)
zeitenBox.textContent = ''
zeitenBox.appendChild(el('p', 't-laden', 'Zeiten werden geladen …'))
post('zeiten', { tag: tag }).then(function (d) {
if (!d || !Array.isArray(d.zeiten)) throw { netz: true, fehler: {}, code: '', status: 0 }
if (nr !== V.zeitSeq || seq !== V.seq) return
zeitenBox.textContent = ''
const h = el('h3', 't-zeiten-titel', 'Freie Zeiten am ' + text)
h.id = 't-zeiten-titel'
zeitenBox.appendChild(h)
const zeiten = d.zeiten.filter(function (z) { return istText(z) && ZEIT_RE.test(z) })
if (zeiten.length === 0) {
zeitenBox.appendChild(el('p', '', T('An diesem Tag ist nichts mehr frei. Bitte wählen Sie einen anderen Tag.', 'An diesem Tag ist nichts mehr frei. Bitte wähle einen anderen Tag.')))
ansage('Keine freien Zeiten am ' + text)
return
}
const liste = el('div', 't-zeiten-liste')
liste.setAttribute('role', 'group')
liste.setAttribute('aria-labelledby', 't-zeiten-titel')
zeiten.forEach(function (z) {
const b = knopf('t-zeit', z, function () {
V.zeit = z
zeigeVerschiebeFrage(tag, z, text)
})
b.setAttribute('aria-label', z + ' Uhr, ' + text)
liste.appendChild(b)
})
zeitenBox.appendChild(liste)
ansage(zeiten.length + (zeiten.length === 1 ? ' freie Zeit am ' : ' freie Zeiten am ') + text)
}).catch(function (err) {
if (nr === V.zeitSeq) ausfall(err)
})
}
zeigeMonat(V.monat || V.start, !V.monat, 0).then(function (m) {
if (m && V.tag && V.tag.slice(0, 7) === m.monat && m.frei[V.tag]) {
const b = tbody.querySelector('button[data-tag="' + V.tag + '"]')
if (b) holeZeiten(V.tag, m)
}
})
}
// Rueckfrage vor dem Verschieben: bisher und neu nebeneinander
function zeigeVerschiebeFrage (tag, zeit, datumText) {
const t = S.v.termin
zeichne(function () {
add(titel('Termin verschieben?'))
const dl = el('dl', 't-zusammen')
const bisher = el('div', 't-zeile')
bisher.appendChild(el('dt', '', 'Bisher'))
bisher.appendChild(el('dd', '', t.datum + ', ' + t.zeit + ' Uhr'))
dl.appendChild(bisher)
const neu = el('div', 't-zeile t-neu')
neu.appendChild(el('dt', '', 'Neu'))
neu.appendChild(el('dd', '', datumText + ', ' + zeit + ' Uhr'))
dl.appendChild(neu)
if (t.leistung) {
const l = el('div', 't-zeile')
l.appendChild(el('dt', '', 'Leistung'))
l.appendChild(el('dd', '', t.leistung))
dl.appendChild(l)
}
add(dl)
const melde = add(el('div', 't-meldungen'))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Andere Zeit wählen', function () { zeigeVerschieben() }))
const ja = knopf('t-btn', 'Termin jetzt verschieben', function () {
if (ja.disabled) return
ja.disabled = true
melde.textContent = ''
post('verschieben', { tag: tag, zeit: zeit }).then(function (d) {
if (!d || d.ok !== true || !d.termin || !istText(d.termin.datum) || !istText(d.termin.zeit)) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeVerschoben(d)
}).catch(function (err) {
ja.disabled = false
behandle(err, function () { zeigeVerschiebeFrage(tag, zeit, datumText) }, function (e) {
if (e.fehler.zeit || e.fehler.tag) {
// Zeit inzwischen vergeben oder dieselbe Zeit: zurueck zur Auswahl, die Meldung des Servers steht oben
zeigeVerschieben(ersterText(e.fehler, ''))
} else {
lade(ersterText(e.fehler, 'Der Termin kann nicht verschoben werden.'))
}
})
})
})
nav.appendChild(ja)
})
}
function zeigeVerschoben (d) {
const v = S.v
v.termin.datum = d.termin.datum
v.termin.zeit = d.termin.zeit
S.verschieben = null
zeichne(function () {
const t = T('Ihr Termin wurde verschoben.', 'Dein Termin wurde verschoben.')
add(titel(t))
add(meldung(T('Wir haben Ihnen eine neue Bestätigung per E-Mail geschickt.', 'Wir haben dir eine neue Bestätigung per E-Mail geschickt.'), 'ok'))
add(zusammenfassung(v.termin))
const nav = add(el('div', 't-nav'))
nav.appendChild(knopf('t-btn t-btn-sek', 'Zur Terminübersicht', function () { lade() }))
ansage(t)
})
}
// ---------------------------------------------------------------- Widerruf (Formular nach § 356a BGB)
function zeigeWiderruf () {
const v = S.v
const t = v.termin
const gast = v.gast && typeof v.gast === 'object' ? v.gast : { name: '', email: '' }
zeichne(function () {
add(titel('Vertrag widerrufen'))
add(el('p', '', T('Hier können Sie den Vertrag über diesen Termin widerrufen (§ 356a BGB). Bitte prüfen Sie Ihre Angaben und senden Sie die Erklärung mit dem Knopf unten ab. Den Eingang bestätigen wir Ihnen per E-Mail.',
'Hier kannst du den Vertrag über diesen Termin widerrufen (§ 356a BGB). Bitte prüfe deine Angaben und sende die Erklärung mit dem Knopf unten ab. Den Eingang bestätigen wir dir per E-Mail.')))
add(zusammenfassung(t))
const form = el('form', 't-form')
form.noValidate = true
const idName = 't-f-name'
const feldName = el('div', 't-feld')
const lName = el('label', 't-label', 'Name')
lName.htmlFor = idName
const iName = el('input', 't-eingabe')
iName.id = idName
iName.name = 'name'
iName.type = 'text'
iName.autocomplete = 'name'
iName.maxLength = 100
iName.required = true
iName.value = istText(gast.name) ? gast.name : ''
const fehlerName = el('p', 't-feldfehler')
fehlerName.id = 't-f-name-fehler'
feldName.appendChild(lName)
feldName.appendChild(iName)
feldName.appendChild(fehlerName)
form.appendChild(feldName)
function festesFeld (id, label, wert, typ) {
const f = el('div', 't-feld')
const l = el('label', 't-label', label)
l.htmlFor = id
const i = el('input', 't-eingabe t-fest')
i.id = id
i.type = typ
i.readOnly = true
i.value = wert
f.appendChild(l)
f.appendChild(i)
return f
}
form.appendChild(festesFeld('t-f-email', 'E-Mail-Adresse der Buchung', istText(gast.email) ? gast.email : '', 'text'))
form.appendChild(festesFeld('t-f-nummer', 'Buchungsnummer', istText(t.nummer) ? t.nummer : '', 'text'))
const erklaerung = el('p', 't-erklaerung',
'Hiermit widerrufe ich den von mir abgeschlossenen Vertrag über die Erbringung der folgenden Dienstleistung: ' +
(t.leistung || 'Termin') + ', ' + t.datum + ', ' + t.zeit + ' Uhr (Buchungsnummer ' + (t.nummer || '–') + ').')
form.appendChild(erklaerung)
const melde = el('div', 't-meldungen')
form.appendChild(melde)
const nav = el('div', 't-nav')
nav.appendChild(knopf('t-btn t-btn-sek', 'Zurück', function () { zeigeVerwalten() }))
const senden = el('button', 't-btn t-btn-warn', 'Widerruf bestätigen')
senden.type = 'submit'
nav.appendChild(senden)
form.appendChild(nav)
add(form)
function nameFehler (text) {
fehlerName.textContent = text
if (text) {
iName.setAttribute('aria-invalid', 'true')
iName.setAttribute('aria-describedby', fehlerName.id)
} else {
iName.removeAttribute('aria-invalid')
iName.removeAttribute('aria-describedby')
}
}
form.addEventListener('submit', function (e) {
e.preventDefault()
if (senden.disabled) return
const name = iName.value.trim()
melde.textContent = ''
nameFehler('')
if (!name) {
nameFehler(T('Bitte geben Sie Ihren Namen an.', 'Bitte gib deinen Namen an.'))
iName.focus()
return
}
senden.disabled = true
post('widerruf', { name: name }).then(function (d) {
if (!d || !d.eingang || !istText(d.eingang.datum) || !istText(d.eingang.uhrzeit)) throw { netz: true, fehler: {}, code: '', status: 0 }
zeigeWiderrufEingang(d.eingang, gast)
}).catch(function (err) {
senden.disabled = false
behandle(err, zeigeWiderruf, function (er) {
if (er.fehler.name) {
nameFehler(er.fehler.name)
iName.focus()
} else if (er.code === 'kein_widerruf') {
lade(ersterText(er.fehler, 'Für diesen Termin ist kein Widerruf möglich.'))
} else {
melde.appendChild(meldung(ersterText(er.fehler, 'Das hat leider nicht geklappt.')))
}
})
})
})
})
}
// Eingangsbestaetigung: Datum und Uhrzeit (Berliner Zeit) vom Server
function zeigeWiderrufEingang (eingang, gast) {
const v = S.v
v.status = 'abgesagt'
v.aktionen = { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false }
zeichne(function () {
const t = T('Ihr Widerruf ist bei uns eingegangen.', 'Dein Widerruf ist bei uns eingegangen.')
add(titel(t))
const eing = el('p', 't-eingang')
eing.appendChild(el('span', 't-eingang-label', 'Eingang: '))
eing.appendChild(el('strong', '', eingang.datum + ', ' + eingang.uhrzeit + ' Uhr'))
add(eing)
add(el('p', '', T('Eine Bestätigung des Eingangs haben wir Ihnen per E-Mail', 'Eine Bestätigung des Eingangs haben wir dir per E-Mail') +
(istText(gast.email) && gast.email ? ' an ' + gast.email : '') + ' geschickt.'))
add(zusammenfassung(v.termin))
ansage(t)
})
}
start()
// Wird ein neuer Link in dieselbe Seite eingefuegt (nur der Hash aendert sich), laedt der Browser nicht neu: dann neu starten
window.addEventListener('hashchange', function () { if (window.location.hash) start() })
})()
+166
View File
@@ -0,0 +1,166 @@
/* Terminplaner im Kundenbereich: Aktionen auf Terminen mit Rueckfrage in der Seite (kein confirm()).
starteAktion(t, li, b, art, knopf) oeffnet die Rueckfrage unter dem Termin, fuehreAus schickt sie an /api/termine ab.
t ist der Zustand des Reiters "Termine": { ctx, offen, nachAenderung() } – nachAenderung laedt Liste und Zahl der Anfragen neu
(der Reiter setzt sie; so kennt dieses Modul den Reiter nicht). Fehler: 400 und belegte Zeit bleiben in der Rueckfrage,
alles andere erscheint als Meldung ueber den Reitern. */
import { MAX, ABSAGE_GRUENDE, datumKurz, heuteBerlin } from './termine-view.js'
import { allgemeinerFehler, feld, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js'
import { zeitWahl } from './termine-formulare.js'
export const AKTION_TEXT = {
annehmen: 'Annehmen', ablehnen: 'Ablehnen', verschieben: 'Verschieben', absagen: 'Absagen', nichterschienen: 'Nicht erschienen',
anonymisieren: 'Gastdaten löschen', entsperren: 'Sperre aufheben',
}
export function schliesseFrage(t, fokus = false) {
if (!t.offen) return
const { li, knopf } = t.offen
li.querySelector('[data-frage]')?.replaceChildren()
li.classList.remove('tm-offen')
t.offen = null
if (fokus && knopf?.isConnected) knopf.focus()
}
export function starteAktion(t, li, b, art, knopf) {
t.ctx.meldung('')
schliesseFrage(t)
const zu = () => schliesseFrage(t, true)
const fragen = { annehmen: frageAnnehmen, ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
const box = fragen[art](t, b, zu)
li.querySelector('[data-frage]').replaceChildren(box)
li.classList.add('tm-offen')
t.offen = { li, knopf, art }
// Fokus ins erste Eingabefeld; ohne Eingabefeld auf die Rueckfrage selbst, damit ein Enter nichts bestaetigt
const erster = box.querySelector('select, textarea, input') || box
erster.focus()
}
/**
* Aktion an die Schnittstelle schicken. erfolg = Text der Meldung. form = Rueckfrage-Formular fuer Fehler (400 und belegte Zeit
* bleiben dort), sonst Meldung ueber den Reitern; bei Fehlern, die auf einen geaenderten Stand deuten (404, 409), wird die Liste neu geladen.
*/
async function fuehreAus(t, b, body, erfolg, form, knopf) {
const { ctx } = t
knopf.disabled = true
try {
const entsperren = body.aktion === 'entsperren'
const antwort = entsperren
? await ctx.api.send('DELETE', `/buchungen/${encodeURIComponent(b.id)}`)
: await ctx.api.send('POST', `/buchungen/${encodeURIComponent(b.id)}/aktion`, body)
await t.nachAenderung() // erst die Liste, dann die Meldung: wer die Meldung sieht, sieht auch den neuen Stand
ctx.meldung(antwort.schon ? 'Das war schon so erledigt.' : erfolg)
} catch (err) {
const imFormular = form && (err.status === 400 || err.code === 'SLOT_VERGEBEN')
if (imFormular) {
zeigeFehler(form, err)
knopf.disabled = false
return
}
ctx.meldung(err.message, 'fehler')
if (err.status === 404 || err.status === 409) await t.nachAenderung()
else knopf.disabled = false
}
}
const mailHinweis = (b, text) => (b.gastEmail ? text : 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist.')
// Annehmen schliesst den Vertrag und verschickt die Bestaetigung (G02): wie jede Aktion erst nach einer Rueckfrage in der Seite
function frageAnnehmen(t, b, zu) {
const { ok, zeile } = knopfZeile('Annehmen', zu)
const form = frage('Anfrage annehmen?',
h('p', null, b.gastEmail
? `${b.gastName} bekommt eine Bestätigung per E-Mail. Mit dem Annehmen kommt der Termin zustande.`
: 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist. Mit dem Annehmen kommt der Termin zustande.'),
allgemeinerFehler(),
zeile)
const erfolg = b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.'
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'annehmen' }, erfolg, form, ok))
return form
}
function frageAblehnen(t, b, zu) {
const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) })
const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
const form = frage('Anfrage ablehnen?',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail, dass der Termin nicht möglich ist. Die Zeit wird wieder frei.')),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'ablehnen', nachricht: nachricht.value }, 'Anfrage abgelehnt.', form, ok))
return form
}
function frageAbsagen(t, b, zu) {
const grund = h('select', null, h('option', { value: '' }, 'Bitte wählen'), ABSAGE_GRUENDE.map((g) => h('option', { value: g }, g)))
const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) })
const { ok, zeile } = knopfZeile('Termin absagen', zu)
const form = frage('Termin absagen?',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit dem Grund. Die Zeit wird wieder frei.')),
feld('grund', 'Grund', grund),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => {
if (!grund.value) {
setzeFehler(form, { grund: 'Bitte wähle einen Grund aus.' })
grund.focus()
return
}
fuehreAus(t, b, { aktion: 'absagen', grund: grund.value, nachricht: nachricht.value }, 'Termin abgesagt.', form, ok)
})
return form
}
function frageNichtErschienen(t, b, zu) {
const { ok, zeile } = knopfZeile('Ja, nicht erschienen', zu)
const form = frage('Als „nicht erschienen“ markieren?',
h('p', null, 'Der Termin bleibt in der Liste und wird als „Nicht erschienen“ geführt. Der Gast bekommt keine E-Mail.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'nichterschienen' }, 'Als „nicht erschienen“ markiert.', form, ok))
return form
}
function frageAnonymisieren(t, b, zu) {
const { ok, zeile } = knopfZeile('Gastdaten löschen', zu)
const form = frage('Gastdaten löschen?',
h('p', null, 'Name, E-Mail, Telefon und Nachricht werden gelöscht. Das lässt sich nicht rückgängig machen. Der Termin bleibt ohne Namen bestehen, die Links in alten E-Mails funktionieren danach nicht mehr.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'anonymisieren' }, 'Gastdaten gelöscht.', form, ok))
return form
}
function frageEntsperren(t, b, zu) {
const { ok, zeile } = knopfZeile('Sperre aufheben', zu)
const form = frage('Sperre aufheben?',
h('p', null, 'Die Zeit ist danach wieder buchbar.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'entsperren' }, 'Sperre aufgehoben.', form, ok))
return form
}
function frageVerschieben(t, b, zu) {
const { ctx } = t
const datum = h('input', { type: 'date', value: b.datum, min: heuteBerlin() })
const zeiten = zeitWahl(ctx, () => ({ ohne: b.id, tag: datum.value }))
const { ok, zeile } = knopfZeile('Verschieben', zu)
const form = frage('Termin verschieben',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit der neuen Zeit. Es gelten keine Fristen und kein Vorlauf.')),
feld('tag', 'Neuer Tag', datum),
zeiten.el,
allgemeinerFehler(),
zeile)
datum.addEventListener('change', () => zeiten.laden())
zeiten.laden()
form.addEventListener('submit', () => {
if (!zeiten.wert()) {
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
return
}
fuehreAus(t, b, { aktion: 'verschieben', tag: datum.value, zeit: zeiten.wert() }, `Termin verschoben auf ${datumKurz(datum.value)}, ${zeiten.wert()} Uhr.`, form, ok)
})
return form
}
+170
View File
@@ -0,0 +1,170 @@
/* Terminplaner im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten).
Alle Module der Ansicht (termine.js, termine-tab-*.js, termine-aktionen.js, termine-formulare.js) bauen die Seite nur hiermit:
Kundentext kommt ausschliesslich ueber textContent in die Seite (h), nie ueber innerHTML. */
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
export function h(tag, attrs, ...kinder) {
const el = document.createElement(tag)
for (const [name, wert] of Object.entries(attrs || {})) {
if (wert === undefined || wert === null || wert === false) continue
if (name === 'class') el.className = wert
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
else el.setAttribute(name, wert === true ? '' : wert)
}
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
return el
}
export async function request(path, { method = 'GET', body } = {}) {
let response
try {
response = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
} catch {
// Netzwerkfehler (offline, Server nicht erreichbar): deutscher Text statt „Failed to fetch“; status 0 = nie eine Antwort bekommen
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
throw err
}
return data
}
/** Adresse des CSV-Exports (die einzige Adresse, die die Ansicht als Link setzt). */
export const exportUrl = (projectId) => `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`
let idZaehler = 0
const neueId = () => `tm-f${++idZaehler}`
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
export function feld(name, label, steuer, hinweis) {
const id = steuer.id || (steuer.id = neueId())
steuer.dataset.feld = name
return h('div', { class: 'tm-feld' },
h('label', { for: id }, label),
steuer,
hinweis ? h('p', { class: 'tm-hinweis' }, hinweis) : null,
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Ankreuzfeld (Wert steuert der Aufrufer: input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
export function hakenFeld(name, steuer, text, hinweis) {
steuer.dataset.feld = name
return h('div', { class: 'tm-feld tm-feld-haken' },
h('label', { class: 'tm-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'tm-hinweis' }, hinweis) : null)),
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */
export const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
export function setzeFehler(form, fehler) {
for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
const rest = []
for (const [name, text] of Object.entries(fehler || {})) {
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
} else {
rest.push(text)
}
}
const allgemein = form.querySelector('[data-fehler="_"]')
if (allgemein) allgemein.textContent = rest.join(' ')
}
/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein). */
export function zeigeFehler(form, err) {
const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
setzeFehler(form, fehler)
form.querySelector('[aria-invalid]')?.focus()
}
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
export function frage(titel, ...inhalt) {
return h('form', { class: 'tm-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
}
/** Knopfzeile einer Rueckfrage: Bestaetigen (schickt das Formular ab) und Abbrechen. */
export function knopfZeile(bestaetigen, zu) {
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, bestaetigen)
return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
}
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel tm-abschnitt' },
h('h3', null, titel),
hinweis ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, hinweis) : null,
...inhalt)
/** Felder einer abgeschalteten Funktion: gesperrt (fieldset disabled sperrt alle Felder darin), sonst unveraendert. */
export const gesperrt = (nurLesen, felder) => (nurLesen ? h('fieldset', { class: 'tm-gruppe-felder', disabled: true }, felder) : felder)
/**
* Klappt unter einem Knopf einen Editor auf und zu (Bearbeiten, „+ Neu“). baue(schliessen) liefert den Editor (Element), der in box
* gesetzt wird. Fokus-Regel fuer alle Editoren: beim Oeffnen ins erste Feld, beim Schliessen ueber „Abbrechen“/„Schliessen“ (schliessen())
* zurueck auf den Knopf, beim erneuten Klick auf den Knopf bleibt er dort.
*/
export function editorUmschalter({ knopf, box, baue }) {
const zu = () => { box.replaceChildren(); knopf.setAttribute('aria-expanded', 'false') }
knopf.addEventListener('click', () => {
if (box.firstChild) { zu(); return }
knopf.setAttribute('aria-expanded', 'true')
const editor = baue(() => { zu(); knopf.focus() })
box.replaceChildren(editor)
editor.querySelector('input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')?.focus()
})
}
/**
* Zeile einer Liste mit Editor darunter (Leistung, Person/Platz, Abwesenheit, Sonderoeffnung): Kurzfassung (haupt) mit Pille, Knoepfe
* (vorKnopf, z. B. Pfeile) und der Knopf „Bearbeiten“ (bei abgeschalteter Funktion „Ansehen“), der den Editor unter der Zeile oeffnet.
* attrs: Attribute der Zeile (data-id / data-tag, so findet der Reiter sie nach dem Neuzeichnen), label: Name fuer den Screenreader.
*/
export function editorZeile({ attrs, haupt, pille = null, vorKnopf = [], nurLesen, label, baue }) {
const box = h('div', { class: 'tm-editor-box' })
const knopf = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${label}`,
}, nurLesen ? 'Ansehen' : 'Bearbeiten')
editorUmschalter({ knopf, box, baue })
return h('li', { class: 'tm-eintrag', ...attrs },
h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' }, haupt), pille),
h('div', { class: 'tm-aktionen' }, vorKnopf, knopf),
box)
}
/**
* „+ Neu“-Knopf mit Editor darunter. voll: die Obergrenze der Liste ist erreicht (Knopf gesperrt, grenzText erklaert es).
* => { leiste, box }: leiste (Knopf und Hinweis; null bei abgeschalteter Funktion) und box (Platz fuer den Editor) setzt der Aufrufer in den Reiter.
*/
export function neuerEintrag({ nurLesen, text, werkzeug, primaer = false, voll = false, grenzText = '', baue }) {
const box = h('div', { class: 'tm-formular-box' })
if (nurLesen) return { leiste: null, box }
const knopf = h('button', { type: 'button', class: primaer ? 'kb-btn-primary' : 'kb-btn-outline', 'data-werkzeug': werkzeug, 'aria-expanded': 'false', disabled: voll }, text)
editorUmschalter({ knopf, box, baue })
return { leiste: h('div', { class: 'tm-werkzeug-zeile' }, knopf, voll ? h('p', { class: 'tm-hinweis' }, grenzText) : null), box }
}
/** Link zum CSV-Export der Termine. */
export const exportLink = (projectId, text) => h('a', { href: exportUrl(projectId), download: 'termine.csv' }, text)
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
export const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel tm-info', role: 'alert' },
h('p', null, text),
nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
+170
View File
@@ -0,0 +1,170 @@
/* Terminplaner im Kundenbereich: Formulare des Reiters "Termine" – freie Uhrzeiten waehlen, Termin von Hand eintragen, Zeit sperren.
Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }.
erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */
import { GESUNDHEIT_HINWEIS, MAX, heuteBerlin, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js'
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
export function zeitWahl(ctx, query, { onWahl } = {}) {
let gewaehlt = ''
let nr = 0
const status = h('p', { class: 'tm-hinweis', 'aria-live': 'polite' })
const knoepfe = h('div', { class: 'tm-zeiten', role: 'group', 'aria-label': 'Freie Uhrzeiten' })
const fehler = h('p', { class: 'tm-fehler', 'data-fehler': 'zeit', role: 'alert' })
const el = h('div', { class: 'tm-feld', 'data-feld': 'zeit' }, h('span', { class: 'tm-label' }, 'Uhrzeit'), status, knoepfe, fehler)
async function laden() {
const mein = ++nr
gewaehlt = ''
knoepfe.replaceChildren()
fehler.textContent = ''
const q = query()
if (!q || !istTag(q.tag)) { status.textContent = 'Wähle zuerst einen Tag.'; return }
status.textContent = 'Freie Zeiten werden geladen …'
try {
const { zeiten } = await ctx.api.get('/zeiten', q)
if (mein !== nr) return
status.textContent = zeiten.length ? 'Wähle eine Uhrzeit:' : 'An diesem Tag ist nichts frei.'
knoepfe.replaceChildren(...zeiten.map((z) => h('button', {
type: 'button', class: 'tm-chip tm-zeit-knopf', 'aria-pressed': 'false', 'data-zeit': z,
onclick: (ev) => {
gewaehlt = z
for (const k of knoepfe.children) k.setAttribute('aria-pressed', String(k === ev.currentTarget))
fehler.textContent = ''
onWahl?.(z)
},
}, z)))
} catch (err) {
if (mein === nr) status.textContent = err.message
}
}
return { el, laden, wert: () => gewaehlt }
}
export function formularTermin(ctx, hooks) {
const e = ctx.daten.einstellung
const aktiveRessourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const leistungen = (e.leistungen || []).filter((l) => l.aktiv && (l.ressourcen || []).some((id) => aktiveRessourcen.some((r) => r.id === id)))
if (!leistungen.length) {
return h('div', { class: 'kb-panel tm-formular' }, h('p', null, 'Lege zuerst im Reiter „Leistungen“ eine Leistung an, die du buchen kannst.'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ctx.zumReiter('leistungen') }, 'Zu den Leistungen'))
}
const wort = ressourcenWort(aktiveRessourcen)
const leistung = h('select', null, leistungen.map((l) => h('option', { value: l.id }, `${l.name} (${l.dauer} Min.)`)))
const person = h('select')
const tag = h('input', { type: 'date', value: hooks.startTag, min: heuteBerlin() })
const name = h('input', { type: 'text', maxlength: String(MAX.name), autocomplete: 'off' })
const email = h('input', { type: 'email', maxlength: '254', autocomplete: 'off' })
const telefon = h('input', { type: 'tel', maxlength: String(MAX.telefon), autocomplete: 'off' })
const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) })
const senden = h('input', { type: 'checkbox', id: 'tm-senden' })
const sendenHinweis = h('span', { class: 'tm-hinweis' })
const zeiten = zeitWahl(ctx, () => ({ leistung: leistung.value, ressource: person.value, tag: tag.value }))
function ressourcenOptionen() {
const l = leistungen.find((x) => x.id === leistung.value)
const infrage = aktiveRessourcen.filter((r) => (l.ressourcen || []).includes(r.id))
person.replaceChildren(...(infrage.length > 1 ? [h('option', { value: 'egal' }, 'Egal (erste freie)')] : []), ...infrage.map((r) => h('option', { value: r.id }, r.name)))
person.disabled = infrage.length === 1
}
ressourcenOptionen()
leistung.addEventListener('change', () => { ressourcenOptionen(); zeiten.laden() })
person.addEventListener('change', () => zeiten.laden())
tag.addEventListener('change', () => zeiten.laden())
const mailAn = () => { senden.disabled = email.value.trim() === ''; if (senden.disabled) senden.checked = false; sendenHinweis.textContent = senden.disabled ? ' (nur mit E-Mail-Adresse)' : '' }
email.addEventListener('input', mailAn)
mailAn()
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Termin eintragen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Termin eintragen' },
h('h3', null, 'Termin eintragen'),
h('p', { class: 'tm-hinweis' }, 'Für Anrufe und Laufkundschaft. Der Termin ist sofort bestätigt. Fristen und Vorlauf gelten dafür nicht.'),
h('div', { class: 'tm-raster' },
feld('leistung', 'Leistung', leistung),
feld('ressource', wort, person),
feld('tag', 'Tag', tag)),
zeiten.el,
h('div', { class: 'tm-raster' },
feld('name', 'Name des Gastes', name),
feld('email', 'E-Mail (optional)', email),
feld('telefon', 'Telefon (optional)', telefon)),
feld('nachricht', 'Notiz (optional)', nachricht, GESUNDHEIT_HINWEIS),
h('label', { class: 'tm-haken', for: 'tm-senden' }, senden, h('span', null, 'Bestätigung an den Gast senden', sendenHinweis)),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: hooks.abbrechen }, 'Abbrechen')))
zeiten.laden()
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
setzeFehler(form, {})
if (!zeiten.wert()) {
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
return
}
ok.disabled = true
try {
const gewaehlt = tag.value
await ctx.api.send('POST', '/buchungen', {
art: 'manuell', leistung: leistung.value, ressource: person.value, tag: gewaehlt, zeit: zeiten.wert(), name: name.value,
email: email.value, telefon: telefon.value, nachricht: nachricht.value, bestaetigungSenden: senden.checked,
})
await hooks.erfolg(gewaehlt, 'Termin eingetragen.')
} catch (err) {
ok.disabled = false
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') {
zeigeFehler(form, err)
if (err.code === 'SLOT_VERGEBEN') zeiten.laden()
} else {
ctx.meldung(err.message, 'fehler')
}
}
})
return form
}
export function formularSperre(ctx, hooks) {
const e = ctx.daten.einstellung
const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const wort = ressourcenWort(ressourcen)
const person = h('select', null, ressourcen.map((r) => h('option', { value: r.id }, r.name)))
const tag = h('input', { type: 'date', value: hooks.startTag, min: heuteBerlin() })
const von = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '12:00', autocomplete: 'off' })
const bis = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '13:00', autocomplete: 'off' })
const titel = h('input', { type: 'text', maxlength: String(MAX.titel), placeholder: 'z. B. Mittagspause', autocomplete: 'off' })
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Zeit sperren')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Zeit sperren' },
h('h3', null, 'Zeit sperren'),
h('p', { class: 'tm-hinweis' }, 'In dieser Zeit kann niemand buchen. Eine Sperre kann höchstens 8 Stunden 20 Minuten (500 Minuten) dauern. Für ganze Tage oder Urlaub nutze die Abwesenheiten im Reiter „Team & Plätze“.'),
h('div', { class: 'tm-raster' },
feld('ressource', wort, person),
feld('tag', 'Tag', tag)),
h('div', { class: 'tm-raster' },
feld('von', 'Von (Uhrzeit)', von, 'In 5-Minuten-Schritten, z. B. 12:00.'),
feld('bis', 'Bis (Uhrzeit)', bis, 'Auch 24:00 ist möglich.')),
feld('titel', 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: hooks.abbrechen }, 'Abbrechen')))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
setzeFehler(form, {})
const vonZeit = zeitNormal(von.value)
const bisZeit = zeitNormal(bis.value, { ende: true })
const fehler = {}
if (!vonZeit) fehler.von = 'Bitte gib die Uhrzeit so an: 12:00.'
if (!bisZeit) fehler.bis = 'Bitte gib die Uhrzeit so an: 13:00.'
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return
}
ok.disabled = true
try {
const gewaehlt = tag.value
await ctx.api.send('POST', '/buchungen', { art: 'sperre', ressource: person.value, tag: gewaehlt, von: vonZeit, bis: bisZeit, titel: titel.value })
await hooks.erfolg(gewaehlt, 'Zeit gesperrt.')
} catch (err) {
ok.disabled = false
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') zeigeFehler(form, err)
else ctx.meldung(err.message, 'fehler')
}
})
return form
}
+19
View File
@@ -0,0 +1,19 @@
/* Terminplaner im Kundenbereich: Texte, die GAESTE lesen, und die Anrede der Gaeste. Dieses Modul ist das einzige der Ansicht, in dem die
Sie-Form stehen darf (test/termineView.test.js prueft, dass alle anderen Portaltexte in der Du-Form sind). */
/** Anrede der Gaeste (anrede der Einstellung): Wert und Beschriftung. */
export const ANREDE_WAHL = [['sie', 'Sie (förmlich)'], ['du', 'Du (persönlich)']]
/**
* Mustertext fuer die Stornobedingungen, Zahlung vor Ort: derselbe Wortlaut wie in den Vorlagen des Servers
* (stornoText in server/services/termine/vorlagen.js, Stornobedingungen Variante 1) mit der Frist in Stunden.
* Ein Muster, kein fertiger Rechtstext: der Betrieb muss ihn pruefen und an sein Angebot anpassen. Eine unbrauchbare Frist (kleiner als 1,
* keine ganze Zahl) ergibt die Vorgabe 24 Stunden.
*/
export function stornoMuster(stunden) {
const frist = Number.isInteger(stunden) && stunden >= 1 ? stunden : 24
return `Sie können Ihren Termin bis ${frist} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ 'Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. '
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
}
+198
View File
@@ -0,0 +1,198 @@
/* Terminplaner im Kundenbereich: Speichern der Einstellungen – gemeinsam fuer die Reiter Leistungen, Team & Plaetze und Einstellungen.
Jede Aenderung ist ein Speichern: PATCH /api/termine/einstellungen schickt die GANZE Einstellung mit der Version, auf der sie beruht
(server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des
aktuellen Standes (ctx.daten.einstellung), laesst aendere(entwurf) sie aendern und schickt sie ab.
- Erfolg: die zurueckgegebene Einstellung und Version gelten (ctx.daten), die Hinweisleiste der Huelle wird neu gezeichnet und die Daten
(Kalender-Links) werden neu geladen; Rueckgabe true.
- 400: Fehler stehen am Feld (data-fehler = Pfad wie 'leistungen.2.dauer' oder 'ressourcen.1.plan.3'); Pfade ohne Platz im container
erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
- 409 EINSTELLUNG_VERALTET (jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Reiter. Rueckgabe false.
- alles andere: Meldung ueber den Reitern.
- Es laeuft immer nur ein Speichern je Reiter (PATCH und das Nachladen danach): solange es laeuft, ist der Reiter aria-busy, alle seine
Knoepfe sind gesperrt, und ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit
einer veralteten Version gesendet zu werden. Dasselbe gilt fuer das Erzeugen eines Kalender-Links (exklusiv), das die Version ebenfalls aendert.
Der Reiter ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Die Tokens der Kalender-Links gehoeren dem Server und stehen nie in
der Einstellung des Browsers. */
import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js'
/** Wie viele Speichern gerade auf einem Tabpanel laufen (aria-busy bleibt, bis das letzte fertig ist). */
const belegt = new WeakMap()
export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'
const WORT = { leistungen: 'Leistung', abwesenheiten: 'Abwesenheit' }
/** „Leistung „Färben“: Text“ aus Pfad und Text eines Fehlers; Eintraege finden sich per Index oder per ID in den Einstellungen (quellen). */
export function beschrifteFehler(pfad, text, quellen) {
if (pfad.startsWith('betrieb.')) return `${text} Das änderst du im Reiter „Einstellungen“.`
const m = /^(leistungen|ressourcen|abwesenheiten)\.([^.]+)/.exec(pfad)
if (!m) return text
for (const quelle of quellen) {
const liste = Array.isArray(quelle?.[m[1]]) ? quelle[m[1]] : []
const eintrag = /^\d+$/.test(m[2]) ? liste[Number(m[2])] : liste.find((e) => e?.id === m[2])
const name = eintrag?.name || eintrag?.titel
if (!name) continue
const wort = m[1] === 'ressourcen' ? (eintrag.typ === 'platz' ? 'Platz' : 'Person') : WORT[m[1]]
return `${wort} „${name}“: ${text}`
}
return text
}
/**
* Speicher eines Reiters. wurzel = das Element des Reiters (verschwindet beim Wechsel des Reiters: danach fasst nichts mehr die Seite an),
* neuZeichnen() zeichnet den Reiter aus ctx.daten neu (nach „Neu laden“).
* => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Reiter setzt es an den Anfang), nochHier() sagt, ob der
* Reiter noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/
export function neuerSpeicher(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' }, '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 Reiter und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
// Das Tabpanel ist fuer alle Reiter dasselbe Element und wechselt der Nutzer waehrend des Speicherns den Reiter, ist wurzel beim Freigeben
// schon ausgehaengt: darum merkt sich die Sperre das Panel, das sie gesetzt hat, und aria-busy faellt erst, wenn kein Speichern mehr darauf laeuft.
let laeuft = false
function sperre(an, gesperrt) {
if (an) {
const tafel = wurzel.closest('[role="tabpanel"]') || wurzel
belegt.set(tafel, (belegt.get(tafel) || 0) + 1)
tafel.setAttribute('aria-busy', 'true')
const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')]
const fokus = document.activeElement
for (const knopf of knoepfe) knopf.disabled = true
return { tafel, knoepfe, fokus }
}
const rest = (belegt.get(gesperrt.tafel) || 1) - 1
belegt.set(gesperrt.tafel, rest)
if (rest === 0) gesperrt.tafel.removeAttribute('aria-busy')
for (const knopf of gesperrt.knoepfe) knopf.disabled = false
// Der Fokus war auf einem gesperrten Knopf und ist weg: zurueck dorthin; hat der Reiter ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so
const jetzt = document.activeElement
if (gesperrt.knoepfe.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus()
return null
}
/** arbeit() exklusiv ausfuehren; laeuft schon etwas, passiert nichts (Rueckgabe undefined). */
async function exklusiv(arbeit) {
if (laeuft) return undefined
laeuft = true
const gesperrt = sperre(true)
try {
return await arbeit()
} finally {
laeuft = false
sperre(false, gesperrt)
}
}
async function speichere(aendere, { container }) {
const ergebnis = await exklusiv(async () => {
setzeFehler(container, {})
konflikt.hidden = true
const bisher = ctx.daten.einstellung
const entwurf = structuredClone(bisher)
if (aendere(entwurf) === false) return false // der Reiter hat selbst Fehler am Feld gezeigt
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
ctx.hinweise()
try {
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
} catch { /* dann zeigen neue Eintraege ihren Kalender-Link erst nach dem naechsten Laden */ }
return true
} catch (err) {
if (!nochHier()) return false
if (err.code === 'EINSTELLUNG_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])
} else {
ctx.meldung(err.message, 'fehler')
}
return false
}
})
return ergebnis === true
}
return { konflikt, speichere, exklusiv, nochHier }
}
/**
* Stelle des Eintrags mit dieser ID in der Liste des Entwurfs. Editoren merken sich keine Stelle, sondern die ID und suchen sie beim
* Absenden (die Liste kann sich inzwischen anders sortiert haben). 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: Reiter neu zeichnen, 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, text, selektor) {
t.zeichne()
t.ctx.meldung(text)
t.wurzel.querySelector(selektor)?.focus()
}
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
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)
container.querySelector('[aria-invalid="true"]')?.focus()
}
/**
* „Entfernen“-Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung, Platz fuer den Fehler des Servers am Eintrag
* (fehlerPfad, z. B. 'leistungen.s_abcd1234': „Fuer diese Leistung gibt es noch 2 kuenftige Termine …“), Knoepfe. aendere(entwurf) entfernt
* den Eintrag, nachher() laeuft nach dem erfolgreichen Speichern (der Reiter 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, { 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' }, 'Entfernen')
knopf.addEventListener('click', () => {
const { zeile } = knopfZeile('Ja, entfernen', () => { box.replaceChildren(); knopf.focus() })
const rueckfrage = frage(titel,
h('p', null, text),
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 }
}
+320
View File
@@ -0,0 +1,320 @@
/* Terminplaner im Kundenbereich: Reiter "Einstellungen" (Spezifikation Abschnitt 9, Punkt 4) – Betriebsangaben fuer Gaeste, Anrede und Farbe,
Regeln, Angaben von Gaesten (Zusatzfelder), Erinnerung, Stornobedingungen (mit Muster), Rechtliches, Bezahlung, Aufbewahrung,
darunter der Einbau-Code fuer die Website, Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Alles ist EIN Formular mit einem Speichern (termine-speichern.js). Jeder Abschnitt ist eine Funktion ctx => { el, lese(entwurf, fehler) }:
el die Karte, lese uebertraegt die Felder in den Entwurf und meldet Eingaben, die kein Feld verstehen kann, in fehler (Pfad ⇒ Text).
Pruefen und Begrenzen bleibt Sache des Servers. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, kein Speichern, Export bleibt. */
import {
ERINNERUNG_STUNDEN, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, MAX, RASTER_MIN, ZAHLARTEN, aktivSeitText, fehltText, ganzzahl, optionenAusText, optionenText, zusatzSchluessel,
} from './termine-view.js'
import { ANREDE_WAHL, stornoMuster } from './termine-gaestetexte.js'
import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { nachSpeichern, neuerSpeicher } from './termine-speichern.js'
// Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling
const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.'
const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. '
+ 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. '
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
+ 'Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.'
const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text))
const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => {
const input = h('input', { type: 'number', inputmode: 'numeric', min, max, step: schritt, value: wert })
return { input, el: feld(name, label, input, hinweis) }
}
const liesZahl = (fehler, name, input) => {
const wert = ganzzahl(input.value)
if (wert === null) fehler[name] = 'Bitte gib eine ganze Zahl an.'
return wert
}
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, zeichne, wurzel }
function zeichne() {
const e = ctx.daten.einstellung
const regelnAbschnitt = regeln(e)
const abschnitte = [betrieb(ctx, e), darstellung(e), regelnAbschnitt, gastFelder(ctx, e), erinnerung(e), storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e), aufbewahrung(e)]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Einstellungen speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Einstellungen' },
gesperrt(ctx.nurLesen, abschnitte.map((a) => a.el)),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Einstellungen gespeichert.', 'form[aria-label="Einstellungen"] button[type="submit"]')
})
// einbau() liefert null (abgeschaltet oder ohne Code): replaceChildren wuerde sonst den Text "null" zeigen
wurzel.replaceChildren(...[sp.konflikt, form, einbau(ctx), exportUndStatus(ctx)].filter(Boolean))
}
zeichne()
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
}
const fehlt = ctx.daten.fehlt || []
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
h('div', { class: 'tm-raster' },
feld('anrede', 'Wie sprichst du deine Gäste an?', anrede),
feld('farbe', 'Akzentfarbe', farbe, 'Für Knöpfe und Kopfzeile der Buchungsseite und der E-Mails.')))
return { el, lese(entwurf) { entwurf.anrede = anrede.value; entwurf.farbe = farbe.value } }
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
const horizont = zahlenFeld('regeln.horizontTage', 'Buchungszeitraum (Tage)', r.horizontTage, { min: 1, max: 365, hinweis: 'Wie weit im Voraus darf gebucht werden? 1 bis 365 Tage.' })
const absage = zahlenFeld('regeln.absageBisStd', 'Selbst absagen bis (Stunden vorher)', r.absageBisStd, { min: 0, max: 336, hinweis: 'Bis dahin dürfen Gäste ihren Termin selbst absagen oder verschieben. 0 bis 336.' })
const offene = zahlenFeld('regeln.maxOffeneJeEmail', 'Offene Termine je E-Mail-Adresse', r.maxOffeneJeEmail, { min: 1, max: 10, hinweis: 'So viele Termine darf eine Adresse gleichzeitig offen haben (1 bis 10). Das schützt vor Spaßbuchungen.' })
const wahlbar = h('input', { type: 'checkbox', checked: r.personWaehlbar !== false })
const el = abschnitt('Regeln fürs Buchen', null,
h('div', { class: 'tm-raster' }, feld('regeln.raster', 'Startzeiten alle', raster, 'In diesen Schritten bieten wir Uhrzeiten an.'), vorlauf.el, horizont.el, absage.el, offene.el),
hakenFeld('regeln.personWaehlbar', wahlbar, 'Gäste dürfen die Person oder den Platz selbst wählen', 'Sonst vergeben wir automatisch eine freie Person oder einen freien Platz.'))
return {
el,
lese(entwurf, fehler) {
entwurf.regeln = {
...entwurf.regeln,
raster: Number(raster.value), vorlaufMin: liesZahl(fehler, 'regeln.vorlaufMin', vorlauf.input), horizontTage: liesZahl(fehler, 'regeln.horizontTage', horizont.input),
absageBisStd: liesZahl(fehler, 'regeln.absageBisStd', absage.input), maxOffeneJeEmail: liesZahl(fehler, 'regeln.maxOffeneJeEmail', offene.input), personWaehlbar: wahlbar.checked,
}
},
absage: absage.input,
}
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
// Zusatzfelder: der Zustand steht in zeilen, die Liste wird daraus gezeichnet (Namen der Fehlerfelder = Stelle in der Liste)
const zeilen = (g.zusatz || []).map((z) => ({ key: z.key, label: z.label, typ: z.typ, optionen: optionenText(z.optionen), pflicht: z.pflicht === true }))
const liste = h('div', { class: 'tm-zusatz' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zusatz-neu',
onclick: () => { zeilen.push({ key: '', label: '', typ: 'text', optionen: '', pflicht: false }); zeichneListe(); liste.lastElementChild?.querySelector('input')?.focus() },
}, '+ Zusatzfeld')
const platz = h('p', { class: 'tm-fehler', 'data-fehler': 'gastFelder.zusatz', role: 'alert' })
function zeichneListe() {
if (neuKnopf) neuKnopf.disabled = zeilen.length >= LIMITS.zusatzfelder
liste.replaceChildren(...zeilen.map((z, i) => {
const p = `gastFelder.zusatz.${i}`
const label = h('input', { type: 'text', maxlength: MAX.zusatzLabel, autocomplete: 'off', value: z.label })
const typ = h('select', null, wahl([['text', 'Freier Text'], ['auswahl', 'Auswahl aus Liste']], z.typ))
const optionen = h('textarea', { rows: '3', maxlength: String(MAX.option * LIMITS.optionen + LIMITS.optionen) }, z.optionen)
const pflicht = h('input', { type: 'checkbox', checked: z.pflicht })
const optionenFeld = feld(`${p}.optionen`, 'Auswahlmöglichkeiten', optionen, `Eine je Zeile, höchstens ${LIMITS.optionen}.`)
optionenFeld.hidden = z.typ !== 'auswahl'
label.addEventListener('input', () => { z.label = label.value })
optionen.addEventListener('input', () => { z.optionen = optionen.value })
pflicht.addEventListener('change', () => { z.pflicht = pflicht.checked })
typ.addEventListener('change', () => { z.typ = typ.value; optionenFeld.hidden = z.typ !== 'auswahl' })
return h('div', { class: 'tm-zusatz-zeile', 'data-zusatz': String(i) },
h('div', { class: 'tm-raster' }, feld(`${p}.label`, 'Beschriftung', label), feld(`${p}.typ`, 'Art', typ)),
optionenFeld,
hakenFeld(`${p}.pflicht`, pflicht, 'Pflichtfeld'),
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'zusatz-weg', 'aria-label': `Zusatzfeld entfernen: ${z.label || i + 1}`,
onclick: () => { zeilen.splice(i, 1); zeichneListe(); neuKnopf?.focus() },
}, 'Entfernen'))
}))
}
zeichneListe()
const el = abschnitt('Angaben von Gästen',
'Name und E-Mail brauchen wir immer. Alles Weitere entscheidest du.',
h('div', { class: 'tm-raster' },
feld('gastFelder.telefon', 'Telefonnummer des Gastes', telefon),
feld('gastFelder.nachricht', 'Nachricht des Gastes', nachricht, `Über dem Feld steht für deine Gäste: „${GESUNDHEIT_HINWEIS}“`)),
h('h4', null, 'Zusatzfelder'),
h('p', { class: 'tm-hinweis' }, `Eigene Fragen an deine Gäste, zum Beispiel nach der Haarlänge oder einer Wunschfarbe. Höchstens ${LIMITS.zusatzfelder}. Keine Gesundheitsangaben abfragen.`),
liste,
platz,
neuKnopf)
return {
el,
lese(entwurf) {
const belegt = zeilen.map((z) => z.key).filter(Boolean)
const zusatz = zeilen.map((z) => {
const key = z.key || zusatzSchluessel(z.label, belegt)
belegt.push(key)
return { key, label: z.label, typ: z.typ, optionen: z.typ === 'auswahl' ? optionenAusText(z.optionen) : [], pflicht: z.pflicht }
})
entwurf.gastFelder = { ...entwurf.gastFelder, telefon: telefon.value, nachricht: nachricht.value, zusatz }
},
}
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
const el = abschnitt('Erinnerung', null,
hakenFeld('erinnerung.anbieten', anbieten, 'Gästen eine E-Mail-Erinnerung anbieten', 'Gäste entscheiden beim Buchen selbst (der Haken ist nie vorangekreuzt).'),
feld('erinnerung.stundenVorher', 'Die Erinnerung kommt', stunden))
return { el, lese(entwurf) { entwurf.erinnerung = { ...entwurf.erinnerung, anbieten: anbieten.checked, stundenVorher: Number(stunden.value) } } }
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
const muster = h('blockquote', { class: 'tm-muster-text' }, stornoMuster(e.regeln?.absageBisStd))
fristFeld.addEventListener('input', () => { muster.textContent = stornoMuster(ganzzahl(fristFeld.value)) })
const einfuegen = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen',
onclick: () => { textfeld.value = muster.textContent; textfeld.focus() },
}, 'Muster in das Textfeld übernehmen')
const el = abschnitt('Stornobedingungen',
'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.',
feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'),
h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' },
h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'),
muster,
einfuegen,
einfuegen ? h('p', { class: 'tm-hinweis' }, 'Das ersetzt den Text im Feld. Gespeichert wird erst mit „Einstellungen speichern“.') : null))
return { el, lese(entwurf) { entwurf.storno = { ...entwurf.storno, text: textfeld.value } } }
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
const el = abschnitt('Rechtliches', null,
h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, WIDERRUF_HINWEIS)),
feld('recht.gaesteSind', 'Deine Gäste sind', gaeste, 'Bei „Unternehmen“ gibt es kein gesetzliches Widerrufsrecht.'),
hakenFeld('recht.freizeit', freizeit, 'Meine Leistungen sind Freizeitangebote mit festem Termin', FREIZEIT_HINWEIS))
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
h('div', { class: 'tm-optionen', role: 'radiogroup', 'aria-label': 'Wo wird bezahlt?' },
h('label', { class: 'tm-haken' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'vorort', checked: true }), h('span', null, 'Vor Ort bezahlen')),
h('label', { class: 'tm-haken tm-aus' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'online', disabled: true }), h('span', null, 'Online bezahlen ', h('span', { class: 'tm-pille tm-pille-off' }, 'kommt bald')))),
h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'zahlung.vorOrtArten', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Zahlarten vor Ort'),
arten.map(({ name, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, name))),
h('p', { class: 'tm-fehler', 'data-fehler': 'zahlung.vorOrtArten', role: 'alert' })))
return { el, lese(entwurf) { entwurf.zahlung = { modus: 'vorort', vorOrtArten: arten.filter(({ input }) => input.checked).map(({ key }) => key) } } }
}
/* ------------------------------------------------------------ Aufbewahrung --- */
function aufbewahrung(e) {
const tage = zahlenFeld('aufbewahrungTage', 'Aufbewahrung (Tage)', e.aufbewahrungTage, {
min: 14, max: 365,
hinweis: 'So lange nach dem Termin bleiben erledigte Termine und Termine, zu denen Gäste nicht erschienen sind, mit allen Angaben gespeichert. Danach löschen wir sie. Abgesagte Termine löschen wir nach 30 Tagen. 14 bis 365.',
})
return { el: abschnitt('Aufbewahrung', null, tage.el), lese(entwurf, fehler) { entwurf.aufbewahrungTage = liesZahl(fehler, 'aufbewahrungTage', tage.input) } }
}
/* ------------------------------------------------------ Einbau-Code (Website) --- */
/** Einbau-Code aus GET /einstellungen (einbau.snippet, vom Server gebaut, nur lesbar) mit Kopierknopf; gleiches Muster wie der Kalender-Link. */
function einbau(ctx) {
const snippet = ctx.daten.einbau?.snippet
if (typeof snippet !== 'string' || !snippet || ctx.nurLesen) return null
const eingabe = h('textarea', { readonly: true, rows: '5', class: 'tm-code-feld', spellcheck: 'false', wrap: 'off', 'aria-label': 'Einbau-Code für deine Website' })
eingabe.value = snippet
eingabe.addEventListener('focus', () => eingabe.select())
const status = h('p', { class: 'tm-hinweis', role: 'status' })
async function kopiere() {
try {
await navigator.clipboard.writeText(eingabe.value)
status.textContent = 'Der Code ist kopiert.'
} catch {
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Code bleibt sichtbar und markiert zum Kopieren von Hand
eingabe.focus()
eingabe.select()
status.textContent = 'Das Kopieren hat nicht geklappt. Der Code ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
}
}
return abschnitt('Einbau auf deiner Website',
'Diesen Code fügst du auf deiner Website an der Stelle ein, an der Gäste Termine buchen sollen. Der Text darin bleibt stehen, solange die Buchung nicht geladen werden kann. '
+ 'Gebucht werden kann erst, wenn deine Angaben vollständig sind und die Adresse deiner Website bei deinem Projekt als Live-Adresse eingetragen ist.',
eingabe,
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'einbau-kopieren', onclick: kopiere }, 'Code kopieren')),
status)
}
/* ------------------------------------------------------------ Export, Status --- */
function exportUndStatus(ctx) {
const seit = aktivSeitText(ctx.daten.aktivSeit)
const abgeschaltet = ctx.daten.state === 'disabled'
return abschnitt('Export und Status', null,
h('p', null, exportLink(ctx.projectId, 'Alle Termine als CSV-Datei herunterladen'), ' (öffnet sich in Excel und Numbers).'),
h('p', { class: 'tm-status' },
h('span', { class: `tm-pille tm-pille-${abgeschaltet ? 'off' : 'ok'}` }, abgeschaltet ? 'Abgeschaltet' : 'Aktiv'),
seit ? ` ${seit}` : ''))
}
+238
View File
@@ -0,0 +1,238 @@
/* Terminplaner im Kundenbereich: Reiter "Leistungen" – Liste in der Reihenfolge, in der Gaeste sie sehen, Anlegen, Bearbeiten (Felder wie in
einstellungen.js: Name, Beschreibung, Dauer, Pause danach, Preis, Buchungsart, wer sie durchfuehrt, aktiv), Entfernen, Reihenfolge.
render(panel, ctx) ist der Einstieg (Registry REITER in termine.js). Jede Aktion ist ein Speichern der ganzen Einstellung
(termine-speichern.js); Pruefen und Begrenzen bleibt Sache des Servers, hier steht nur, was ein Feld ohne Antwort des Servers wissen kann.
Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder, aber gesperrt und ohne Knoepfe zum Aendern. */
import {
GESUNDHEIT_HINWEIS, LIMITS, MAX, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, ordneNachIds, preisAnzeige, verschiebeEintrag,
} from './termine-view.js'
import { allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } from './termine-speichern.js'
const BUCHUNGSART = [['sofort', 'Sofort buchbar'], ['anfrage', 'Nur als Anfrage (du nimmst jeden Termin an)']]
const PREISART = [['keiner', 'Ohne Preis'], ['fest', 'Festpreis'], ['ab', 'Preis ab']]
const option = (wert, text, gewaehlt) => h('option', { value: wert, selected: gewaehlt }, text)
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, sp, zeichne, wurzel }
// Reihenfolge (Ruling R51): die Pfeile aendern nur diese gemerkte Reihenfolge (IDs), gespeichert wird sie mit „Reihenfolge speichern“ in einem
// Speichern; null = wie gespeichert. Sie bleibt beim Neuzeichnen (z. B. nach dem Speichern einer Leistung) erhalten.
let ordnung = null
/** Anzeigereihenfolge (IDs): die gemerkte, auf den aktuellen Stand der Liste angewandt; ist sie die gespeicherte, gilt keine gemerkte mehr. */
function anzeigeIds() {
const liste = ctx.daten.einstellung.leistungen || []
const gespeichert = liste.map((l) => l.id)
const ids = ordnung ? ordneNachIds(liste, ordnung).map((l) => l.id) : gespeichert
ordnung = ids.join('|') === gespeichert.join('|') ? null : ids
return ids
}
function zeichne() {
const e = ctx.daten.einstellung
const leistungen = e.leistungen || []
const kopfFehler = h('div', null, allgemeinerFehler())
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Leistung anlegen', werkzeug: 'leistung-neu', primaer: true,
voll: leistungen.length >= LIMITS.leistungen, grenzText: `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`,
baue: (schliessen) => formular(t, { leistung: null, schliessen }),
})
const zeilen = new Map(leistungen.map((l) => [l.id, zeile(t, l, verschiebe)]))
const liste = h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' })
const status = h('span', { role: 'status' })
const speichern = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'reihenfolge-speichern' }, 'Reihenfolge speichern')
const verwerfen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'reihenfolge-verwerfen' }, 'Verwerfen')
const leiste = h('div', { class: 'tm-box tm-box-info tm-reihenfolge', hidden: true }, status, h('div', { class: 'tm-frage-knoepfe' }, speichern, verwerfen))
/** Zeilen in Anzeigereihenfolge bringen (vorhandene Zeilen werden nur verschoben: offene Editoren und ihre Eingaben bleiben), Pfeile und Leiste nachziehen. */
function aktualisiere() {
const ids = anzeigeIds()
liste.replaceChildren(...ids.map((id) => zeilen.get(id)))
ids.forEach((id, i) => {
const li = zeilen.get(id)
const hoch = li.querySelector('[data-aktion="hoch"]')
if (hoch) hoch.disabled = i === 0
const runter = li.querySelector('[data-aktion="runter"]')
if (runter) runter.disabled = i === ids.length - 1
})
leiste.hidden = !ordnung
}
function verschiebe(id, richtung, aktion) {
const ids = anzeigeIds()
ordnung = verschiebeEintrag(ids, ids.indexOf(id), richtung)
aktualisiere()
const stelle = anzeigeIds().indexOf(id) + 1
status.textContent = ordnung
? `„${leistungen.find((l) => l.id === id).name}“ steht jetzt an Stelle ${stelle} von ${ids.length}. Die neue Reihenfolge ist noch nicht gespeichert.`
: 'Die Reihenfolge ist wieder wie gespeichert.'
const li = zeilen.get(id)
;(li.querySelector(`[data-aktion="${aktion}"]:not(:disabled)`) || li.querySelector('[data-aktion="bearbeiten"]'))?.focus()
}
const fokusErsteZeile = () => liste.querySelector('li [data-aktion="bearbeiten"]')?.focus()
verwerfen.addEventListener('click', () => {
ordnung = null
aktualisiere()
fokusErsteZeile()
ctx.meldung('Die gemerkte Reihenfolge ist verworfen.')
})
speichern.addEventListener('click', async () => {
const gewuenscht = [...(ordnung || [])]
// die gemerkte Reihenfolge wirkt auf eine frische Kopie des gespeicherten Stands, nach IDs (nicht nach Stellen)
const ok = await sp.speichere((entwurf) => { entwurf.leistungen = ordneNachIds(entwurf.leistungen, gewuenscht) }, { container: kopfFehler })
if (!ok || !sp.nochHier()) return
ordnung = null
zeichne()
ctx.meldung('Reihenfolge gespeichert.')
fokusErsteZeile()
})
aktualisiere()
wurzel.replaceChildren(
sp.konflikt,
h('div', { class: 'tm-box tm-box-info', role: 'note' },
h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')),
neu.leiste,
neu.box,
leiste,
kopfFehler,
leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'))
}
zeichne()
}
/** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Pfeile fuer die Reihenfolge und der Knopf, der das Formular unter der Zeile oeffnet. */
function zeile(t, l, verschiebe) {
const { ctx } = t
const e = ctx.daten.einstellung
const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean)
const meta = [`${l.dauer} Min.`, l.pufferNach ? `${l.pufferNach} Min. Pause danach` : '', preisAnzeige(l), l.buchungsart === 'anfrage' ? 'Nur als Anfrage' : 'Sofort buchbar', namen.join(', ') || 'niemand zugeordnet']
const pfeil = (aktion, richtung, zeichen, text) => h('button', {
type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, onclick: () => verschiebe(l.id, richtung, aktion),
}, h('span', { 'aria-hidden': 'true' }, zeichen))
return editorZeile({
attrs: { 'data-id': l.id },
nurLesen: ctx.nurLesen,
label: l.name,
haupt: [
h('strong', { class: 'tm-name' }, l.name),
h('span', { class: 'tm-meta' }, meta.filter(Boolean).join(' · ')),
l.beschreibung ? h('span', { class: 'tm-meta' }, l.beschreibung) : null,
],
pille: h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv'),
vorKnopf: ctx.nurLesen ? [] : [pfeil('hoch', -1, '↑', 'Nach oben'), pfeil('runter', 1, '↓', 'Nach unten')],
baue: (schliessen) => formular(t, { leistung: l, schliessen }),
})
}
/**
* Formular einer neuen (leistung null) oder bestehenden Leistung. Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten
* Liste (leistungen.2.dauer); die Felder heissen danach. Beim Absenden wird die Leistung per ID gesucht, nicht per Stelle.
*/
function formular(t, { leistung, schliessen }) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const neu = leistung === null
const index = neu ? (e.leistungen || []).length : (e.leistungen || []).findIndex((x) => x.id === leistung.id)
const l = leistung || { id: eindeutigeId('s', (e.leistungen || []).map((x) => x.id)), name: '', beschreibung: '', dauer: 30, pufferNach: 0, preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', ressourcen: [], aktiv: true }
const p = `leistungen.${index}`
const name = h('input', { type: 'text', maxlength: MAX.leistungName, autocomplete: 'off', value: l.name })
const beschreibung = h('textarea', { rows: '2', maxlength: MAX.beschreibung }, l.beschreibung)
const dauer = h('input', { type: 'number', inputmode: 'numeric', min: '5', max: '480', step: '5', value: l.dauer })
const puffer = h('input', { type: 'number', inputmode: 'numeric', min: '0', max: '120', step: '5', value: l.pufferNach ?? 0 })
const preisArt = h('select', null, PREISART.map(([wert, text]) => option(wert, text, l.preisArt === wert)))
const preis = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', value: l.preisArt === 'keiner' ? '' : centZuEuro(l.preisCent) })
const preisSteuern = () => { preis.disabled = preisArt.value === 'keiner'; if (preis.disabled) preis.value = '' }
preisArt.addEventListener('change', preisSteuern)
preisSteuern()
const art = h('select', null, BUCHUNGSART.map(([wert, text]) => option(wert, text, l.buchungsart === wert)))
const aktiv = h('input', { type: 'checkbox', checked: l.aktiv })
const boxen = (e.ressourcen || []).map((r) => ({ r, input: h('input', { type: 'checkbox', value: r.id, checked: (l.ressourcen || []).includes(r.id) }) }))
const wer = h('fieldset', { class: 'tm-feld tm-gruppe-felder', 'data-feld': `${p}.ressourcen`, tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Wer führt die Leistung durch?'),
boxen.length
? boxen.map(({ r, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, `${r.name} · ${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ' (inaktiv)'}`)))
: h('p', { class: 'tm-hinweis' }, 'Lege zuerst im Reiter „Team & Plätze“ eine Person oder einen Platz an.'),
h('p', { class: 'tm-fehler', 'data-fehler': `${p}.ressourcen`, role: 'alert' }))
const felder = [
feld(`${p}.name`, 'Name', name, 'So steht die Leistung beim Buchen.'),
feld(`${p}.beschreibung`, 'Beschreibung (optional)', beschreibung, `Ein bis zwei Sätze für deine Gäste. ${GESUNDHEIT_HINWEIS}`),
h('div', { class: 'tm-raster' },
feld(`${p}.dauer`, 'Dauer (Minuten)', dauer, '5 bis 480, in 5-Minuten-Schritten.'),
feld(`${p}.pufferNach`, 'Pause danach (Minuten)', puffer, 'Zeit danach, in der niemand gebucht werden kann. Gäste sehen sie nicht. Dauer und Pause zusammen höchstens 500 Minuten.')),
h('div', { class: 'tm-raster' },
feld(`${p}.preisArt`, 'Preis', preisArt),
feld(`${p}.preisCent`, 'Betrag in Euro', preis, 'Bei „Preis ab“ ist es der Mindestpreis.')),
feld(`${p}.buchungsart`, 'Buchung', art),
wer,
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Leistungen können Gäste buchen. Eine inaktive Leistung bleibt gespeichert.'),
]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Leistung anlegen' : 'Speichern')
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: `Leistung „${l.name}“ entfernen?`,
text: 'Das kann nicht rückgängig gemacht werden. Gibt es für die Leistung noch künftige Termine, geht das Entfernen nicht. Dann deaktiviere sie stattdessen.',
fehlerPfad: `leistungen.${l.id}`,
aendere: (entwurf) => { entwurf.leistungen = entwurf.leistungen.filter((x) => x.id !== l.id) },
nachher: () => nachSpeichern(t, `Leistung „${l.name}“ entfernt.`, '[data-werkzeug="leistung-neu"]'),
})
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Leistung anlegen' : `${ctx.nurLesen ? 'Leistung' : 'Leistung bearbeiten'}: ${l.name}` },
h('h3', null, neu ? 'Neue Leistung' : l.name),
gesperrt(ctx.nurLesen, felder),
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))
/** Leistung aus den Feldern; Eingaben, die kein Feld verstehen kann, werden hier am Feld gemeldet. */
function lese() {
const fehler = {}
const minuten = ganzzahl(dauer.value)
if (minuten === null) fehler[`${p}.dauer`] = 'Bitte gib die Dauer in ganzen Minuten an.'
const pause = puffer.value.trim() === '' ? 0 : ganzzahl(puffer.value)
if (pause === null) fehler[`${p}.pufferNach`] = 'Bitte gib die Pause in ganzen Minuten an (0 = keine Pause).'
let cent = 0
if (preisArt.value !== 'keiner') {
cent = euroZuCent(preis.value)
if (cent === null) fehler[`${p}.preisCent`] = 'Bitte gib den Preis in Euro an, zum Beispiel 45,00.'
}
if (Object.keys(fehler).length) return { fehler }
return {
leistung: {
...l, name: name.value, beschreibung: beschreibung.value, dauer: minuten, pufferNach: pause, preisArt: preisArt.value, preisCent: cent,
buchungsart: art.value, ressourcen: boxen.filter(({ input }) => input.checked).map(({ r }) => r.id), aktiv: aktiv.checked,
},
}
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = lese()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
if (neu) {
entwurf.leistungen.push(gelesen.leistung)
return true
}
const stelle = stelleVon(entwurf.leistungen, l.id, form, 'Diese Leistung')
if (stelle < 0) return false
entwurf.leistungen[stelle] = gelesen.leistung
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, neu ? 'Leistung angelegt.' : 'Leistung gespeichert.', `li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
+226
View File
@@ -0,0 +1,226 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze" – Personen und Plaetze mit Wochenplan (je Tag mehrere Zeitfenster, Text wie
„09:00–12:30, 13:30–18:00“, fensterParsen), Zuordnung der Leistungen, Kalender-Abo-Link (kopieren, neu erzeugen); darunter die Ausnahmen
(Abwesenheiten, Feiertage, Sonderoeffnungen: termine-team-ausnahmen.js). render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Jede Aenderung speichert die ganze Einstellung (termine-speichern.js); die Zuordnung steht in der Einstellung bei den Leistungen
(leistungen[].ressourcen), eine neue Person und ihre Zuordnung gehen deshalb in EINEM Speichern raus. Tokens der Kalender-Links vergibt nur
der Server. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, keine Knoepfe zum Aendern, keine Links. */
import { LIMITS, MAX, WOCHENTAGE_PLAN, eindeutigeId, planAusTexten, planTexte } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, hakenFeld, knopfZeile, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } from './termine-speichern.js'
import { abwesenheiten, feiertage, sonderoeffnungen } from './termine-team-ausnahmen.js'
const TYPEN = [['person', 'Person'], ['platz', 'Platz']]
const zaehle = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, sp, zeichne, wurzel }
function zeichne() {
wurzel.replaceChildren(sp.konflikt, personen(t), abwesenheiten(t), feiertage(t), sonderoeffnungen(t))
}
zeichne()
}
/* ----------------------------------------------------- Personen und Plaetze --- */
function personen(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const liste = e.ressourcen || []
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Person oder Platz hinzufügen', werkzeug: 'ressource-neu', primaer: true,
voll: liste.length >= LIMITS.ressourcen, grenzText: `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`,
baue: (schliessen) => formular(t, { r: null, schliessen }),
})
const reihe = (r) => {
const leistungen = (e.leistungen || []).filter((l) => (l.ressourcen || []).includes(r.id)).length
const tage = Object.keys(r.plan || {}).length
return editorZeile({
attrs: { 'data-id': r.id },
nurLesen: ctx.nurLesen,
label: r.name,
haupt: [
h('strong', { class: 'tm-name' }, r.name),
h('span', { class: 'tm-meta' }, [r.typ === 'platz' ? 'Platz' : 'Person', tage ? `Zeiten an ${zaehle(tage, 'Tag', 'Tagen')}` : 'keine Zeiten', leistungen ? zaehle(leistungen, 'Leistung', 'Leistungen') : 'keine Leistung'].join(' · ')),
],
pille: h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv'),
baue: (schliessen) => formular(t, { r, schliessen }),
})
}
return abschnitt('Personen und Plätze',
'Wer oder was kann gebucht werden? Jede Person und jeder Platz hat eigene Zeiten und bietet bestimmte Leistungen an. Die Leistungen selbst legst du im Reiter „Leistungen“ an.',
neu.leiste,
neu.box,
liste.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Personen und Plätze' }, liste.map(reihe))
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
}
/**
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
* Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten Liste (ressourcen.1.plan.3); die Felder heissen danach. Beim
* Absenden wird die Person/der Platz per ID gesucht, nicht per Stelle.
*/
function formular(t, { r, schliessen }) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const neu = r === null
const index = neu ? (e.ressourcen || []).length : (e.ressourcen || []).findIndex((x) => x.id === r.id)
const alt = r || { id: eindeutigeId('r', (e.ressourcen || []).map((x) => x.id)), typ: 'person', name: '', aktiv: true, plan: {} }
const p = `ressourcen.${index}`
const leistungen = e.leistungen || []
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
const texte = planTexte(alt.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${p}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: `„${alt.name}“ entfernen?`,
text: 'Die Zeiten, die Zuordnung zu den Leistungen und die Abwesenheiten gehen mit verloren. Gibt es noch künftige Termine, geht das Entfernen nicht. Dann setze sie auf „Inaktiv“.',
fehlerPfad: `ressourcen.${alt.id}`,
aendere: (entwurf) => {
entwurf.ressourcen = entwurf.ressourcen.filter((x) => x.id !== alt.id)
for (const l of entwurf.leistungen) l.ressourcen = (l.ressourcen || []).filter((id) => id !== alt.id)
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => a.ressourceId !== alt.id)
},
nachher: () => nachSpeichern(t, `„${alt.name}“ entfernt.`, '[data-werkzeug="ressource-neu"]'),
})
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Person oder Platz hinzufügen' : `${ctx.nurLesen ? 'Person oder Platz' : 'Bearbeiten'}: ${alt.name}` },
h('h3', null, neu ? 'Neue Person oder neuer Platz' : alt.name),
gesperrt(ctx.nurLesen, [
h('div', { class: 'tm-raster' },
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
feld(`${p}.typ`, 'Art', typ)),
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
zuordnung.length
? zuordnung.map(({ input, name: feldName, label }) => hakenFeld(feldName, input, label))
: h('p', { class: 'tm-hinweis' }, 'Es gibt noch keine Leistung. Lege sie im Reiter „Leistungen“ an und ordne sie danach hier zu.')),
]),
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 gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
if (gelesen.fehler) {
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
return false
}
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
if (neu) {
entwurf.ressourcen.push(ressource)
} else {
const stelle = stelleVon(entwurf.ressourcen, alt.id, form, 'Diese Person oder dieser Platz')
if (stelle < 0) return false
entwurf.ressourcen[stelle] = ressource
}
// die Zuordnung steht bei den Leistungen: genau diese Person/diesen Platz bei jeder Leistung ein- oder austragen
for (const { id, input } of zuordnung) {
const leistung = entwurf.leistungen.find((l) => l.id === id)
if (!leistung) continue
const ids = (leistung.ressourcen || []).filter((x) => x !== alt.id)
leistung.ressourcen = input.checked ? [...ids, alt.id] : ids
}
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.', `li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)
})
return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), entfernen?.box)
}
/* ---------------------------------------------------------- Kalender-Abo --- */
/** Kalender-Link einer gespeicherten Person/eines Platzes: anzeigen, kopieren, neu erzeugen (der Server vergibt das Token). */
function kalenderLink(t, r) {
const { ctx } = t
const url = ctx.daten.feeds?.[r.id]
const kopf = [
h('h4', null, 'Kalender-Abo'),
h('p', { class: 'tm-hinweis' }, 'Mit diesem Link zeigt dein Kalender (Google, Apple, Outlook) die bestätigten Termine automatisch an. Füge ihn dort als Kalender-Abo ein („Kalender abonnieren“ oder „Per URL hinzufügen“), nicht als Datei. Google aktualisiert abonnierte Kalender nur alle paar Stunden, neue Termine erscheinen dort deshalb mit Verspätung.'),
]
if (!url) return h('div', { class: 'tm-feed' }, kopf, h('p', { class: 'tm-hinweis' }, 'Der Link erscheint, sobald die Seite neu geladen ist.'))
const eingabe = h('input', { type: 'text', readonly: true, class: 'tm-link-feld', value: url, 'aria-label': `Kalender-Link für ${r.name}`, spellcheck: 'false' })
eingabe.addEventListener('focus', () => eingabe.select())
const status = h('p', { class: 'tm-hinweis', role: 'status' })
const frageBox = h('div', { class: 'tm-frage' })
async function kopiere() {
try {
await navigator.clipboard.writeText(eingabe.value)
status.textContent = 'Der Link ist kopiert.'
} catch {
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Link bleibt sichtbar und markiert zum Kopieren von Hand
eingabe.focus()
eingabe.select()
status.textContent = 'Das Kopieren hat nicht geklappt. Der Link ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
}
}
function fragen(ausloeser) {
const { zeile } = knopfZeile('Neuen Link erzeugen', () => { frageBox.replaceChildren(); ausloeser.focus() })
const box = frage('Neuen Kalender-Link erzeugen?',
h('p', null, 'Der bisherige Link funktioniert danach nicht mehr. Kalender, die ihn abonniert haben, zeigen keine neuen Termine mehr, bis du dort den neuen Link einträgst. Tu das, wenn der Link in falsche Hände geraten ist.'),
allgemeinerFehler(),
zeile)
// exklusiv wie ein Speichern: das Erzeugen aendert die Version, ein gleichzeitiges Speichern bekaeme sonst die alte
box.addEventListener('submit', async () => {
const erzeugt = await t.sp.exklusiv(async () => {
try {
const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`)
if (!ctx.aktuell()) return false
ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf
ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url }
if (!eingabe.isConnected) return true // der Reiter wurde inzwischen neu gezeichnet und zeigt schon den neuen Link
eingabe.value = antwort.url
frageBox.replaceChildren()
status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.'
ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`)
return true
} catch (err) {
if (ctx.aktuell() && eingabe.isConnected) zeigeFehler(box, err)
return false
}
})
// Fokus erst nach dem Freigeben der Knoepfe (im gesperrten Zustand nimmt ein Knopf keinen Fokus an); ersetzt, den Knopf derselben Person suchen
if (!erzeugt || !t.sp.nochHier()) return
const knopf = ausloeser.isConnected ? ausloeser : t.wurzel.querySelector(`li[data-id="${r.id}"] [data-aktion="link-neu"]`)
knopf?.focus()
})
frageBox.replaceChildren(box)
box.focus()
}
const neuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-neu', onclick: (ev) => fragen(ev.currentTarget) }, 'Neuen Link erzeugen')
return h('div', { class: 'tm-feed' },
kopf,
h('p', { class: 'tm-hinweis' }, 'Der Link ist geheim: Wer ihn kennt, sieht die bestätigten Termine mit den Namen der Gäste. Gib ihn nicht weiter.'),
eingabe,
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', onclick: kopiere }, 'Link kopieren'), neuKnopf),
status,
frageBox)
}
+234
View File
@@ -0,0 +1,234 @@
/* Terminplaner im Kundenbereich: Reiter "Termine" – Liste nach Tagen (Tag, Woche, Monat, Offene Anfragen), Filter, Zeilen mit Aktionen,
"+ Termin eintragen" und "Zeit sperren". render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
ctx kommt aus termine.js: { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }. Die Aktionen mit
Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */
import {
FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fensterAufteilen, filterStatus, gruppiereNachTag, heuteBerlin, istSperre, istTag,
moeglicheAktionen, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitraumTitel,
} from './termine-view.js'
import { fehlerKarte, h } from './termine-dom.js'
import { AKTION_TEXT, schliesseFrage, starteAktion } from './termine-aktionen.js'
import { formularSperre, formularTermin } from './termine-formulare.js'
const ABSENDER = { gast: 'Vom Gast abgesagt', betrieb: 'Von dir abgesagt', system: 'Automatisch abgelehnt (Termin vorbei)' }
export function render(panel, ctx) {
const t = { ctx, auswahl: ctx.auswahl, panel, nr: 0, buchungen: [], offen: null, formular: null }
const { s } = ctx
t.nachAenderung = () => ladeListe(t, { auchZahl: true }) // Liste und Zahl der Anfragen nach einer Aenderung neu holen
// Kopf: Ansicht, Navigation, Titel, Filter, Werkzeuge
const ansichtKnoepfe = [['tag', 'Tag'], ['woche', 'Woche'], ['monat', 'Monat']].map(([key, text]) =>
h('button', { type: 'button', class: 'tm-seg', 'data-ansicht': key, onclick: () => setze(t, { ansicht: key }) }, text))
t.zurueck = h('button', { type: 'button', class: 'kb-btn-outline', hidden: true, onclick: () => setze(t, { ansicht: 'tag' }) }, 'Zurück zur Übersicht')
t.nav = h('div', { class: 'tm-nav' },
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Zurück', onclick: () => setze(t, { tag: verschiebeTag(t.auswahl.ansicht, s.tag, -1) }) }, h('span', { 'aria-hidden': 'true' }, '‹')),
h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(t.auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
// nur ein echter Tag springt an (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes und wuerde die Berechnung der Ansicht festfahren)
t.datumFeld.addEventListener('change', () => { if (istTag(t.datumFeld.value)) setze(t, { tag: t.datumFeld.value }) })
t.titel = h('h3', { class: 'tm-zeitraum' })
const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
t.werkzeuge = ctx.nurLesen ? null : h('div', { class: 'tm-werkzeuge' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', hidden: true })
t.formularBox = h('div', { class: 'tm-formular-box' })
t.liste = h('div', { class: 'tm-liste' })
panel.replaceChildren(
h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
t.titel,
t.filterBox,
h('div', { class: 'tm-werkzeug-zeile' }, t.werkzeuge, t.neuBtn),
t.anfragenBox,
t.formularBox,
t.liste)
aktualisiereKopf(t)
ladeListe(t)
}
/** Auswahl aendern (Ansicht, Filter, Tag) und die Liste neu laden. */
function setze(t, aenderung) {
const { s } = t.ctx
if (aenderung.ansicht) t.auswahl.ansicht = aenderung.ansicht
if (aenderung.filter) t.auswahl.filter = aenderung.filter
if (aenderung.tag) s.tag = aenderung.tag
t.ctx.meldung('')
schliesseFrage(t)
aktualisiereKopf(t)
ladeListe(t)
}
function aktualisiereKopf(t) {
const { s, daten } = t.ctx
const { ansicht, filter } = t.auswahl
const anfragen = ansicht === 'anfragen'
for (const k of t.ansichtBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.ansicht === ansicht))
for (const k of t.filterBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.filter === filter))
t.ansichtBox.hidden = anfragen
t.nav.hidden = anfragen
t.filterBox.hidden = anfragen
t.datumFeld.parentElement.hidden = anfragen
t.zurueck.hidden = !anfragen
t.titel.textContent = zeitraumTitel(ansicht, s.tag)
t.datumFeld.value = s.tag
const n = daten.anfragenOffen
t.anfragenBox.hidden = anfragen || !(n > 0)
if (!t.anfragenBox.hidden) {
t.anfragenBox.replaceChildren(
h('span', null, anfragenText(n)),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => setze(t, { ansicht: 'anfragen' }) }, 'Anfragen ansehen'))
}
}
async function ladeListe(t, { auchZahl = false } = {}) {
const { ctx } = t
const { s } = ctx
const nr = ++t.nr
const anfragen = t.auswahl.ansicht === 'anfragen'
// Offene Anfragen: ab heute 365 Tage (Ruling R50); die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin())
const fenster = anfragen ? fensterAufteilen(von, bis) : [{ von, bis }]
const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter)
t.liste.setAttribute('aria-busy', 'true')
try {
const [antworten] = await Promise.all([
Promise.all(fenster.map((f) => ctx.api.get('/buchungen', { von: f.von, bis: f.bis, ...(status ? { status } : {}) }))),
auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
])
if (nr !== t.nr || !ctx.aktuell() || !t.liste.isConnected) return
// ein Termin ueber die Fenstergrenze (Mitternacht) steht in beiden Antworten: nach ID zusammenfuehren
t.buchungen = [...new Map(antworten.flatMap((a) => a.buchungen || []).map((b) => [b.id, b])).values()]
zeichneListe(t)
} catch (err) {
if (nr !== t.nr || !ctx.aktuell()) return
t.liste.replaceChildren(fehlerKarte(err.message, () => ladeListe(t)))
} finally {
if (nr === t.nr) t.liste.removeAttribute('aria-busy')
}
}
function leerText(auswahl) {
if (auswahl.ansicht === 'anfragen') return 'Es warten keine Anfragen.'
const wo = auswahl.ansicht === 'monat' ? 'in diesem Monat' : 'an diesem Tag'
return auswahl.filter === 'alle' ? `Keine Termine ${wo}.` : `Keine Termine mit diesem Filter ${wo}.`
}
function zeichneListe(t) {
const { s } = t.ctx
const { ansicht } = t.auswahl
const heute = heuteBerlin()
const gruppen = gruppiereNachTag(t.buchungen)
let tage = gruppen
// Tag und Woche zeigen jeden Tag des Zeitraums, auch leere; Monat und Anfragen nur Tage mit Terminen
if (ansicht === 'tag' || ansicht === 'woche') {
const { von, bis } = bereichFuer(ansicht, s.tag, heute)
tage = []
for (let tag = von; tag <= bis; tag = tagPlus(tag, 1)) tage.push(gruppen.find((g) => g.tag === tag) || { tag, buchungen: [] })
}
t.offen = null
if (!gruppen.length && ansicht !== 'woche') {
t.liste.replaceChildren(h('p', { class: 'tm-leer' }, leerText(t.auswahl)))
return
}
t.liste.replaceChildren(...tage.map((g) => h('section', { class: 'tm-tag', 'data-tag': g.tag },
ansicht === 'tag' ? null : h('h4', { class: 'tm-tag-kopf' }, tagUeberschrift(g.tag, heute)),
g.buchungen.length
? h('ul', { class: 'tm-eintraege' }, g.buchungen.map((b) => eintrag(t, b)))
: h('p', { class: 'tm-leer' }, 'Keine Termine'))))
}
function eintrag(t, b) {
const { ctx } = t
const sperre = istSperre(b)
const aktionen = ctx.nurLesen ? [] : moeglicheAktionen(b, Date.now())
const kontext = `${sperre ? 'Sperre' : b.leistung}, ${datumKurz(b.datum)} um ${b.zeit} Uhr`
const li = h('li', { class: `tm-eintrag${sperre ? ' tm-sperre' : ''}`, 'data-id': b.id, 'data-status': b.status, 'data-quelle': b.quelle })
const titelBlock = sperre
? [h('strong', { class: 'tm-name' }, 'Gesperrt'), b.titel && b.titel !== 'Gesperrt' ? h('span', { class: 'tm-meta' }, b.titel) : null, b.person ? h('span', { class: 'tm-meta' }, b.person) : null]
: [h('strong', { class: 'tm-name' }, b.leistung || 'Termin'),
h('span', { class: 'tm-meta' }, [b.person, b.gastName || 'Ohne Namen', b.quelle === 'manuell' ? 'von dir eingetragen' : ''].filter(Boolean).join(' · '))]
li.append(h('div', { class: 'tm-zeile' },
h('span', { class: 'tm-zeit' }, `${b.zeit}–${endeZeit(b)}`),
h('div', { class: 'tm-haupt' }, titelBlock),
sperre ? h('span', { class: 'tm-pille tm-pille-off' }, 'Gesperrt') : h('span', { class: `tm-pille tm-pille-${statusTon(b.status)}` }, statusLabel(b.status))))
const hinweise = []
if (b.zustellFehler) hinweise.push(h('p', { class: 'tm-hinweis-zeile tm-warn' }, h('strong', null, 'Bestätigung nicht zugestellt'), ' – die E-Mail an den Gast ist nicht angekommen. Melde dich am besten telefonisch.'))
if (b.status === 'abgesagt') {
// absageGrund steht auch bei Vermerken an noch bestehenden Terminen (z. B. spaeter Widerruf): als Grund nur bei einer Absage zeigen
if (b.widerrufenAt) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, 'Vom Gast widerrufen'))
else if (ABSENDER[b.abgesagtVon]) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER[b.abgesagtVon]))
if (b.absageGrund) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, `${b.abgesagtVon === 'betrieb' ? 'Grund' : 'Vermerk'}: ${b.absageGrund}`))
}
if (b.status === 'abgelehnt' && b.abgesagtVon === 'system') hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER.system))
li.append(...hinweise)
const knoepfe = aktionen.filter((a) => a !== 'anonymisieren')
if (knoepfe.length) {
li.append(h('div', { class: 'tm-aktionen' }, knoepfe.map((a) => h('button', {
type: 'button',
class: a === 'annehmen' ? 'kb-btn-primary tm-knopf' : 'kb-btn-outline tm-knopf',
'data-aktion': a,
'aria-label': `${AKTION_TEXT[a]}: ${kontext}`,
onclick: (ev) => starteAktion(t, li, b, a, ev.currentTarget),
}, AKTION_TEXT[a]))))
}
li.append(h('div', { class: 'tm-frage', 'data-frage': '' }))
if (!sperre) {
const fakten = [
['Buchungsnummer', b.nummer], ['Dauer', `${b.dauer} Minuten`], ['Name', b.gastName], ['E-Mail', b.gastEmail], ['Telefon', b.gastTelefon],
['Nachricht', b.gastNachricht], ['Erinnerung', b.erinnerung ? 'Per E-Mail gewünscht' : ''],
].filter(([, wert]) => wert)
li.append(h('details', { class: 'tm-details' },
h('summary', { 'aria-label': `Details: ${kontext}` }, 'Details'),
h('dl', { class: 'tm-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))),
aktionen.includes('anonymisieren')
? h('button', { type: 'button', class: 'tm-link-knopf', 'data-aktion': 'anonymisieren', onclick: (ev) => starteAktion(t, li, b, 'anonymisieren', ev.currentTarget) }, 'Gastdaten löschen')
: null))
}
return li
}
/* ---------------------------------------- Termin eintragen / Zeit sperren --- */
function oeffneFormular(t, art) {
schliesseFrage(t)
t.ctx.meldung('')
if (t.formular === art) { schliesseFormular(t); return }
t.formular = art
for (const k of t.werkzeuge.children) k.setAttribute('aria-expanded', String(k.dataset.werkzeug === art))
const hooks = {
// Startwert fuer Tag-Felder: der gewaehlte Tag, wenn er nicht in der Vergangenheit liegt, sonst heute
startTag: t.ctx.s.tag >= heuteBerlin() ? t.ctx.s.tag : heuteBerlin(),
erfolg: (tag, text) => nachErfolg(t, tag, text),
abbrechen: () => { schliesseFormular(t); t.werkzeuge.querySelector(`[data-werkzeug="${art}"]`).focus() },
}
const form = art === 'manuell' ? formularTermin(t.ctx, hooks) : formularSperre(t.ctx, hooks)
t.formularBox.replaceChildren(form)
form.querySelector('select, input')?.focus()
}
function schliesseFormular(t) {
t.formular = null
t.formularBox.replaceChildren()
for (const k of t.werkzeuge?.children || []) k.setAttribute('aria-expanded', 'false')
}
async function nachErfolg(t, tag, text) {
schliesseFormular(t)
if (!t.ctx.aktuell()) return
t.ctx.s.tag = tag
if (t.auswahl.ansicht === 'anfragen') t.auswahl.ansicht = 'tag'
t.auswahl.filter = 'alle' // sonst bliebe der neue Eintrag unter einem Statusfilter (z. B. „Angefragt“) unsichtbar
aktualisiereKopf(t)
await ladeListe(t, { auchZahl: true })
t.ctx.meldung(text)
}
+199
View File
@@ -0,0 +1,199 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze", die Abschnitte fuer Ausnahmen vom Wochenplan – Abwesenheiten und Urlaub,
Feiertage (Bundesland) und Sonderoeffnungen. Jeder Abschnitt ist eine Funktion (t) => Element; t = { ctx, sp, zeichne, wurzel } kommt
aus termine-tab-team.js (sp = Speicher des Reiters, zeichne() zeichnet den Reiter neu). Jede Aenderung speichert die ganze Einstellung
(termine-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
import { BUNDESLAENDER, FENSTER_FEHLER, LIMITS, MAX, datumKurz, datumLang, eindeutigeId, fensterParsen, fensterText, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './termine-speichern.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)
/* ------------------------------------------------- Abwesenheiten, Urlaub --- */
export function abwesenheiten(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const heute = heuteBerlin()
const alle = e.abwesenheiten || []
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Abwesenheit eintragen', werkzeug: 'abwesenheit-neu',
voll: alle.length >= LIMITS.abwesenheiten, grenzText: `Mehr als ${LIMITS.abwesenheiten} Abwesenheiten sind nicht möglich.`,
baue: (schliessen) => formular(t, { a: null, schliessen }),
})
const wer = (a) => (a.ressourceId ? (e.ressourcen || []).find((r) => r.id === a.ressourceId)?.name || 'Unbekannt' : 'Ganzer Betrieb')
const reihe = (a) => editorZeile({
attrs: { 'data-id': a.id },
nurLesen: ctx.nurLesen,
label: `${a.titel || 'Abwesenheit'}, ${datumKurz(a.von)}`,
haupt: [
h('strong', { class: 'tm-name' }, a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`),
h('span', { class: 'tm-meta' }, [wer(a), a.titel].filter(Boolean).join(' · ')),
],
baue: (schliessen) => formular(t, { a, schliessen }),
})
return abschnitt('Abwesenheiten und Urlaub',
'In dieser Zeit kann niemand neu buchen, für eine Person, einen Platz oder den ganzen Betrieb. Termine, die schon gebucht sind, bleiben bestehen. Sag sie bei Bedarf selbst ab.',
neu.leiste,
neu.box,
kommend.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Abwesenheiten' }, kommend.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Abwesenheiten eingetragen.'),
vorbei.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Abwesenheiten (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
: null)
}
/** Formular einer neuen (a null) oder bestehenden Abwesenheit; Felder nach der Stelle in der gespeicherten Liste, Absenden sucht per ID. */
function formular(t, { a, schliessen }) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const neu = a === null
const index = neu ? (e.abwesenheiten || []).length : (e.abwesenheiten || []).findIndex((x) => x.id === a.id)
const alt = a || { id: eindeutigeId('a', (e.abwesenheiten || []).map((x) => x.id)), ressourceId: null, von: heuteBerlin(), bis: heuteBerlin(), titel: '' }
const p = `abwesenheiten.${index}`
const wer = h('select', null,
h('option', { value: '', selected: !alt.ressourceId }, 'Ganzer Betrieb'),
(e.ressourcen || []).map((r) => h('option', { value: r.id, selected: alt.ressourceId === r.id }, r.name)))
const von = h('input', { type: 'date', value: alt.von })
const bis = h('input', { type: 'date', value: alt.bis })
const titel = h('input', { type: 'text', maxlength: MAX.abwesenheitTitel, autocomplete: 'off', placeholder: 'z. B. Betriebsurlaub', value: alt.titel })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Abwesenheit eintragen' : 'Speichern')
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: 'Abwesenheit entfernen?',
text: 'In dieser Zeit kann danach wieder gebucht werden.',
aendere: (entwurf) => { entwurf.abwesenheiten = entwurf.abwesenheiten.filter((x) => x.id !== alt.id) },
nachher: () => nachSpeichern(t, 'Abwesenheit entfernt.', '[data-werkzeug="abwesenheit-neu"]'),
})
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Abwesenheit eintragen' : 'Abwesenheit bearbeiten' },
h('h3', null, neu ? 'Neue Abwesenheit' : 'Abwesenheit'),
gesperrt(ctx.nurLesen, [
feld(`${p}.ressourceId`, 'Wer ist weg?', wer),
h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis)),
feld(`${p}.titel`, 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
]),
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 eintrag = { ...alt, ressourceId: wer.value || null, von: von.value, bis: bis.value, titel: titel.value }
if (neu) {
entwurf.abwesenheiten = [...(entwurf.abwesenheiten || []), eintrag]
return true
}
const stelle = stelleVon(entwurf.abwesenheiten, alt.id, form, 'Diese Abwesenheit')
if (stelle < 0) return false
entwurf.abwesenheiten[stelle] = eintrag
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, neu ? 'Abwesenheit eingetragen.' : 'Abwesenheit gespeichert.', `li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
/* -------------------------------------------------------------- Feiertage --- */
export function feiertage(t) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: e.bundesland === key }, name)))
const zu = h('input', { type: 'checkbox', checked: e.feiertageGeschlossen !== false })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
gesperrt(ctx.nurLesen, [
feld('bundesland', 'Bundesland', land, 'Danach richten sich die gesetzlichen Feiertage.'),
hakenFeld('feiertageGeschlossen', zu, 'An gesetzlichen Feiertagen geschlossen', 'Dann kann an Feiertagen niemand buchen. Eine Sonderöffnung (unten) geht vor.'),
]),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => { entwurf.bundesland = land.value; entwurf.feiertageGeschlossen = zu.checked }, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
})
return abschnitt('Feiertage', null, form)
}
/* ------------------------------------------------------- Sonderöffnungen --- */
export function sonderoeffnungen(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const tage = Object.keys(e.sonderoeffnungen || {}).sort()
const neu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Sonderöffnung eintragen', werkzeug: 'sonder-neu',
voll: tage.length >= LIMITS.sonderoeffnungen, grenzText: `Mehr als ${LIMITS.sonderoeffnungen} Sonderöffnungen sind nicht möglich.`,
baue: (schliessen) => sonderFormular(t, { tag: null, schliessen }),
})
const reihe = (tag) => editorZeile({
attrs: { 'data-tag': tag },
nurLesen: ctx.nurLesen,
label: `Sonderöffnung ${datumKurz(tag)}`,
haupt: [
h('strong', { class: 'tm-name' }, datumLang(tag)),
h('span', { class: 'tm-meta' }, fensterText(e.sonderoeffnungen[tag]) || 'Geschlossen'),
],
baue: (schliessen) => sonderFormular(t, { tag, schliessen }),
})
return abschnitt('Sonderöffnungen',
'Für einzelne Tage, an denen es anders ist als im Wochenplan: Heiligabend nur bis 13 Uhr, ein Sonntag mit Öffnung oder ein Brückentag mit geschlossener Tür. Das gilt für alle Personen und Plätze und geht dem Wochenplan und den Feiertagen vor.',
neu.leiste,
neu.box,
tage.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Sonderöffnungen' }, tage.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Sonderöffnungen eingetragen.'))
}
/** Formular fuer einen neuen (tag null: mit Datum) oder bestehenden Tag (Datum steht fest; wer den Tag aendern will, entfernt ihn und legt einen neuen an). */
function sonderFormular(t, { tag, schliessen }) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const neu = tag === null
const datum = h('input', { type: 'date', value: heuteBerlin() })
const zeiten = h('input', { type: 'text', autocomplete: 'off', placeholder: '09:00–13:00', value: neu ? '' : fensterText(e.sonderoeffnungen[tag]) })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Sonderöffnung eintragen' : 'Speichern')
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: `Sonderöffnung am ${datumKurz(tag)} entfernen?`,
text: 'Danach gilt an diesem Tag wieder der Wochenplan (und der Feiertag, falls einer ist).',
aendere: (entwurf) => { const rest = { ...entwurf.sonderoeffnungen }; delete rest[tag]; entwurf.sonderoeffnungen = rest },
nachher: () => nachSpeichern(t, 'Sonderöffnung entfernt.', '[data-werkzeug="sonder-neu"]'),
})
const datumFeld = feld('datum', 'Tag', datum)
const zeitenFeld = feld('zeiten', 'Geöffnet von – bis', zeiten, 'Mehrere Zeitfenster mit Komma trennen: 09:00–12:00, 14:00–18:00. Leer lassen: an diesem Tag geschlossen.')
const zeitenFehler = zeitenFeld.querySelector('[data-fehler]')
// Fehler des Servers heissen 'sonderoeffnungen.<Tag>': beim Absenden nimmt das Feld der Zeiten diesen Namen an
const zeitenName = (name) => { zeitenFehler.dataset.fehler = name; zeiten.dataset.feld = name }
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Sonderöffnung eintragen' : `Sonderöffnung ${datumKurz(tag)}` },
h('h3', null, neu ? 'Neue Sonderöffnung' : datumLang(tag)),
gesperrt(ctx.nurLesen, [neu ? datumFeld : null, zeitenFeld]),
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 tagWert = neu ? datum.value : tag
zeitenName('zeiten')
const fehler = {}
if (!istTag(tagWert)) fehler.datum = 'Bitte wähle einen Tag.'
else if (neu && Object.hasOwn(entwurf.sonderoeffnungen || {}, tagWert)) fehler.datum = 'Für diesen Tag gibt es schon eine Sonderöffnung. Bearbeite sie in der Liste.'
const fenster = fensterParsen(zeiten.value)
if (fenster === null) fehler.zeiten = FENSTER_FEHLER
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
zeitenName(`sonderoeffnungen.${tagWert}`)
entwurf.sonderoeffnungen = { ...(entwurf.sonderoeffnungen || {}), [tagWert]: fenster }
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, neu ? 'Sonderöffnung eingetragen.' : 'Sonderöffnung gespeichert.', `li[data-tag="${neu ? datum.value : tag}"] [data-aktion="bearbeiten"]`)
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
+458
View File
@@ -0,0 +1,458 @@
/* Terminplaner im Kundenbereich: reine Helfer ohne DOM (ES-Modul; termine.js nutzt sie, test/termineView.test.js prueft sie).
Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender
(UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */
const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
const TAG_MS = 24 * 60 * 60 * 1000
/** Die Schnittstelle liefert Buchungen fuer hoechstens 62 Tage nach dem Beginn des Zeitraums. */
const MAX_TAGE = 62
const GELOESCHT = 'Gelöscht'
/**
* Laengen der Eingaben (maxlength der Felder): name/nachricht/titel/telefon fuer Gaeste und Sperren (ablauf.js, ablaufBetrieb.js),
* der Rest fuer die Einstellungen (einstellungen.js kuerzt laengere Texte stillschweigend; url ist eine Obergrenze mit Fehler).
* telefon gilt auch fuer das Telefon des Betriebs. test/termineView.test.js vergleicht sie mit dem Server.
*/
export const MAX = {
name: 100, telefon: 40, nachricht: 500, titel: 120,
betriebName: 80, adresse: 200, url: 300, leistungName: 100, beschreibung: 300, ressourcenName: 80, abwesenheitTitel: 80,
zusatzLabel: 60, option: 60, storno: 2000,
}
/* ------------------------------------------------------------ Kalender --- */
function utc(tag) {
const m = TAG_RE.exec(String(tag))
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
const tagVon = (ms) => new Date(ms).toISOString().slice(0, 10)
/** Der Tag n Kalendertage nach (oder vor, n < 0) dem Tag. */
export function tagPlus(tag, n) {
return tagVon(utc(tag) + n * TAG_MS)
}
/** Montag bis Sonntag der Woche, in der der Tag liegt. */
export function wocheVon(tag) {
const wochentag = new Date(utc(tag)).getUTCDay() // 0 = Sonntag
const montag = tagPlus(tag, -((wochentag + 6) % 7))
return { von: montag, bis: tagPlus(montag, 6) }
}
/** Erster bis letzter Tag des Monats, in dem der Tag liegt. */
export function monatVon(tag) {
const [jahr, monat] = String(tag).split('-').map(Number)
return { von: `${tag.slice(0, 7)}-01`, bis: tagVon(Date.UTC(jahr, monat, 0)) }
}
/**
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute 365 Tage, unabhaengig vom Buchungszeitraum der
* Einstellung: auch eine Anfrage, die vor einer spaeteren Verkuerzung des Zeitraums kam, soll in der Liste stehen). Fuer 'anfragen'
* ist der Zeitraum laenger als ein Aufruf der Schnittstelle: fensterAufteilen macht daraus die einzelnen Abfragen (hoechstens 6).
*/
export function bereichFuer(ansicht, tag, heute) {
if (ansicht === 'woche') return wocheVon(tag)
if (ansicht === 'monat') return monatVon(tag)
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, 365) }
return { von: tag, bis: tag }
}
/**
* Zerlegt von..bis (beide Tage eingeschlossen) in lueckenlose Fenster, die die Schnittstelle einzeln annimmt:
* bis hoechstens 62 Tage nach von. 365 Tage ab heute sind sechs Aufrufe.
*/
export function fensterAufteilen(von, bis) {
const fenster = []
for (let anfang = von; anfang <= bis; anfang = tagPlus(fenster.at(-1).bis, 1)) {
const ende = tagPlus(anfang, MAX_TAGE)
fenster.push({ von: anfang, bis: ende < bis ? ende : bis })
}
return fenster
}
/** Ein echter Kalendertag 'JJJJ-MM-TT' aus den Jahren 2000 bis 2100 (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes). */
export function istTag(text) {
const m = typeof text === 'string' ? TAG_RE.exec(text) : null
if (!m) return false
const jahr = Number(m[1])
const monat = Number(m[2])
const tag = Number(m[3])
if (jahr < 2000 || jahr > 2100) return false
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
return datum.getUTCFullYear() === jahr && datum.getUTCMonth() === monat - 1 && datum.getUTCDate() === tag
}
/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
export function verschiebeTag(ansicht, tag, richtung) {
if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
if (ansicht === 'monat') {
const [jahr, monat] = tag.split('-').map(Number)
return tagVon(Date.UTC(jahr, monat - 1 + richtung, 1))
}
return tagPlus(tag, richtung)
}
/** Heutiger Kalendertag in Berlin (nicht der des Browsers). */
export function heuteBerlin(jetzt = new Date()) {
return jetzt.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
/* --------------------------------------------------------------- Datum --- */
const tagOhneJahr = (tag) => `${Number(tag.slice(8))}. ${MONATE[Number(tag.slice(5, 7)) - 1]}`
/** 'Montag, 5. Oktober 2026' */
export function datumLang(tag) {
return `${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)} ${tag.slice(0, 4)}`
}
/** '05.10.2026' */
export function datumKurz(tag) {
return `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
}
/** Ueberschrift eines Tages in der Liste: 'Heute · Montag, 5. Oktober'; das Jahr nur, wenn es nicht das heutige ist. */
export function tagUeberschrift(tag, heute) {
const rel = tag === heute ? 'Heute · ' : tag === tagPlus(heute, 1) ? 'Morgen · ' : tag === tagPlus(heute, -1) ? 'Gestern · ' : ''
const jahr = tag.slice(0, 4) === heute.slice(0, 4) ? '' : ` ${tag.slice(0, 4)}`
return `${rel}${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)}${jahr}`
}
/** Titel des gewaehlten Zeitraums ueber der Liste: '5.–11. Oktober 2026', 'Oktober 2026', 'Montag, 5. Oktober 2026'. */
export function zeitraumTitel(ansicht, tag) {
if (ansicht === 'anfragen') return 'Offene Anfragen'
if (ansicht === 'monat') return `${MONATE[Number(tag.slice(5, 7)) - 1]} ${tag.slice(0, 4)}`
if (ansicht === 'woche') {
const { von, bis } = wocheVon(tag)
if (von.slice(0, 7) === bis.slice(0, 7)) return `${Number(von.slice(8))}.–${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
if (von.slice(0, 4) === bis.slice(0, 4)) return `${tagOhneJahr(von)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
return `${tagOhneJahr(von)} ${von.slice(0, 4)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
}
return datumLang(tag)
}
const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt), Mitternacht als '24:00' (22:00–24:00); '' wenn kein Zeitpunkt. */
export function endeZeit(buchung) {
const ende = new Date(buchung?.endsAt)
if (Number.isNaN(ende.getTime())) return ''
const zeit = ENDE_FORMAT.format(ende)
return zeit === '00:00' ? '24:00' : zeit
}
/* ------------------------------------------------------------- Uhrzeit --- */
/** 'H:MM', 'HH:MM' oder 'HH.MM' ⇒ 'HH:MM'; ende erlaubt 24:00. Das 5-Minuten-Raster prueft der Server (und fensterParsen). */
export function zeitNormal(text, { ende = false } = {}) {
const m = typeof text === 'string' ? /^(\d{1,2})[:.](\d{2})$/.exec(text.trim()) : null
if (!m) return null
const stunde = Number(m[1])
const minute = Number(m[2])
if (minute > 59 || stunde > 24 || (stunde === 24 && (!ende || minute !== 0))) return null
return `${String(stunde).padStart(2, '0')}:${m[2]}`
}
const inMinuten = (zeit) => Number(zeit.slice(0, 2)) * 60 + Number(zeit.slice(3))
/**
* Zeitfenster eines Wochentags aus Text: '09:00–12:30, 13:30–18:00' ⇒ [['09:00','12:30'], ['13:30','18:00']].
* Getrennt durch Komma, Strichpunkt, Zeilenumbruch oder „und“; Bis-Strich: -, –, — oder „bis“. Leerer Text ⇒ [] (geschlossen).
* Ungueltig ⇒ null: keine Uhrzeit, kein 5-Minuten-Raster, Ende nicht nach dem Beginn, nicht aufsteigend, Ueberlappung.
*/
export function fensterParsen(text) {
if (typeof text !== 'string') return null
if (text.trim() === '') return []
const fenster = []
let vorher = null
for (const teil of text.split(/[,;\n]|\bund\b/)) {
const m = /^\s*(\S+?)\s*(?:[-–—]|\bbis\b)\s*(\S+)\s*$/.exec(teil)
const von = m ? zeitNormal(m[1]) : null
const bis = m ? zeitNormal(m[2], { ende: true }) : null
if (von === null || bis === null || inMinuten(von) % 5 !== 0 || inMinuten(bis) % 5 !== 0) return null
if (inMinuten(bis) <= inMinuten(von) || (vorher !== null && inMinuten(von) < inMinuten(vorher))) return null
vorher = bis
fenster.push([von, bis])
}
return fenster
}
/** [['09:00','12:30'], ['13:30','18:00']] ⇒ '09:00–12:30, 13:30–18:00' (Umkehrung von fensterParsen). */
export function fensterText(fenster) {
return (Array.isArray(fenster) ? fenster : []).map(([von, bis]) => `${von}–${bis}`).join(', ')
}
/* -------------------------------------------------------------- Status --- */
const STATUS = {
gehalten: ['Reserviert', 'wait'],
angefragt: ['Angefragt', 'wait'],
bestaetigt: ['Bestätigt', 'ok'],
erledigt: ['Erledigt', 'off'],
nichterschienen: ['Nicht erschienen', 'bad'],
abgesagt: ['Abgesagt', 'off'],
abgelehnt: ['Abgelehnt', 'off'],
verfallen: ['Verfallen', 'off'],
}
/** Anzeigetext eines Status; Unbekanntes nie roh. */
export function statusLabel(status) {
return Object.hasOwn(STATUS, status) ? STATUS[status][0] : 'Unbekannt'
}
/** Farbton der Pille: ok | wait | off | bad. */
export function statusTon(status) {
return Object.hasOwn(STATUS, status) ? STATUS[status][1] : 'off'
}
/** Filter der Terminliste; status = Liste fuer ?status= der Schnittstelle (null = alle). */
export const FILTER = [
{ key: 'alle', label: 'Alle', status: null },
{ key: 'offen', label: 'Offen', status: ['angefragt', 'bestaetigt'] },
{ key: 'angefragt', label: 'Angefragt', status: ['angefragt'] },
{ key: 'bestaetigt', label: 'Bestätigt', status: ['bestaetigt'] },
{ key: 'abgesagt', label: 'Abgesagt', status: ['abgesagt', 'abgelehnt', 'verfallen'] },
]
/** Wert fuer ?status= ('' = alle Status). */
export function filterStatus(key) {
return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
}
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. Gleicher Text steht als anfragenText in funktionen.js (Kachel) – beide gemeinsam aendern. */
export function anfragenText(n) {
if (!Number.isInteger(n) || n < 1) return ''
return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
}
/** Gruende fuer die Absage durch den Betrieb (Pflichtauswahl; der Gast liest sie in der Absage-Mail). */
export const ABSAGE_GRUENDE = ['Krankheit', 'Betriebliche Gründe', 'Urlaub oder Betriebsferien', 'Auf Wunsch des Gastes', 'Sonstiges']
/* ----------------------------------------------------------- Einstellung --- */
/** „Person“, „Platz“ oder „Person/Platz“, je nachdem, was der Betrieb als Ressourcen hat. */
export function ressourcenWort(ressourcen) {
const typen = new Set((Array.isArray(ressourcen) ? ressourcen : []).map((r) => r?.typ))
if (typen.size === 1 && typen.has('platz')) return 'Platz'
if (typen.size === 1 && typen.has('person')) return 'Person'
return 'Person/Platz'
}
const FEHLT = { 'betrieb.telefon': 'deine Telefonnummer', 'betrieb.adresse': 'deine Anschrift' }
/** Was fuer die Buchbarkeit noch fehlt (fehlt = Pfade aus GET /einstellungen): „deine Telefonnummer und deine Anschrift“; '' wenn nichts. */
export function fehltText(fehlt) {
const teile = (Array.isArray(fehlt) ? fehlt : []).map((pfad) => FEHLT[pfad]).filter(Boolean)
return teile.join(' und ')
}
/* ------------------------------------------------------------- Buchungen --- */
export const istSperre = (buchung) => buchung?.quelle === 'sperre'
/** Buchungen nach Berliner Tag gruppiert (datum), Tage und Termine aufsteigend: [{ tag, buchungen }]. */
export function gruppiereNachTag(buchungen) {
const nachTag = new Map()
for (const b of [...(Array.isArray(buchungen) ? buchungen : [])].sort((a, c) => (a.startsAt < c.startsAt ? -1 : a.startsAt > c.startsAt ? 1 : 0))) {
if (!nachTag.has(b.datum)) nachTag.set(b.datum, [])
nachTag.get(b.datum).push(b)
}
return [...nachTag.keys()].sort().map((tag) => ({ tag, buchungen: nachTag.get(tag) }))
}
const hatGastdaten = (b) => Boolean((b.gastName && b.gastName !== GELOESCHT) || b.gastEmail || b.gastTelefon || b.gastNachricht)
/**
* Aktionen, die der Betrieb fuer die Buchung anbieten darf (jetzt = ms), in der Reihenfolge der Knoepfe:
* annehmen | ablehnen | verschieben | absagen | nichterschienen | anonymisieren | entsperren.
* Der Server prueft alles noch einmal; das hier haelt nur sinnlose Knoepfe weg.
*/
export function moeglicheAktionen(buchung, jetzt) {
const b = buchung || {}
const beginn = Date.parse(b.startsAt)
const ende = Date.parse(b.endsAt)
if (istSperre(b)) return ende > jetzt ? ['entsperren'] : []
const aktionen = []
const begonnen = !(beginn > jetzt)
if (b.status === 'angefragt') {
if (!begonnen) aktionen.push('annehmen')
aktionen.push('ablehnen')
if (!begonnen) aktionen.push('verschieben')
} else if (b.status === 'bestaetigt') {
if (!begonnen) aktionen.push('verschieben', 'absagen')
else aktionen.push('nichterschienen')
} else if (b.status === 'erledigt') {
aktionen.push('nichterschienen')
}
if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
return aktionen
}
/* ------------------------------------------- Einstellungen bearbeiten (21b) --- */
/** Hinweis ueber Namensfeldern, die Gaeste lesen (Wortlaut GESUNDHEIT_HINWEIS in server/services/termine/rechtstexte.js). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
/** Obergrenzen der Listen (GRENZEN in einstellungen.js); der Server prueft sie noch einmal. */
export const LIMITS = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 }
export const RASTER_MIN = [5, 10, 15, 20, 30, 60]
export const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
export const GAST_FELD_WAHL = [['aus', 'Aus'], ['optional', 'Optional'], ['pflicht', 'Pflicht']]
export const ZAHLARTEN = [['bar', 'bar'], ['ec', 'EC-Karte'], ['kreditkarte', 'Kreditkarte'], ['ueberweisung', 'Überweisung'], ['paypal', 'PayPal']]
export const BUNDESLAENDER = [
['BW', 'Baden-Württemberg'], ['BY', 'Bayern'], ['BE', 'Berlin'], ['BB', 'Brandenburg'], ['HB', 'Bremen'], ['HH', 'Hamburg'], ['HE', 'Hessen'],
['MV', 'Mecklenburg-Vorpommern'], ['NI', 'Niedersachsen'], ['NW', 'Nordrhein-Westfalen'], ['RP', 'Rheinland-Pfalz'], ['SL', 'Saarland'],
['SN', 'Sachsen'], ['ST', 'Sachsen-Anhalt'], ['SH', 'Schleswig-Holstein'], ['TH', 'Thüringen'],
]
/* ----------------------------------------------------------- Wochenplan --- */
/** ISO-Wochentage wie in plan der Einstellung: '1' = Montag … '7' = Sonntag. */
export const WOCHENTAGE_PLAN = [['1', 'Montag'], ['2', 'Dienstag'], ['3', 'Mittwoch'], ['4', 'Donnerstag'], ['5', 'Freitag'], ['6', 'Samstag'], ['7', 'Sonntag']]
export const FENSTER_FEHLER = 'Bitte so schreiben: 09:00–12:30, 13:30–18:00 (5-Minuten-Schritte, aufsteigend, ohne Überlappung, Ende bis 24:00).'
/** plan { '1': [['09:00','12:30']], … } ⇒ Text je Wochentag { '1': '09:00–12:30', …, '7': '' } (leer = geschlossen). */
export function planTexte(plan) {
return Object.fromEntries(WOCHENTAGE_PLAN.map(([nr]) => [nr, fensterText(plan?.[nr])]))
}
/** Umkehrung: Texte je Wochentag ⇒ { plan } (nur Tage mit Zeiten) oder { fehler: { '3': FENSTER_FEHLER } } fuer Tage, die fensterParsen ablehnt. */
export function planAusTexten(texte) {
const plan = {}
const fehler = {}
for (const [nr] of WOCHENTAGE_PLAN) {
const fenster = fensterParsen(texte?.[nr] ?? '')
if (fenster === null) fehler[nr] = FENSTER_FEHLER
else if (fenster.length) plan[nr] = fenster
}
return Object.keys(fehler).length ? { fehler } : { plan }
}
/* ------------------------------------------------------------------ IDs --- */
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
/**
* Neue ID einer Person/eines Platzes ('r'), einer Leistung ('s') oder einer Abwesenheit ('a'): Praefix_ + 8 Zeichen a-z0-9 (Ruling R8,
* dieselbe Form wie neueId in einstellungen.js; so bleibt ein Speichern atomar, auch wenn neue Eintraege aufeinander 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 neueId(praefix, bytes = zufallsBytes) {
if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${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}`
}
/** Neue ID, die in vorhandene (Liste oder Set von IDs) nicht vorkommt. */
export function eindeutigeId(praefix, vorhandene, bytes) {
const belegt = new Set(vorhandene)
let id
do { id = neueId(praefix, bytes) } while (belegt.has(id))
return id
}
/* ----------------------------------------------------------- Zahlen, Preis --- */
/** Ganze Zahl aus einem Feld ('45', ' 45 '), sonst null (leer, Komma, Buchstaben, mehr als 9 Stellen). */
export function ganzzahl(text) {
return typeof text === 'string' && /^\s*-?\d{1,9}\s*$/.test(text) ? Number(text) : null
}
/**
* Euro aus einem Feld in Cent. Deutsche Schreibweise: ',' trennt die Cent, '.' die Tausender ('1.234,50'); '45.50' und '12.5'
* gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei Nachkommastellen, kein Betrag vor dem Komma).
* Die Grenze (0 bis 10.000 Euro) prueft der Server.
*/
export function euroZuCent(text) {
if (typeof text !== 'string') return null
const s = text.replace(/€/g, '').trim()
if (!/^[\d.,]+$/.test(s)) return null
let ganz = s
let rest = ''
const komma = s.indexOf(',')
if (komma >= 0) {
ganz = s.slice(0, komma)
rest = s.slice(komma + 1)
if (/[.,]/.test(rest)) return null
if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null
ganz = ganz.replaceAll('.', '')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
ganz = s.replaceAll('.', '')
} else if (s.includes('.')) {
const teile = s.split('.')
if (teile.length !== 2) return null
;[ganz, rest] = teile
}
if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null
return Number(ganz) * 100 + Number(rest.padEnd(2, '0'))
}
/** 4500 ⇒ '45,00' (Feldwert); unbrauchbar ⇒ ''. */
export function centZuEuro(cent) {
return Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : ''
}
/** '45,00 €' | 'ab 45,00 €' | 'ohne Preis' (wie preisText in einstellungen.js, dort '' statt „ohne Preis“). */
export function preisAnzeige(leistung) {
const l = leistung || {}
if (l.preisArt === 'keiner' || !Number.isInteger(l.preisCent) || l.preisCent <= 0) return 'ohne Preis'
const euro = String(Math.floor(l.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return `${l.preisArt === 'ab' ? 'ab ' : ''}${euro},${String(l.preisCent % 100).padStart(2, '0')} €`
}
/* ------------------------------------------------------- Zusatzfelder, Listen --- */
const RESERVIERT = ['__proto__', 'constructor', 'prototype']
/**
* Schluessel eines Zusatzfelds aus der Beschriftung ('Größe in cm' ⇒ 'groesse_in_cm'): 1 bis 20 Zeichen a-z0-9_, nicht in vorhandene
* (Liste) und nicht reserviert; sonst wird _2, _3 … angehaengt. Der Kunde sieht den Schluessel nie.
*/
export function zusatzSchluessel(label, vorhandene) {
const belegt = new Set(vorhandene)
const grund = String(label ?? '').toLowerCase()
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 20).replace(/_+$/, '') || 'feld'
let key = grund
for (let n = 2; belegt.has(key) || RESERVIERT.includes(key); n += 1) {
const endung = `_${n}`
key = grund.slice(0, 20 - endung.length) + endung
}
return key
}
/** Auswahlmoeglichkeiten eines Zusatzfelds: eine je Zeile ⇔ Liste. */
export function optionenAusText(text) {
return typeof text === 'string' ? text.split(/\r?\n/).map((zeile) => zeile.trim()).filter(Boolean) : []
}
export function optionenText(optionen) {
return Array.isArray(optionen) ? optionen.join('\n') : ''
}
/** Kopie der Liste, in der der Eintrag an Stelle index um richtung (-1 hoch, +1 runter) verschoben ist; ausserhalb: unveraendert. */
export function verschiebeEintrag(liste, index, richtung) {
const kopie = [...liste]
const ziel = index + richtung
if (!Number.isInteger(index) || index < 0 || index >= kopie.length || ziel < 0 || ziel >= kopie.length) return kopie
;[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]]
return kopie
}
/**
* Eintraege nach der Reihenfolge der IDs: Kopie der Liste; unbekannte IDs werden uebergangen, Eintraege ohne ID in der Liste der IDs
* (z. B. inzwischen neu) folgen hinten in ihrer bisherigen Reihenfolge. So wirkt eine gemerkte Reihenfolge auf den aktuellen Stand der Liste.
*/
export function ordneNachIds(liste, ids) {
const eintraege = Array.isArray(liste) ? liste : []
const reihenfolge = new Map((Array.isArray(ids) ? ids : []).map((id, stelle) => [id, stelle]))
const stelle = (eintrag) => (reihenfolge.has(eintrag?.id) ? reihenfolge.get(eintrag.id) : Infinity)
return eintraege.map((eintrag, alt) => [eintrag, alt]).sort(([a, altA], [b, altB]) => (stelle(a) === stelle(b) ? altA - altB : stelle(a) < stelle(b) ? -1 : 1)).map(([eintrag]) => eintrag)
}
/** 'Aktiv seit 01.10.2026' aus aktivSeit (ISO-Zeit) nach Berliner Kalender; '' ohne brauchbare Zeit. */
export function aktivSeitText(iso) {
const zeit = typeof iso === 'string' ? new Date(iso) : null
if (!zeit || Number.isNaN(zeit.getTime())) return ''
return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}`
}
+222
View File
@@ -0,0 +1,222 @@
/* Terminplaner im Kundenbereich (termine.js): Projektauswahl, Vorlagen, Reiter, Terminliste, Rueckfragen und Formulare.
Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit tm-. Handy: bis 880px wie die App-Ansicht. */
#view-termine .kb-panel { max-width: none; }
#view-termine .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-termine button:focus-visible, #view-termine input:focus-visible, #view-termine select:focus-visible, #view-termine textarea:focus-visible,
#view-termine summary:focus-visible, #view-termine a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.tm-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* -------------------------------------------------- Hinweise und Meldungen --- */
.tm-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
.tm-box[hidden] { display: none; }
.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; scroll-margin-top: calc(var(--kb-appbar-h, 76px) + 12px); }
/* leer bleibt die Live-Region im Dokument (sonst liest ein Screenreader die naechste Meldung nicht vor), nimmt aber keinen Platz */
.tm-meldung:empty { margin: 0; padding: 0; border: 0; }
.tm-meldung:focus { outline: none; }
.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
.tm-info p:last-child { margin-bottom: 0; }
/* ----------------------------------------------------------------- Reiter --- */
.tm-reiterleiste { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); }
.tm-reiter {
padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted);
font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.tm-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }
.tm-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.tm-panel:focus { outline: none; }
/* ------------------------------------------------------------- Werkzeugleiste --- */
.tm-leiste { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; }
.tm-leiste [hidden], .tm-filter[hidden] { display: none; }
.tm-segment { display: inline-flex; border: 1px solid var(--kb-border-strong); border-radius: 999px; overflow: hidden; }
.tm-seg { padding: 9px 18px; border: none; background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.tm-seg + .tm-seg { border-left: 1px solid var(--kb-border-strong); }
.tm-seg[aria-pressed='true'] { background: var(--kb-purple); color: #fff; }
.tm-nav { display: inline-flex; align-items: center; gap: 6px; }
.tm-pfeil {
display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; border: 1px solid var(--kb-border-strong);
background: transparent; color: #fff; font: inherit; font-size: 22px; line-height: 1; cursor: pointer;
}
.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
#view-termine input, #view-termine select, #view-termine textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Datumsfelder dunkel */
.tm-sprung input { padding: 8px 10px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 14px; }
.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tm-chip {
padding: 8px 15px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit;
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
}
.tm-chip:hover { background: rgba(255, 255, 255, 0.07); }
.tm-chip[aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
.tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.tm-werkzeuge { display: contents; }
.tm-werkzeuge button[aria-expanded='true'] { box-shadow: 0 0 0 3px var(--kb-purple-soft); }
#view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 40px; }
/* ------------------------------------------------------------------- Liste --- */
.tm-tag-kopf { margin: 22px 0 8px; font-size: 14px; font-weight: 700; color: var(--kb-muted); }
.tm-tag:first-child .tm-tag-kopf { margin-top: 6px; }
.tm-liste[aria-busy='true'] { opacity: 0.55; }
.tm-leer { margin: 0 0 8px; padding: 14px 4px; font-size: 14px; color: var(--kb-faint); }
.tm-eintraege { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.tm-eintrag { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.tm-eintrag.tm-offen { border-color: var(--kb-purple-line); }
.tm-sperre {
border-style: dashed; border-color: var(--kb-border-strong);
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 10px, transparent 10px 20px), var(--kb-surface);
}
.tm-zeile { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 14px; align-items: start; }
.tm-zeit { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; padding-top: 1px; }
.tm-haupt { min-width: 0; }
.tm-name, .tm-meta { display: block; overflow-wrap: anywhere; }
.tm-meta { margin-top: 2px; font-size: 13.5px; color: var(--kb-muted); }
.tm-pille { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.tm-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tm-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.tm-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.tm-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
.tm-hinweis-zeile { margin: 8px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--kb-muted); overflow-wrap: anywhere; }
.tm-hinweis-zeile.tm-warn { color: #fcd34d; }
.tm-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.tm-frage:empty { display: none; }
.tm-frage-box {
display: grid; gap: 10px; margin-top: 12px; padding: 14px; border-radius: 12px; background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line);
}
.tm-frage-box:focus { outline: none; }
.tm-frage-box p { margin: 0; font-size: 14px; line-height: 1.5; }
.tm-frage-knoepfe { display: flex; flex-wrap: wrap; gap: 10px; }
.tm-details { margin-top: 10px; font-size: 14px; }
.tm-details summary { display: inline-block; padding: 4px 0; color: var(--kb-lilac); font-size: 13.5px; cursor: pointer; }
.tm-fakten { display: grid; gap: 8px; margin: 8px 0 0; }
.tm-fakten > div { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 2px 12px; }
.tm-fakten dt { color: var(--kb-muted); font-size: 13px; }
.tm-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.tm-link-knopf { margin-top: 10px; padding: 4px 0; border: none; background: none; color: #fca5a5; font: inherit; font-size: 13.5px; text-decoration: underline; cursor: pointer; }
/* ------------------------------------------------------------------ Formulare --- */
.tm-formular { margin-bottom: 16px; }
.tm-formular h3 { margin-bottom: 6px; }
.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; align-items: start; }
.tm-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
.tm-feld > label, .tm-label { font-size: 13px; font-weight: 600; }
.tm-feld input:not([type=checkbox]):not([type=radio]), .tm-feld select, .tm-feld textarea {
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.tm-feld select option { color: #111; }
.tm-feld [aria-invalid='true'] { border-color: #f87171; }
.tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
.tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
.tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
.tm-fehler:empty { display: none; }
.tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; }
.tm-haken { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0 6px; font-size: 14px; line-height: 1.5; cursor: pointer; }
.tm-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.tm-formular .tm-frage-knoepfe { margin-top: 16px; }
/* ------------------------------------------------------------- Vorlagenauswahl --- */
.tm-vorlagen > p { margin: 0 0 14px; line-height: 1.55; color: var(--kb-muted); }
.tm-vorlagen-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 0 0 16px; padding: 0; border: 0; min-width: 0; }
.tm-vorlage { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 14px; border: 2px solid var(--kb-border-strong); cursor: pointer; }
.tm-vorlage:hover { background: rgba(255, 255, 255, 0.04); }
.tm-vorlage:has(input:checked) { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
.tm-vorlage input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.tm-vorlage strong, .tm-vorlage small { display: block; }
.tm-vorlage small { margin-top: 3px; font-size: 13px; line-height: 1.45; color: var(--kb-muted); }
/* ----------------------------------- Leistungen, Team & Plaetze, Einstellungen --- */
.tm-tab > * + * { margin-top: 16px; }
.tm-tab > .tm-box { margin-bottom: 0; }
.tm-hinweise:empty { display: none; }
.tm-feld[hidden] { display: none; }
.tm-reihenfolge .tm-frage-knoepfe { margin: 0; }
.tm-gruppe-felder { display: grid; gap: 0; min-width: 0; margin: 0; padding: 0; border: 0; }
.tm-gruppe-felder > legend { padding: 0; }
.tm-feld.tm-gruppe-felder { margin-top: 14px; }
.tm-abschnitt h3 { margin-bottom: 4px; }
.tm-abschnitt h4 { margin: 18px 0 4px; font-size: 14.5px; }
.tm-abschnitt-text { margin: 0 0 14px; line-height: 1.55; }
.tm-abschnitt .tm-box { margin: 0 0 14px; }
.tm-abschnitt .tm-werkzeug-zeile { align-items: center; margin-bottom: 12px; }
.tm-abschnitt .tm-eintraege { margin-top: 4px; }
.tm-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px 14px; }
.tm-kopf .tm-pille { flex: none; }
.tm-eintrag .tm-meta + .tm-meta { margin-top: 0; }
.tm-editor-box:empty, .tm-frage:empty { display: none; }
.tm-editor { margin-top: 12px; }
.tm-eintrag .tm-formular { margin-bottom: 0; padding: 14px 0 0; border: 0; border-top: 1px solid var(--kb-border); border-radius: 0; background: transparent; }
.tm-eintrag .tm-formular h3 { display: none; }
.tm-feld-haken { margin-top: 4px; }
.tm-feld-haken .tm-haken { margin: 6px 0 0; }
.tm-haken > span { font-weight: 600; }
.tm-haken small { display: block; margin-top: 2px; font-weight: 400; }
.tm-abschnitt form > .tm-frage-knoepfe, .tm-tab > form > .tm-frage-knoepfe { margin-top: 16px; }
.tm-feld input[type=color] { height: 46px; padding: 4px; cursor: pointer; }
.tm-gefahr { color: #fca5a5; border-color: rgba(248, 113, 113, 0.45); }
.tm-gefahr:hover { background: rgba(248, 113, 113, 0.1); }
.tm-wochenplan .tm-raster { gap: 0 14px; }
.tm-feed { margin-top: 14px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--kb-border); }
.tm-feed h4 { margin: 0 0 6px; font-size: 14.5px; }
.tm-feed .tm-hinweis { margin: 0 0 8px; line-height: 1.5; }
.tm-feed .tm-frage-knoepfe { margin: 10px 0 0; }
.tm-link-feld {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis;
}
.tm-code-feld {
display: block; width: 100%; box-sizing: border-box; margin: 4px 0 10px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; line-height: 1.5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre; overflow: auto; resize: vertical;
}
.tm-muster { margin-top: 14px; padding: 14px 16px; border-radius: 12px; border: 1px dashed var(--kb-border-strong); }
.tm-muster > p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.55; color: var(--kb-muted); }
.tm-muster-text { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--kb-border-strong); font-size: 13.5px; line-height: 1.6; color: var(--kb-muted); font-style: italic; }
.tm-zusatz { display: grid; gap: 12px; margin: 10px 0; }
.tm-zusatz:empty { display: none; }
.tm-zusatz-zeile { padding: 12px 14px 14px; border-radius: 12px; border: 1px solid var(--kb-border); }
.tm-zusatz-zeile .tm-gefahr { margin-top: 10px; }
.tm-optionen { display: grid; gap: 4px; margin: 4px 0 8px; }
.tm-optionen .tm-haken { margin: 6px 0; }
.tm-aus { opacity: 0.6; cursor: not-allowed; }
.tm-status { margin: 0; }
.tm-abschnitt details.tm-details { margin-top: 12px; }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
.tm-pfeil { width: 44px; height: 44px; }
.tm-link-knopf { padding: 11px 0; }
.tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.tm-haken input { margin-top: 0; }
.tm-link-feld, .tm-code-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tm-reiter { text-align: center; }
.tm-leiste { gap: 10px; }
.tm-segment { width: 100%; }
.tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; }
.tm-nav { width: 100%; }
.tm-nav .tm-heute { flex: 1; }
.tm-sprung { width: 100%; }
.tm-sprung input { flex: 1; min-width: 0; }
.tm-zeile { grid-template-columns: minmax(0, 1fr) auto; }
.tm-haupt { grid-column: 1 / -1; grid-row: 2; }
.tm-fakten > div { grid-template-columns: minmax(0, 1fr); }
.tm-werkzeug-zeile > *, .tm-werkzeuge > * { flex: 1 1 calc(50% - 5px); }
.tm-werkzeuge > .kb-btn-primary { flex-basis: 100%; }
.tm-aktionen > * { flex: 1 1 auto; }
.tm-aktionen > .tm-pfeil { flex: 0 0 44px; }
.tm-kopf { flex-wrap: wrap; }
.tm-feed { padding: 12px; }
.tm-frage-knoepfe > * { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
+244
View File
@@ -0,0 +1,244 @@
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle:
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */
import { fehltText, heuteBerlin } from './termine-view.js'
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { render as renderTermine } from './termine-tab-termine.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
import { render as renderTeam } from './termine-tab-team.js'
import { render as renderEinstellungen } from './termine-tab-einstellungen.js'
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
/* --------------------------------------------------------- Einstieg --- */
export async function initTermine(root, { projectId } = {}) {
if (!root) return
const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch
const { projects = [], termine } = await request('/api/funktionen')
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null }
S = s
if (!termine?.verfuegbar) {
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
if (!projects.length) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
const zustand = (p) => p.features?.termine?.state
s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
|| projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0]
auswahl.projectId = s.projekt.projectId
s.buehne = h('div', { id: 'termine-buehne' })
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
await zeigeProjekt(s)
}
function projektwahl(s) {
const select = h('select', { id: 'termine-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.tag = heuteBerlin()
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
}
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' },
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
exportLink(projectId, 'exportieren'),
'.')
/**
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
*/
async function zeigeProjekt(s) {
const projekt = s.projekt
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
const zustand = projekt.features?.termine?.state || 'off'
s.nurLesen = zustand !== 'active'
if (zustand !== 'active' && zustand !== 'disabled') {
s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
return
}
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ladeDaten(projekt.projectId)
} catch (err) {
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return
}
if (!aktuell()) return
s.daten = daten
if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
else if (s.nurLesen) {
s.buehne.replaceChildren(
hinweisAbgeschaltet(projekt.projectId),
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
} else zeigeVorlagen(s, { projekt, aktuell })
}
/* ---------------------------------------------------- Vorlage wählen --- */
function zeigeVorlagen(s, { projekt, aktuell }) {
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
let gewaehlt = null
const karten = (s.daten.vorlagen || []).map((v) => {
const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key })
radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' })
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung)))
})
start.addEventListener('click', async () => {
if (!gewaehlt) return
start.disabled = true
fehler.textContent = ''
try {
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } })
const daten = await ladeDaten(projekt.projectId)
if (!aktuell()) return
s.daten = daten
zeigeReiter(s, { projekt, aktuell })
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
} catch (err) {
fehler.textContent = err.message
start.disabled = false
}
})
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' },
h('h3', null, 'Womit möchtest du starten?'),
h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'),
h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten),
fehler,
h('div', { class: 'abo-actions' }, start)))
}
/* -------------------------------------------------------- Reiter-Gerüst --- */
/** key, Beschriftung, Funktion (panel, ctx). */
const REITER = [
{ key: 'termine', label: 'Termine', render: renderTermine },
{ key: 'leistungen', label: 'Leistungen', render: renderLeistungen },
{ key: 'team', label: 'Team & Plätze', render: renderTeam },
{ key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen },
]
/**
* Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region
* der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine
* Fehlermeldung bekommt zusaetzlich den Fokus.
*/
function zeigeMeldung(s, text, art = 'ok') {
const el = s.meldungEl
if (!el) return
el.textContent = text || ''
el.className = `tm-meldung tm-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!text) return
if (art === 'fehler') el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest' })
}
function zeigeReiter(s, { projekt, aktuell }) {
const projectId = projekt.projectId
const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const ctx = {
s,
projectId,
daten: s.daten,
nurLesen,
auswahl,
aktuell,
api: {
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
send: (method, pfad, body = {}) => (method === 'DELETE'
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
},
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
hinweise: () => { if (aktuell()) zeichneHinweise() },
zumReiter: (key) => waehleReiter(key),
neuLaden: async () => {
const daten = await ladeDaten(projectId)
if (aktuell()) {
s.daten = daten
ctx.daten = daten
zeichneHinweise()
}
return daten
},
}
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
const hinweisBox = h('div', { class: 'tm-hinweise' })
function zeichneHinweise() {
const hinweise = []
if (nurLesen) {
hinweise.push(hinweisAbgeschaltet(projectId))
} else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
}
hinweisBox.replaceChildren(...hinweise)
}
zeichneHinweise()
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
const knoepfe = REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === auswahl.reiter)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
waehleReiter(REITER[ziel].key)
knoepfe[ziel].focus()
})
function waehleReiter(key) {
const reiter = REITER.find((r) => r.key === key) || REITER[0]
auswahl.reiter = reiter.key
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1
}
panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
zeigeMeldung(s, '')
panel.replaceChildren()
Promise.resolve()
.then(() => reiter.render(panel, ctx))
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel)
waehleReiter(auswahl.reiter)
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Terminplaner: legt die vier Collections termineConfig, termineBookings, termineClaims und termineOutbox mit
* Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in
* server/services/termine/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes
* auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (TERMINE_ENABLED) ausfuehren.
* Usage:
* node scripts/termine-setup.mjs --check (nur melden, nichts anlegen; Exit 1 bei Fehlendem)
* node scripts/termine-setup.mjs (Fehlendes anlegen)
*/
import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js'
import { TERMINE_COLLECTIONS } from '../server/services/termine/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 = []
// Erst alles pruefen, dann (ohne --check) anlegen, damit --check garantiert nichts veraendert
for (const [name, { attributes, indexes }] of Object.entries(TERMINE_COLLECTIONS)) {
const id = config.collections[name]
const exists = await api.collectionExists(id)
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)
})
}
+33
View File
@@ -23,6 +23,14 @@ function resolveDatabaseId() {
return WOMS_DATABASE_ID
}
/** Zahl >= 0 aus einer Umgebungsvariablen; leer, keine Zahl oder negativ ergibt den Vorgabewert. */
function nonNegativeNumber(raw, fallback) {
const value = Number(raw)
return String(raw ?? '').trim() !== '' && Number.isFinite(value) && value >= 0 ? value : fallback
}
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
// 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.
const products = {
@@ -160,6 +168,11 @@ export const config = {
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
financeSettings: process.env.APPWRITE_COLLECTION_FINANCE_SETTINGS || 'financeSettings',
financeFixedCosts: process.env.APPWRITE_COLLECTION_FINANCE_FIXED_COSTS || 'financeFixedCosts',
// Terminplaner (Einstellungen, Buchungen, Belegungszellen, Mail-Warteschlange; scripts/termine-setup.mjs)
termineConfig: process.env.APPWRITE_COLLECTION_TERMINE_CONFIG || 'termineConfig',
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
@@ -360,6 +373,26 @@ export const config = {
avvVersion: '2026-10-02',
},
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
// Laufzeit); Schalter siehe server/services/termine/schalter.js
termine: {
// off (Vorgabe) | pilot (nur TERMINE_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(termineEnabled) ? termineEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.TERMINE_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
holdMinutes: 30,
zelleMin: 5,
mailsProStunde: 200,
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
// eine Adresse oder Buchung bekannt ist (Zeitunterschied). Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
},
invoiceNinja: {
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
+28
View File
@@ -16,6 +16,7 @@ import customerDataRoutes from './routes/customerData.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -23,6 +24,7 @@ import { purgeViewLog } from './services/customerViewLog.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -34,6 +36,7 @@ import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
@@ -53,6 +56,9 @@ app.use(sessionMiddleware())
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen
// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften
app.use(kundenansichtGuard)
// Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und
// haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde
app.use('/api/public/termine/v1', publicTermineRoutes)
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
@@ -72,6 +78,7 @@ app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -192,6 +199,27 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
// Terminplaner (Modus off: jeder Lauf kehrt sofort zurueck, ohne Abfrage; ein noch laufender Job wird nicht ueberlappt):
// jede Minute abgelaufene Haltungen verfallen lassen und die Mail-Warteschlange senden, alle 5 Minuten Erinnerungen, erledigte
// Termine und vergangene Anfragen, taeglich (3 Minuten nach dem Start beginnend) Loeschfristen und Aufraeumen. Die Jobs melden
// einzelne Fehler selbst; hier faengt der Rahmen alles Unerwartete ab. Im Log stehen keine Gastdaten.
const termineMinutenJob = () =>
minutenJob(new Date()).catch((err) => console.warn('[termine] Minutenjob fehlgeschlagen:', err.message))
setInterval(termineMinutenJob, 60 * 1000).unref()
const termineFuenfMinutenJob = () =>
fuenfMinutenJob(new Date()).catch((err) => console.warn('[termine] Fünf-Minuten-Job fehlgeschlagen:', err.message))
setInterval(termineFuenfMinutenJob, 5 * 60 * 1000).unref()
// Nach dem Tagesjob steht eine Zeile mit den Zahlen der Loeschungen im Log (Spur unwiderruflicher Loeschungen, ohne Personendaten)
const termineTagesJob = () =>
taeglicherJob(new Date())
.then((ergebnis) => {
const zeile = tagesbericht(ergebnis)
if (zeile) console.log(zeile)
})
.catch((err) => console.warn('[termine] Tagesjob fehlgeschlagen:', err.message))
setTimeout(termineTagesJob, 180 * 1000).unref()
setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref()
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
const repoAssign = () =>
runRepoAssignJob({ now: new Date() }).catch((err) =>
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G01 · Termin bestätigen (E-Mail-Bestätigung durch den Gast)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast (die beim Buchen angegebene Adresse) · ohne BCC
Platzhalter: {{leistung}}, {{datum}}, {{zeit}}, {{link}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, bestaetigen, zu_viele
Kein vom Gast eingegebener Text (auch kein Name): die Mail geht an eine noch nicht bestätigte Adresse.
Block bestaetigen: Normalfall mit Terminangaben und Bestätigungsknopf. Block zu_viele: Grenze offener Termine erreicht, nichts wurde reserviert (kein Knopf).
Schließen sich aus: bestaetigen und zu_viele (der Server setzt genau einen von beiden).
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<!-- WENN bestaetigen -->
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Bitte bestätigen Sie Ihren Termin<!-- ENDE sie --><!-- WENN du -->Bitte bestätige deinen Termin<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag,<br>bei {{betrieb_name}} wurde mit dieser E-Mail-Adresse ein Termin ausgewählt. Bitte bestätigen Sie ihn mit dem Knopf unten. Der Termin ist nur kurze Zeit für Sie reserviert.<!-- ENDE sie --><!-- WENN du -->Hallo,<br>bei {{betrieb_name}} wurde mit dieser E-Mail-Adresse ein Termin ausgewählt. Bitte bestätige ihn mit dem Knopf unten. Der Termin ist nur kurze Zeit für dich reserviert.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:26px 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="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Termin bestätigen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Der Knopf öffnet die Bestätigungsseite. Dort sehen Sie noch einmal alle Angaben.<!-- ENDE sie --><!-- WENN du -->Der Knopf öffnet die Bestätigungsseite. Dort siehst du noch einmal alle Angaben.<!-- ENDE du --></td></tr>
<!-- ENDE bestaetigen -->
<!-- WENN zu_viele -->
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihr Termin konnte nicht vorgemerkt werden<!-- ENDE sie --><!-- WENN du -->Dein Termin konnte nicht vorgemerkt werden<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag,<br>bei {{betrieb_name}} wurde mit dieser E-Mail-Adresse ein Termin ausgewählt. Zu dieser E-Mail-Adresse gibt es dort aber bereits die höchstmögliche Zahl offener Termine. Der gewünschte Termin wurde deshalb nicht für Sie reserviert. Bitte sagen Sie einen Ihrer offenen Termine ab (Link in der jeweiligen Bestätigungs-E-Mail) oder rufen Sie uns an: {{betrieb_telefon}}.<!-- ENDE sie --><!-- WENN du -->Hallo,<br>bei {{betrieb_name}} wurde mit dieser E-Mail-Adresse ein Termin ausgewählt. Zu dieser E-Mail-Adresse gibt es dort aber bereits die höchstmögliche Zahl offener Termine. Der gewünschte Termin wurde deshalb nicht für dich reserviert. Bitte sage einen deiner offenen Termine ab (Link in der jeweiligen Bestätigungs-E-Mail) oder ruf uns an: {{betrieb_telefon}}.<!-- ENDE du --></td></tr>
<!-- ENDE zu_viele -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Das waren Sie nicht? Dann ignorieren Sie diese E-Mail.<!-- ENDE sie --><!-- WENN du -->Das warst du nicht? Dann ignoriere diese E-Mail.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G02 · Termin bestätigt (Zusammenfassung mit Verwaltungslink und Kalendereintrag)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{person}}, {{datum}}, {{zeit}}, {{dauer}}, {{preis}}, {{zahlung}}, {{nummer}}, {{link}}, {{ort}}, {{storno_text}}, {{widerrufsbelehrung}}, {{widerruf_link}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, preis, person, storno, widerruf, kein_widerruf, anfrage_angenommen
Block preis: Preis und Zahlungsart. Block person: Person oder Platz. Block storno: Stornobedingungen im Wortlaut ({{storno_text}}).
Block widerruf: Widerrufsrecht besteht ({{widerrufsbelehrung}} als Text mit Zeilenumbrüchen, {{widerruf_link}}).
Block kein_widerruf: Verbraucherbuchung einer Freizeitdienstleistung mit festem Termin (§ 312g Abs. 2 Nr. 9 BGB), gleicher Wortlaut in Sie- und Du-Fassung.
Block anfrage_angenommen: der Betrieb hat eine Anfrage angenommen. Kalendereintrag (.ics) kommt als icalEvent.
Schließen sich aus: widerruf und kein_widerruf (der Server setzt höchstens einen von beiden).
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihr Termin ist bestätigt<!-- ENDE sie --><!-- WENN du -->Dein Termin ist bestätigt<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br><!-- WENN anfrage_angenommen -->
{{betrieb_name}} hat Ihre Terminanfrage angenommen. <!-- ENDE anfrage_angenommen -->Ihr Termin ist bestätigt. Hier die Zusammenfassung:<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br><!-- WENN anfrage_angenommen -->
{{betrieb_name}} hat deine Terminanfrage angenommen. <!-- ENDE anfrage_angenommen -->Dein Termin ist bestätigt. Hier die Zusammenfassung:<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<!-- WENN person -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bei</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<!-- ENDE person -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Dauer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{dauer}}</td></tr>
<!-- WENN preis -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Preis</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{preis}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zahlung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zahlung}}</td></tr>
<!-- ENDE preis -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Ort</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{ort}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Buchungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr>
</table></td></tr>
<!-- WENN storno -->
<tr><td class="px ink" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:22px;font-weight:bold;color:#111111;">Stornobedingungen</td></tr>
<tr><td class="px mut" style="padding:6px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">{{storno_text}}</td></tr>
<!-- ENDE storno -->
<tr><td class="px" style="padding:26px 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="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Termin verwalten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Hier können Sie Ihren Termin ansehen, absagen oder verschieben, solange es die Bedingungen des Betriebs erlauben. Ein Kalendereintrag liegt dieser E-Mail bei.<!-- ENDE sie --><!-- WENN du -->Hier kannst du deinen Termin ansehen, absagen oder verschieben, solange es die Bedingungen des Betriebs erlauben. Ein Kalendereintrag liegt dieser E-Mail bei.<!-- ENDE du --></td></tr>
<!-- WENN kein_widerruf -->
<tr><td class="px mut" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.</td></tr>
<!-- ENDE kein_widerruf -->
<!-- WENN widerruf -->
<tr><td class="px mut" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie --><strong>Widerrufsrecht.</strong> Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail.<!-- ENDE sie --><!-- WENN du --><strong>Widerrufsrecht.</strong> Wenn du Verbraucher bist, kannst du diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular findest du weiter unten in dieser E-Mail.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:18px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn2" style="border:2px solid #111111;border-radius:10px;"><a href="{{widerruf_link}}" style="display:block;padding:12px 24px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#111111;text-decoration:none;">Vertrag widerrufen</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:26px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:22px;font-weight:bold;color:#111111;">Widerrufsbelehrung und Muster-Widerrufsformular</td></tr>
<tr><td class="px mut" style="padding:4px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Rechtlich vorgeschriebener Wortlaut:</td></tr>
<tr><td class="px" style="padding:10px 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:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;color:#111111;">{{widerrufsbelehrung}}</td></tr></table></td></tr>
<!-- ENDE widerruf -->
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G03 · Anfrage eingegangen (Gast hat die Adresse bestätigt, der Betrieb prüft)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{datum}}, {{zeit}}, {{link}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du
Der Verwaltungslink dient hier nur zum Absagen der Anfrage.
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihre Terminanfrage ist eingegangen<!-- ENDE sie --><!-- WENN du -->Deine Terminanfrage ist eingegangen<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br>vielen Dank für Ihre Anfrage. {{betrieb_name}} prüft sie und meldet sich bei Ihnen. Erst wenn der Betrieb den Termin bestätigt hat, ist er verbindlich.<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br>danke für deine Anfrage. {{betrieb_name}} prüft sie und meldet sich bei dir. Erst wenn der Betrieb den Termin bestätigt hat, ist er verbindlich.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Wunschtermin</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:26px 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="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Anfrage absagen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Möchten Sie die Anfrage zurückziehen, können Sie sie hier absagen.<!-- ENDE sie --><!-- WENN du -->Willst du die Anfrage zurückziehen, kannst du sie hier absagen.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</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">
<!--
G04 · Anfrage abgelehnt (der Betrieb nimmt die Terminanfrage nicht an)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{datum}}, {{zeit}}, {{nachricht}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, nachricht
Block nachricht: optionale Nachricht des Betriebs ({{nachricht}}).
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihre Terminanfrage wurde abgelehnt<!-- ENDE sie --><!-- WENN du -->Deine Terminanfrage wurde abgelehnt<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br>leider kann {{betrieb_name}} Ihre Terminanfrage nicht annehmen.<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br>leider kann {{betrieb_name}} deine Terminanfrage nicht annehmen.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Wunschtermin</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
</table></td></tr>
<!-- WENN nachricht -->
<tr><td class="px ink" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:22px;font-weight:bold;color:#111111;">Nachricht von {{betrieb_name}}</td></tr>
<tr><td class="px" style="padding:8px 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:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:23px;color:#111111;">{{nachricht}}</td></tr></table></td></tr>
<!-- ENDE nachricht -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Bei Fragen erreichen Sie uns unter {{betrieb_telefon}}.<!-- ENDE sie --><!-- WENN du -->Bei Fragen erreichst du uns unter {{betrieb_telefon}}.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G05 · Erinnerung (nur mit Einwilligung des Gastes, sonst nichts)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{person}}, {{datum}}, {{zeit}}, {{link}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, person
Block person: Person oder Platz.
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Erinnerung an Ihren Termin<!-- ENDE sie --><!-- WENN du -->Erinnerung an deinen Termin<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br>wir erinnern Sie an Ihren Termin bei {{betrieb_name}}. Sie haben diese Erinnerung bei der Buchung gewünscht.<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br>wir erinnern dich an deinen Termin bei {{betrieb_name}}. Du hast diese Erinnerung bei der Buchung gewünscht.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<!-- WENN person -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bei</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<!-- ENDE person -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:26px 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="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Termin verwalten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Können Sie nicht kommen? Dann sagen Sie den Termin bitte rechtzeitig ab.<!-- ENDE sie --><!-- WENN du -->Kannst du nicht kommen? Dann sage den Termin bitte rechtzeitig ab.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G06 · Termin abgesagt (wer abgesagt hat, Grund, Kalenderdatei zum Entfernen)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{datum}}, {{zeit}}, {{absage_von}}, {{grund}}, {{nachricht}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, grund, nachricht
Block grund: gewählter Grund bei Absage durch den Betrieb ({{grund}}). Block nachricht: freie Nachricht des Betriebs ({{nachricht}}). Kalenderdatei (.ics, CANCEL) kommt als icalEvent.
Schließen sich nicht aus: grund und nachricht sind unabhängig und können zusammen gesetzt werden.
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihr Termin wurde abgesagt<!-- ENDE sie --><!-- WENN du -->Dein Termin wurde abgesagt<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br>Ihr Termin bei {{betrieb_name}} wurde abgesagt.<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br>dein Termin bei {{betrieb_name}} wurde abgesagt.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abgesagt von</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{absage_von}}</td></tr>
<!-- WENN grund -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Grund</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{grund}}</td></tr>
<!-- ENDE grund -->
</table></td></tr>
<!-- WENN nachricht -->
<tr><td class="px ink" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:22px;font-weight:bold;color:#111111;">Nachricht von {{betrieb_name}}</td></tr>
<tr><td class="px" style="padding:8px 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:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:23px;color:#111111;">{{nachricht}}</td></tr></table></td></tr>
<!-- ENDE nachricht -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Die beigefügte Kalenderdatei entfernt den Termin aus Ihrem Kalender.<!-- ENDE sie --><!-- WENN du -->Die beigefügte Kalenderdatei entfernt den Termin aus deinem Kalender.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G07 · Termin verschoben (alte und neue Zeit, aktualisierte Kalenderdatei)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast · ohne BCC
Platzhalter: {{gast_name}}, {{leistung}}, {{alt_datum}}, {{alt_zeit}}, {{datum}}, {{zeit}}, {{link}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du
Kalenderdatei (.ics, REQUEST mit höherer Sequenz) kommt als icalEvent.
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Ihr Termin wurde verschoben<!-- ENDE sie --><!-- WENN du -->Dein Termin wurde verschoben<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag {{gast_name}},<br>Ihr Termin bei {{betrieb_name}} hat eine neue Zeit.<!-- ENDE sie --><!-- WENN du -->Hallo {{gast_name}},<br>dein Termin bei {{betrieb_name}} hat eine neue Zeit.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bisher</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{alt_datum}}, {{alt_zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Neu</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{datum}}, {{zeit}} Uhr</strong></td></tr>
</table></td></tr>
<tr><td class="px" style="padding:26px 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="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Termin verwalten &rarr;</a></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Die beigefügte Kalenderdatei aktualisiert den Termin in Ihrem Kalender. Hier können Sie Ihren Termin auch ansehen und absagen.<!-- ENDE sie --><!-- WENN du -->Die beigefügte Kalenderdatei aktualisiert den Termin in deinem Kalender. Hier kannst du deinen Termin auch ansehen und absagen.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G08 · Eingang Widerruf (Eingangsbestätigung nach § 356a BGB mit Datum und Uhrzeit)
Betreff: {{betreff}}
Absender: „<Betrieb>“ <Portal-Postfach> · Antworten an die Betriebs-E-Mail
Empfänger: Gast (die bei der Erklärung angegebene Adresse) · ohne BCC
Platzhalter: {{eingang}}, {{nummer}}, {{betreff}}, {{farbe}}, {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}
Optionale Blöcke (Anrede genau einer von sie und du, vom Server gesetzt): sie, du, zugeordnet, nach_frist
Kein vom Gast eingegebener Text (weder Name noch Freitext): die Mail geht an eine ungeprüfte Adresse.
Block zugeordnet: wirksamer Widerruf, Buchung abgesagt ({{nummer}} nur hier). Block nach_frist: Frist bei Eingang abgelaufen.
Schließen sich aus: zugeordnet und nach_frist (der Server setzt höchstens einen von beiden).
-->
<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>{{betreff}}</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;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<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" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN sie -->Eingangsbestätigung Ihres Widerrufs<!-- ENDE sie --><!-- WENN du -->Eingangsbestätigung deines Widerrufs<!-- ENDE du --></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;"><!-- WENN sie -->Guten Tag,<br>wir bestätigen den Eingang Ihrer Widerrufserklärung: Sie haben erklärt, einen Vertrag über einen Termin bei {{betrieb_name}} zu widerrufen.<!-- ENDE sie --><!-- WENN du -->Hallo,<br>wir bestätigen den Eingang deiner Widerrufserklärung: Du hast erklärt, einen Vertrag über einen Termin bei {{betrieb_name}} zu widerrufen.<!-- ENDE du --></td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
</table></td></tr>
<!-- WENN zugeordnet -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie -->Ihre Erklärung betrifft die Buchung {{nummer}}. Der Termin ist abgesagt.<!-- ENDE sie --><!-- WENN du -->Deine Erklärung betrifft die Buchung {{nummer}}. Der Termin ist abgesagt.<!-- ENDE du --></td></tr>
<!-- ENDE zugeordnet -->
<!-- WENN nach_frist -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie --><strong>Hinweis:</strong> Nach unseren Unterlagen war die Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen. {{betrieb_name}} prüft, wie es weitergeht, und meldet sich bei Ihnen.<!-- ENDE sie --><!-- WENN du --><strong>Hinweis:</strong> Nach unseren Unterlagen war die Widerrufsfrist bei Eingang deiner Erklärung bereits abgelaufen. {{betrieb_name}} prüft, wie es weitergeht, und meldet sich bei dir.<!-- ENDE du --></td></tr>
<!-- ENDE nach_frist -->
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN sie -->{{betrieb_name}} prüft Ihre Erklärung und meldet sich bei Ihnen, falls noch etwas zu klären ist.<!-- ENDE sie --><!-- WENN du -->{{betrieb_name}} prüft deine Erklärung und meldet sich bei dir, falls noch etwas zu klären ist.<!-- ENDE du --></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;"><!-- WENN sie -->Sie haben diese E-Mail erhalten, weil mit Ihrer E-Mail-Adresse ein Widerruf erklärt wurde. Waren Sie das nicht, können Sie diese Nachricht ignorieren.<!-- ENDE sie --><!-- WENN du -->Du hast diese E-Mail erhalten, weil mit deiner E-Mail-Adresse ein Widerruf erklärt wurde. Warst du das nicht, kannst du diese Nachricht ignorieren.<!-- ENDE du --></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{betrieb_adresse}}</div><div>{{betrieb_telefon}}</div><div><a class="mut" href="mailto:{{betrieb_email}}" style="color:#6b6964;text-decoration:none;">{{betrieb_email}}</a></div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K20 · Neue Buchung oder Terminanfrage (Terminplaner, an den Betrieb)
Betreff: {{betreff}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Betrieb (E-Mail-Adresse des Betriebs) · OHNE BCC (enthält Angaben von Gästen)
Platzhalter: {{betreff}}, {{kunde_name}}, {{leistung}}, {{person}}, {{datum}}, {{zeit}}, {{gast_name}}, {{gast_email}}, {{gast_telefon}}, {{gast_nachricht}}, {{portal_link}}
Optionale Blöcke (Anfrage statt fester Buchung): anfrage
Den Betreff und die Überschrift setzt der Server je Fall (Buchung oder Anfrage).
-->
<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>{{betreff}}</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;">
<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;">Terminplaner</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 ink" style="padding:44px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:24px;line-height:30px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{betreff}}</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>über Deinen Terminplaner ist ein neuer Termin eingegangen.</td></tr>
<!-- WENN anfrage -->
<tr><td class="px" style="padding:20px 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:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Diese Anfrage ist noch nicht bestätigt.</strong> Bitte nimm sie im Portal an oder lehne sie ab. Dein Gast wartet auf Deine Antwort.</td></tr></table></td></tr>
<!-- ENDE anfrage -->
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gast</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_name}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Nachricht</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_nachricht}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:26px 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_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Portal ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</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,117 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K21 · Termin abgesagt, verschoben oder widerrufen (Terminplaner, an den Betrieb)
Betreff: {{betreff}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Betrieb (E-Mail-Adresse des Betriebs) · OHNE BCC (enthält Angaben von Gästen)
Platzhalter: {{betreff}}, {{kunde_name}}, {{leistung}}, {{person}}, {{datum}}, {{zeit}}, {{gast_name}}, {{gast_email}}, {{gast_telefon}}, {{gast_nachricht}}, {{portal_link}}, {{alt_datum}}, {{alt_zeit}}, {{eingang}}
Optionale Blöcke (genau einer pro Mail, sie schließen sich gegenseitig aus): abgesagt, verschoben, widerrufen, nicht_zugeordnet, nach_frist, begonnen
Den Betreff und die Überschrift setzt der Server je Fall. Block nicht_zugeordnet: Erklärung ohne passende Buchung (Angaben des Absenders).
Block nach_frist: Widerrufsfrist bei Eingang abgelaufen. Block begonnen: Erklärung nach Terminbeginn, die Frist läuft noch (Betrieb prüft Erlöschen bzw. Wertersatz).
-->
<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>{{betreff}}</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;">
<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;">Terminplaner</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 ink" style="padding:44px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:24px;line-height:30px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{betreff}}</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}},</td></tr>
<!-- WENN abgesagt -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><strong>{{gast_name}}</strong> hat den Termin abgesagt. Der Zeitraum ist wieder frei.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
</table></td></tr>
<!-- ENDE abgesagt -->
<!-- WENN verschoben -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><strong>{{gast_name}}</strong> hat den Termin verschoben. Der alte Zeitraum ist wieder frei.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bisher</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{alt_datum}}, {{alt_zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Neu</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{datum}}, {{zeit}} Uhr</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
</table></td></tr>
<!-- ENDE verschoben -->
<!-- WENN widerrufen -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><strong>{{gast_name}}</strong> hat den Vertrag widerrufen. Der Termin ist abgesagt und der Zeitraum wieder frei. Bitte prüfe, ob schon etwas bezahlt wurde, das erstattet werden muss.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
</table></td></tr>
<!-- ENDE widerrufen -->
<!-- WENN nach_frist -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><strong>{{gast_name}}</strong> hat einen Widerruf erklärt. Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen. Der Termin ist unverändert, wir haben nichts abgesagt. Bitte prüfe die Erklärung und melde Dich bei Deinem Gast.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
</table></td></tr>
<!-- ENDE nach_frist -->
<!-- WENN begonnen -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte. Bitte prüfe, ob das Widerrufsrecht schon erloschen ist (vollständige Leistung nach Zustimmung zum vorzeitigen Beginn) oder ob Wertersatz in Frage kommt.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Name</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_name}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
</table></td></tr>
<!-- ENDE begonnen -->
<!-- WENN nicht_zugeordnet -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Über das Widerrufsformular ist eine Erklärung eingegangen, die wir keiner Buchung zuordnen konnten. Bitte prüfe die Angaben und melde Dich beim Absender.</td></tr>
<tr><td class="px" style="padding:24px 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="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Name</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_name}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Weitere Angaben</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_nachricht}}</td></tr>
</table></td></tr>
<!-- ENDE nicht_zugeordnet -->
<tr><td class="px" style="padding:26px 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_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Portal ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</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>
+7 -2
View File
@@ -15,13 +15,18 @@ export const KUNDENANSICHT_ALLOWED = [
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
// Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, Abschnitt 9)
['POST', /^\/api\/termine\/.+$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen)
const PASS_PREFIXES = ['/api/team/', '/api/admin/']
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen); oeffentliche Schnittstellen
// (/api/public/: Pinnwand, Terminplaner fuer Gaeste) kennen keine Sitzung und ignorieren ein Kundenansicht-Cookie im Browser
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/public/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
+29 -12
View File
@@ -2,10 +2,19 @@ import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
disable,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
} from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
* Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt.
*/
export const funktionenAdminRouter = Router()
@@ -37,13 +46,17 @@ async function ticketExists(ticketId) {
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
async function adminView(customerId) {
const projects = await listCustomerFeatures(customerId)
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs })
for (const p of projects) {
const news = p.features.news
const doc = await getFeatureDoc(customerId, p.projectId, 'news')
news.adminNote = doc?.adminNote || ''
news.ticketMissing = news.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
for (const key of FEATURE_KEYS) {
const view = p.features[key]
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
view.adminNote = doc?.adminNote || ''
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
}
}
return projects
}
@@ -56,9 +69,11 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => {
}
})
funktionenAdminRouter.post('/:customerId/news', async (req, res) => {
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try {
const { customerId } = req.params
const { customerId, key } = req.params
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
const { projectId, action } = req.body || {}
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
if (action !== 'activate' && action !== 'disable') {
@@ -70,12 +85,14 @@ funktionenAdminRouter.post('/:customerId/news', async (req, res) => {
await assertOwnProject(customerId, projectId)
const by = adminName(req)
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, 'news')
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, 'news', { by, adminNote })
await adminActivate(customerId, projectId, key, { by, adminNote })
} else {
if (!doc) throw Object.assign(new Error('Für dieses Projekt gibt es noch keine News-Pinnwand'), { status: 409 })
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 })
}
})
+3
View File
@@ -14,6 +14,7 @@ import {
markOrdered,
runActivation,
} from '../services/siteFeatures.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import {
ELIGIBILITY_MESSAGES,
@@ -104,6 +105,8 @@ router.get('/', requireSession, async (req, res) => {
projects,
setupEstimate: config.funktionen.setupEstimate,
consentText: config.funktionen.consentText,
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
termine: { verfuegbar: termineFuerKunde(req.session.email) },
})
} catch (err) {
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
+5 -22
View File
@@ -7,11 +7,10 @@ import { uploadSlot } from './chat.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 30
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
function fail(res, err) {
@@ -23,26 +22,10 @@ function fail(res, err) {
}
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = Date.now()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
function limitWrites(req, res, next) {
if (writeRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
}
return next()
}
const limitWrites = createWriteLimiter({ max: 30 }).middleware(
(req) => getSessionCustomerId(req),
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
)
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
+584
View File
@@ -0,0 +1,584 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import express, { Router } from 'express'
import { config } from '../config.js'
import { analyseDomains } from '../services/analyse.js'
import {
MELDUNG,
bestaetige,
fehlerMit,
fehlerSammler,
halte,
istObjekt,
pruefeEingabe,
pruefeSlot,
sageAbGast,
verschiebeGast,
vorschau,
widerrufeGast,
widerrufeOhneLink,
zeitenFuerToken,
} from '../services/termine/ablauf.js'
import { imZeitraum } from '../services/termine/buchungen.js'
import { brauchtZustimmungVorzeitig, emailAdresse, oeffentlicheEinstellung } from '../services/termine/einstellungen.js'
import { icsKalender } from '../services/termine/ics.js'
import { kontextPerSiteKey } from '../services/termine/kontext.js'
import {
darfAnfrage,
darfAnfrageSite,
darfBuchungsversuch,
darfG01,
darfHaltung,
darfWiderrufsversuch,
} from '../services/termine/limits.js'
import {
GESUNDHEIT_HINWEIS,
ZUSTIMMUNG_ERINNERUNG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_VORZEITIG,
formularHinweis,
widerrufsbelehrung,
} from '../services/termine/rechtstexte.js'
import { belegungAus, freieTage, freieZeiten } from '../services/termine/slots.js'
import { termineAus } from '../services/termine/schalter.js'
import { buchungsNummer, formToken, pruefeFormToken } from '../services/termine/token.js'
import { datumLang, monatInfo, zuUtc } from '../services/termine/zeit.js'
/**
* Oeffentliche Schnittstelle des Terminplaners: /api/public/termine/v1 (Spezifikation 2026-10-02-terminplaner-design.md,
* Abschnitt 8). Ohne Session, ohne Captcha; der Schutz besteht aus Herkunftspruefung (CORS), Honeypot und Formular-Token,
* Grenzen im Speicher (services/termine/limits.js) und einer Mindestantwortzeit.
*
* - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem
* globalen express.json (2 MB): so liest nie der grosse Parser den Rumpf einer oeffentlichen Anfrage. Alles unterhalb des
* Pfades beantwortet dieser Router selbst. Antworten sind immer JSON { ... } oder { fehler: { <feld|_>: Text }, code? };
* Rohtexte von Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar").
* - Antworten enthalten nie customerId, projectId, featureId, $id, Ticket- oder Stripe-IDs: nur Leistungs-/Ressourcen-IDs
* der Einstellung und die Buchungsnummer. Die Geschaeftsregeln stehen in services/termine/*; hier wird nur gelesen,
* geprueft, begrenzt und uebersetzt.
* - Unbekannter, inaktiver, abgeschalteter siteKey oder Modus off: immer 200 { aktiv: false } (GET und POST unter
* /:siteKey). Die Token-Seiten (/termin/*) bleiben fuer abgeschaltete Funktionen nutzbar (Absagen, Widerruf), bei Modus
* off antworten sie wie bei einem unbekannten Link, ohne Abfrage.
* - CORS: GET fuer alle (*); POST und Preflight nur von Seiten des Betriebs (Hosts des Projekts, analyseDomains), des
* Portals (Host von config.publicBaseUrl) oder gleicher Herkunft, die Token-Seiten nur von den beiden letzten. Eine
* fremde oder fehlende Origin ist 403. Fuer einen unbekannten siteKey laesst sich nichts pruefen: dort { aktiv: false }.
* Meldungen ohne Daten (429, 503, aktiv:false) tragen `*`, damit das Widget sie lesen kann.
* - Buchung (POST /:siteKey/buchungen), in dieser Reihenfolge: Grenze je IP (jeder Versuch zaehlt) => Funktion pruefen =>
* Herkunft => Honeypot kontakt_url und Formular-Token (zu schnell oder Honeypot: dieselbe Erfolgsantwort, nichts
* gespeichert; zu alt oder fremd: 400 "Bitte laden Sie die Seite neu.") => Eingabe und Zeit pruefen (Fehler des Gastes
* zuerst, damit keine Grenze etwas verraet) => Grenze je siteKey (429) => Grenze G01 je Adresse (still: dieselbe 201-Antwort,
* aber keine Buchung und keine Mail) => halten. "Zu viele offene Termine" und "gehalten" antworten gleich (kein Orakel
* fuer Dritte).
* - Mindestantwortzeit config.termine.minAntwortMs fuer die Buchung und beide Widerrufe (auch bei Fehlern), gemessen ab
* Beginn der Anfrage: verwischt, ob eine Adresse oder Buchung bekannt ist.
* - tage und zeiten: 10 Sekunden Speicher-Cache je (siteKey, Parameter); config und alle anderen Antworten ohne ETag.
*/
const router = Router()
const BODY_MAX = 16 * 1024
const TAG_MS = 24 * 60 * 60 * 1000
const CACHE_MS = 10 * 1000
const CACHE_MAX = 10000
const FEED_DATEI_RE = /^([A-Za-z0-9_-]{43})\.ics$/
const FEED_VOR_MS = 7 * TAG_MS
const FEED_NACH_MS = 90 * TAG_MS
const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } }
const TEXT_NICHT_GEFUNDEN = 'Nicht gefunden'
const NICHT_GEFUNDEN = { fehler: { _: TEXT_NICHT_GEFUNDEN } }
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.'
const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.'
// 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()
}
/** Speicher-Cache von tage und zeiten: Schluessel -> { bis (ms), body }; hoechstens CACHE_MAX Eintraege. */
const cache = new Map()
export function resetPublicTermineForTests() {
cache.clear()
uhr = () => new Date()
}
// ---------------------------------------------------------------- Antworten und Fehler
/** Fehler mit Statuscode fuer den Gast (4xx); `fehlerErgebnis` macht daraus die Antwort. */
const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS })
const nichtGefunden = () => fehlerMit(404, { _: TEXT_NICHT_GEFUNDEN })
const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' })
const unbekannterLink = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbekannt' })
const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {})
/** Antwort fuer einen Fehler: Fehler des Gastes (4xx mit `fehler` aus den Diensten) wie sie sind, alles andere 503 ohne Rohtext. */
function fehlerErgebnis(err) {
const status = err?.status
const fehler = nurTexte(err?.fehler)
if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) {
const body = { fehler }
if (typeof err.code === 'string') body.code = err.code
if (istObjekt(err.betrieb)) body.betrieb = nurTexte(err.betrieb)
return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
}
console.error(`[publicTermine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return { status: 503, body: NICHT_ERREICHBAR }
}
/** Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null }. */
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
const lesen = req.method === 'GET' || req.method === 'HEAD'
if (lesen) res.set('Access-Control-Allow-Origin', '*')
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') // Meldungen ohne Daten
res.status(status)
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
if (status === 204) return res.end() // Preflight: nur Kopfzeilen
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
return res.end(inhalt)
}
/**
* Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert (z. B. nicht serialisierbarer Wert): ein schlichtes 503
* mit fester Meldung, sofern noch nichts gesendet wurde; sonst wird die Antwort beendet. Eine Anfrage bleibt nie haengen und
* der async Handler lehnt nie unbehandelt ab (Express 4 faengt das nicht).
*/
function notantwort(res, err) {
try {
console.error(`[publicTermine] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
} catch {
// das Log darf die Notantwort nicht verhindern
}
try {
if (res.headersSent) return void res.end()
const inhalt = JSON.stringify(NICHT_ERREICHBAR)
res.statusCode = 503
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII
return void res.end(inhalt)
} catch {
try {
res.destroy()
} catch {
// nichts mehr zu tun
}
}
}
/** schreibe, aber mit der Notantwort statt eines Fehlers (fuer Stellen ausserhalb von route). */
function schreibeSicher(req, res, ergebnis, cors) {
try {
schreibe(req, res, ergebnis, cors)
} catch (err) {
notantwort(res, err)
}
}
/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Funktionen (GET kurz im Browser-Cache). */
const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' })
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder
* wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht
* schneller als config.termine.minAntwortMs (ab Beginn der Anfrage, auch beim Lesen des Rumpfes).
*/
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
try {
const cors = { erlaubt: null }
let ergebnis
try {
ergebnis = await fn(req, uhr(), cors)
} catch (err) {
ergebnis = fehlerErgebnis(err)
}
if (mindestdauer) {
const rest = config.termine.minAntwortMs - (performance.now() - req.termineStart)
if (rest > 0) await warte(rest)
}
schreibe(req, res, ergebnis, cors)
} catch (err) {
notantwort(res, err)
}
}
// ---------------------------------------------------------------- Herkunft
/**
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von
* config.publicBaseUrl (Gaesteseiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Ohne project (die
* Token-Seiten /termin/*) gelten nur die beiden letzten. Sonst null (fehlende, fremde, unlesbare Origin oder nicht in
* kanonischer Schreibweise).
*/
function erlaubteHerkunft(req, project) {
const origin = req.get('origin')
if (typeof origin !== 'string' || origin === '') return null
let url
try {
url = new URL(origin)
} catch {
return null
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
try {
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
} catch {
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
}
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
return erlaubt.has(url.host) ? origin : null
}
/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */
function pruefeHerkunft(req, cors, project) {
const origin = erlaubteHerkunft(req, project)
if (!origin) throw nichtErlaubt()
cors.erlaubt = origin
}
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
router.use((req, res, next) => {
req.termineStart = performance.now()
// Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen).
if (Number(req.get('content-length')) > BODY_MAX) {
res.set('Connection', 'close')
return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
}
return next()
})
router.use(express.json({ limit: BODY_MAX }))
/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */
const vorabantwort = (origin) => ({
status: 204,
body: '',
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
},
})
// ---------------------------------------------------------------- Token-Seiten (/termin/*)
const rumpf = (req) => (istObjekt(req.body) ? req.body : {})
/**
* Aufruf mit Token: Herkunft nur Portal und gleiche Herkunft, Grenze je IP (widerruf: zusaetzlich Widerrufe je IP), bei
* Modus off wie ein unbekannter Link ohne Abfrage. fn(body, now) liefert den Body der 200-Antwort.
*/
const terminRoute = (fn, { widerruf = false } = {}) =>
route(
async (req, now, cors) => {
pruefeHerkunft(req, cors, null)
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
if (widerruf && !darfWiderrufsversuch(req.ip, now)) throw zuViele(3600)
if (termineAus()) throw unbekannterLink()
return { body: await fn(rumpf(req), now) }
},
{ mindestdauer: widerruf },
)
router.post('/termin/vorschau', terminRoute((b, now) => vorschau(b.token, now)))
router.post('/termin/bestaetigen', terminRoute(async (b, now) => ({ ok: true, ...(await bestaetige(b.token, now)) })))
router.post('/termin/absagen', terminRoute(async (b, now) => ({ ok: true, ...(await sageAbGast(b.token, now)) })))
router.post(
'/termin/verschieben',
terminRoute(async (b, now) => ({ ok: true, ...(await verschiebeGast(b.token, { tag: b.tag, zeit: b.zeit }, now)) })),
)
router.post(
'/termin/widerruf',
terminRoute(async (b, now) => ({ ok: true, ...(await widerrufeGast(b.token, { name: b.name }, now)) }), { widerruf: true }),
)
// zeitenFuerToken unterscheidet Monat und Tag daran, welcher Schluessel gesetzt ist: fehlt der gefragte, ist er leer (=> 400)
router.post(
'/termin/tage',
terminRoute((b, now) => zeitenFuerToken(b.token, { monat: typeof b.monat === 'string' ? b.monat : '' }, now)),
)
router.post(
'/termin/zeiten',
terminRoute((b, now) => zeitenFuerToken(b.token, { tag: typeof b.tag === 'string' ? b.tag : '' }, now)),
)
router.options(
'/termin/:aktion',
route(async (req, now, cors) => {
pruefeHerkunft(req, cors, null)
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
return vorabantwort(cors.erlaubt)
}),
)
// ---------------------------------------------------------------- Kalender-Abo
const sha256 = (text) => createHash('sha256').update(text).digest()
/** Die Ressource, deren feedToken zum gegebenen passt (Vergleich in konstanter Zeit ueber alle Ressourcen); sonst null. */
function ressourceZuFeedToken(einstellung, token) {
const gegeben = sha256(token)
let treffer = null
for (const r of einstellung.ressourcen || []) {
if (typeof r?.feedToken === 'string' && timingSafeEqual(sha256(r.feedToken), gegeben)) treffer = r
}
return treffer
}
router.get(
'/kalender/:siteKey/:datei',
route(async (req, now) => {
const { siteKey, datei } = req.params
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const format = FEED_DATEI_RE.exec(datei)
const ctx = format ? await kontextPerSiteKey(siteKey, now) : null
const ressource = ctx ? ressourceZuFeedToken(ctx.einstellung, format[1]) : null
if (!ressource) throw nichtGefunden() // ohne Hinweis, was nicht stimmt
if (!darfAnfrageSite(siteKey, now)) throw zuViele(60)
const e = ctx.einstellung
const nowMs = now.getTime()
const liste = await imZeitraum(ctx.feature.$id, new Date(nowMs - FEED_VOR_MS), new Date(nowMs + FEED_NACH_MS))
const termine = liste
.filter((b) => b.ressourceId === ressource.id && b.status === 'bestaetigt')
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
.map((b) => ({
uid: `${b.$id}@termine.webklar.com`,
sequenz: Number.isInteger(b.icsSequenz) && b.icsSequenz >= 0 ? b.icsSequenz : 0,
start: b.startsAt,
ende: b.endsAt,
titel: b.quelle === 'sperre' ? b.leistungName || 'Gesperrt' : b.gastName ? `${b.leistungName}: ${b.gastName}` : b.leistungName,
ort: b.quelle === 'sperre' ? '' : e.betrieb.adresse,
beschreibung: b.quelle === 'sperre' ? '' : `Buchungsnummer: ${buchungsNummer(b.$id)}`,
}))
return {
body: icsKalender(termine, { name: `${e.betrieb.name} – ${ressource.name}`, jetzt: now }),
typ: 'text/calendar; charset=utf-8',
cache: 'private, max-age=300', // enthaelt Gastnamen: nur im Cache des Abonnenten, nicht in gemeinsamen Caches
headers: { 'X-Robots-Tag': 'noindex', 'Referrer-Policy': 'no-referrer' },
}
}),
)
// ---------------------------------------------------------------- Konfiguration, Tage, Zeiten
/** Lesende Abrufe: Grenze je IP, Funktion suchen ({ aktiv: false } wenn es sie nicht gibt), Grenze je siteKey. */
const lesenRoute = (fn) =>
route(async (req, now) => {
const { siteKey } = req.params
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await kontextPerSiteKey(siteKey, now)
if (!ctx) return aktivFalse(req)
if (!darfAnfrageSite(siteKey, now)) throw zuViele(60)
return fn(ctx, req, now)
})
router.get(
'/:siteKey/config',
lesenRoute((ctx, req, now) => {
const e = ctx.einstellung
const oeffentlich = oeffentlicheEinstellung(e, now)
const texte = {
storno: ZUSTIMMUNG_STORNO,
erinnerung: ZUSTIMMUNG_ERINNERUNG(e.erinnerung.stundenVorher),
vorzeitig: ZUSTIMMUNG_VORZEITIG(e.betrieb.name),
gesundheit: GESUNDHEIT_HINWEIS,
}
// Das Widget zeigt die Belehrung vor "Zahlungspflichtig buchen" (R14); fehlt eine Angabe, wirft sie => 503 statt einer Buchung ohne Belehrung
if (oeffentlich.widerruf) texte.widerrufsbelehrung = widerrufsbelehrung(e.betrieb)
return {
// formMinSekunden (R53): so alt muss das Formular-Token sein, sonst antwortet POST ohne Speichern; das Widget wartet entsprechend
body: { aktiv: true, ...oeffentlich, hinweis: formularHinweis(e), texte, formToken: formToken(req.params.siteKey, now), formMinSekunden: config.termine.formMinSekunden },
cache: 'public, max-age=60', // das Formular-Token ist bei jedem Abruf neu: kein ETag
}
}),
)
/** Leistung und Ressource aus der Abfrage (wie das Formular sie annimmt); Fehler gehen an fehl. => { leistung, ressource } oder null */
function leistungUndRessource(e, query, fehl) {
const leistung = typeof query.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === query.leistung && l.aktiv) : undefined
const aktiv = new Set((e.ressourcen || []).filter((r) => r.aktiv).map((r) => r.id))
const infrage = leistung ? (leistung.ressourcen || []).filter((id) => aktiv.has(id)) : []
if (!leistung || infrage.length === 0) {
fehl('leistung', MELDUNG.leistung)
return null
}
const ressource = query.ressource === undefined ? 'egal' : query.ressource
if (ressource !== 'egal' && !(typeof ressource === 'string' && infrage.includes(ressource))) {
fehl('ressource', MELDUNG.ressource)
return null
}
return { leistung, ressource }
}
/** Antwort aus dem 10-Sekunden-Speicher oder frisch berechnet. */
async function ausCache(schluessel, nowMs, berechne) {
const gemerkt = cache.get(schluessel)
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.body
const body = await berechne()
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(schluessel, { bis: nowMs + CACHE_MS, body })
return body
}
router.get(
'/:siteKey/tage',
lesenRoute(async (ctx, req, now) => {
const e = ctx.einstellung
const { fehler, fehl } = fehlerSammler()
const wahl = leistungUndRessource(e, req.query, fehl)
const monat = typeof req.query.monat === 'string' ? req.query.monat : ''
const info = monatInfo(monat)
const von = info ? zuUtc(`${monat}-01`, '00:00') : null
const bis = info ? zuUtc(`${info.nachher}-01`, '00:00') : null
if (!von || !bis) fehl('monat', MELDUNG.monat)
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
const schluessel = `tage|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${monat}`
const body = await ausCache(schluessel, now.getTime(), async () => {
const imMonat = await imZeitraum(ctx.feature.$id, von, bis)
const tage = freieTage({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, monat, belegung: belegungAus(imMonat, now), now })
return { tage, monat: info }
})
return { body, cache: 'public, max-age=15' }
}),
)
router.get(
'/:siteKey/zeiten',
lesenRoute(async (ctx, req, now) => {
const e = ctx.einstellung
const { fehler, fehl } = fehlerSammler()
const wahl = leistungUndRessource(e, req.query, fehl)
const tag = typeof req.query.tag === 'string' ? req.query.tag : ''
const mittag = zuUtc(tag, '12:00')
if (!mittag) fehl('tag', MELDUNG.tag)
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
const schluessel = `zeiten|${req.params.siteKey}|${wahl.leistung.id}|${wahl.ressource}|${tag}`
const body = await ausCache(schluessel, now.getTime(), async () => {
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
const zeiten = freieZeiten({ einstellung: e, leistungId: wahl.leistung.id, ressourceId: wahl.ressource, tag, belegung: belegungAus(amTag, now), now })
return {
zeiten: zeiten.map((z) => z.uhrzeit),
datumLang: datumLang(mittag),
// Zustimmung zum vorzeitigen Beginn: serverseitig fuer diesen Tag, das Widget rechnet nie selbst
vorzeitigNoetig: zeiten.some((z) => brauchtZustimmungVorzeitig(e, wahl.leistung, z.start, now)),
}
})
return { body, cache: 'no-store' }
}),
)
// ---------------------------------------------------------------- Buchen und Widerruf (POST /:siteKey/...)
/** Maskierte Adresse fuer die Antwort: 'm***@gmail.com' (erstes Zeichen, Rest des Namens verdeckt); ohne Adresse '***'. */
function maskiere(adresse) {
const at = typeof adresse === 'string' ? adresse.lastIndexOf('@') : -1
return at < 1 ? '***' : `${[...adresse][0]}***${adresse.slice(at)}`
}
/** Die Erfolgsantwort der Buchung (auch bei Honeypot, zu schnell, zu viele offene Termine und Mailgrenze). */
const erfolg = (adresse) => ({ status: 201, body: { ok: true, email: maskiere(adresse), minuten: config.termine.holdMinutes } })
/** Nur die Felder des Formulars (kein Durchreichen fremder Schluessel an die Dienste). */
const formularFelder = (b) => ({
leistung: b.leistung,
ressource: b.ressource,
tag: b.tag,
zeit: b.zeit,
name: b.name,
email: b.email,
telefon: b.telefon,
nachricht: b.nachricht,
zusatz: b.zusatz,
erinnerung: b.erinnerung,
zustimmungen: b.zustimmungen,
})
router.post(
'/:siteKey/buchungen',
route(
async (req, now, cors) => {
const { siteKey } = req.params
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
const ctx = await kontextPerSiteKey(siteKey, now)
if (!ctx) return aktivFalse(req)
pruefeHerkunft(req, cors, ctx.project)
const b = req.body
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG.formular })
// Honeypot (jeder Wert ausser leer) und zu schnell abgeschickt: dieselbe Antwort wie sonst, nichts wird gespeichert
const honig = b.kontakt_url
const falle = honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '')
if (falle) return erfolg(emailAdresse(b.email))
const token = pruefeFormToken(siteKey, b.formToken, now)
if (token === 'zu_schnell') return erfolg(emailAdresse(b.email))
if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
// Fehler des Gastes (Eingabe, nicht mehr freie Zeit) vor allen Grenzen, die von der Adresse abhaengen
const eingabe = formularFelder(b)
const gepruft = pruefeEingabe(ctx.einstellung, eingabe, now)
await pruefeSlot(ctx, gepruft, now)
if (!darfHaltung(siteKey, now)) throw zuViele(3600)
if (!darfG01(ctx.feature.$id, gepruft.email, now)) return erfolg(gepruft.email) // still: keine Buchung, keine Mail
const ergebnis = await halte(ctx, eingabe, now)
return erfolg(ergebnis.email)
},
{ mindestdauer: true },
),
)
router.post(
'/:siteKey/widerruf',
route(
async (req, now, cors) => {
if (!darfWiderrufsversuch(req.ip, now)) throw zuViele(3600)
const ctx = await kontextPerSiteKey(req.params.siteKey, now)
if (!ctx) return aktivFalse(req)
pruefeHerkunft(req, cors, ctx.project)
const b = req.body
const erklaerung = istObjekt(b) ? { name: b.name, email: b.email, nummer: b.nummer } : b
const { eingang } = await widerrufeOhneLink(ctx, erklaerung, now)
return { body: { ok: true, eingang } }
},
{ mindestdauer: true },
),
)
// Preflight unter /:siteKey/*: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne Funktion nichts zu pruefen
router.options(
'/:siteKey/:was',
route(async (req, now, cors) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await kontextPerSiteKey(req.params.siteKey, now)
if (!ctx) return vorabantwort('*')
pruefeHerkunft(req, cors, ctx.project)
return vorabantwort(cors.erlaubt)
}),
)
// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers
router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN }))
// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers
router.use((err, req, res, _next) => {
if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG })
return schreibeSicher(req, res, fehlerErgebnis(err))
})
export default router
+480
View File
@@ -0,0 +1,480 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { belegungAus, freieZeiten } from '../services/termine/slots.js'
import { buchungsNummer } from '../services/termine/token.js'
import { VORLAGEN, ausVorlage, vorlagenListe } from '../services/termine/vorlagen.js'
import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine/zeit.js'
/**
* Kunden-Schnittstelle des Terminplaners: /api/termine (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 9).
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder
* `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409.
* Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in
* services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion,
* termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail).
*
* Fehler: { error: <erster Text>, fehler?: { <feld|_>: Text }, code? }. `error` liest das Portal (wie bei allen Routen),
* `fehler` zeigt Feldfehler am Feld (Pfade wie 'betrieb.telefon' oder 'leistungen.s_abcd1234'). Texte von Appwrite oder
* unbekannten Fehlern verlassen den Server nie (500 "Interner Serverfehler").
* Antworten sind nie zwischenspeicherbar (Gastdaten, Kalender-Links).
*/
const router = Router()
const MAX_WRITES_PER_HOUR = 120
const MAX_TAGE = 62
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
const STATUS_TEXT = {
gehalten: 'Reserviert',
angefragt: 'Angefragt',
bestaetigt: 'Bestätigt',
erledigt: 'Erledigt',
nichterschienen: 'Nicht erschienen',
abgesagt: 'Abgesagt',
abgelehnt: 'Abgelehnt',
verfallen: 'Verfallen',
}
const CSV_KOPF = ['Nummer', 'Status', 'Datum', 'Uhrzeit', 'Leistung', 'Person/Platz', 'Name', 'E-Mail', 'Telefon', 'Nachricht']
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
fremd: 'Kein Zugriff auf dieses Projekt.',
nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.',
nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.',
abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.',
keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
vorlage: 'Bitte wähle eine Vorlage aus der Liste.',
vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.',
version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.',
art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.',
zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).',
zeitraumLang: `Der Zeitraum darf höchstens ${MAX_TAGE} Tage umfassen.`,
zeitraumUmgekehrt: 'Das Ende darf nicht vor dem Beginn liegen.',
status: 'Ungültiger Status.',
tag: 'Bitte ein Datum auswählen.',
leistung: 'Bitte eine Leistung auswählen.',
ressource: 'Unbekannte Person oder unbekannter Platz.',
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
keineBuchung: 'Diesen Termin gibt es nicht.',
ungueltig: 'Ungültige Eingabe.',
}
// 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()
}
// ---------------------------------------------------------------- Fehler
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */
const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) })
/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 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 eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET'
if (!eigen || status < 400 || status >= 500) {
console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return res.status(500).json({ error: 'Interner Serverfehler' })
}
const body = { error: Object.values(texte)[0] ?? 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)
}
}
// ---------------------------------------------------------------- Schreib-Limit
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetTermineRoutesForTests() {
limiter.reset()
uhr = () => new Date()
}
// ---------------------------------------------------------------- Projekt und Funktion
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
/**
* Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active.
* Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409).
*/
const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => {
const projectId = projectIdOf(req)
if (!projectId) throw stop(400, MELDUNG.projekt, undefined, 'projectId')
const customerId = getSessionCustomerId(req)
try {
req.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
}
req.projectId = projectId
if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
const feature = await getFeatureDoc(customerId, projectId, 'termine')
const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null
if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet')
req.ctx = ctx
return next()
})
/** Wer ändert? Für updatedBy der Einstellung (Kundenansicht: der Mitarbeiter, sonst der Kunde). */
const von = (req) => (isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : `kunde:${getSessionCustomerId(req)}`)
/** Buchungen, Zeiten und Aktionen brauchen gespeicherte Einstellungen (Betriebsangaben). */
function mitEinstellung(req) {
if (!req.ctx.einstellung) throw stop(409, MELDUNG.keineEinstellung, 'keine_einstellung')
return req.ctx
}
// ---------------------------------------------------------------- Einstellungen
/** Einstellung für den Browser: ohne Feed-Token (die Links stehen in `feeds`, neue Token vergibt nur der Server). */
const fuerKunde = (e) => ({ ...e, ressourcen: (e.ressourcen || []).map(({ feedToken: _geheim, ...rest }) => rest) })
const feedUrl = (siteKey, token) => `${config.publicBaseUrl}/api/public/termine/v1/kalender/${siteKey}/${token}.ics`
/** { [ressourceId]: Kalender-Link } aller Personen und Plätze. */
function feedLinks(ctx) {
const links = {}
for (const r of ctx.einstellung?.ressourcen || []) {
if (typeof r?.id === 'string' && typeof r.feedToken === 'string') links[r.id] = feedUrl(ctx.feature.siteKey, r.feedToken)
}
return links
}
const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[z])
/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */
function einbauSnippet(ctx) {
const telefon = ctx.einstellung?.betrieb?.telefon
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
return [
`<div data-webklar-termine="${esc(ctx.feature.siteKey)}" data-api="${esc(config.publicBaseUrl)}" data-modus="inline">`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(config.publicBaseUrl)}/embed/termine.js" defer></script>`,
].join('\n')
}
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => {
const { ctx } = req
const e = ctx.einstellung
res.json({
state: ctx.feature.state,
aktivSeit: ctx.feature.activatedAt || null,
einstellung: e ? fuerKunde(e) : null,
version: ctx.version,
vorlagen: vorlagenListe(),
feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {},
siteKey: ctx.feature.siteKey || null,
einbau: { snippet: einbauSnippet(ctx) },
anfragenOffen: await zaehleAnfragen(ctx.feature.$id),
fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar
})
}))
/** Betriebsangaben aus dem Kundenkonto für eine Vorlage: Name/Firma, Anschrift, E-Mail (analysePrivacyData) und Telefon. */
async function betriebAusKonto(customerId) {
let kunde = null
try {
kunde = await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err?.status !== 404) throw err
}
const daten = analysePrivacyData(kunde)
return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' }
}
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const key = req.body?.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
const betrieb = await betriebAusKonto(getSessionCustomerId(req))
const now = uhr()
let gespeichert
try {
// version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(req.ctx.feature, () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}, { version: 0, by: von(req), now })
} catch (err) {
throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
}
await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`)
res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
}))
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const { version, einstellung: eingabe } = req.body ?? {}
if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
const now = uhr()
const feature = req.ctx.feature
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now })
if (ergebnis.fehler) {
throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler })
}
return ergebnis.einstellung
}, { version, by: von(req), now })
await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert')
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version })
}))
router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const ressourceId = String(req.params.id)
const now = uhr()
// Baut auf dem aktuellen Stand auf (version null) und ersetzt nur das Token dieser einen Person/dieses Platzes
const gespeichert = await speichereEinstellung(req.ctx.feature, (bisher) => {
const ressourcen = Array.isArray(bisher?.ressourcen) ? bisher.ressourcen : []
if (!ressourcen.some((r) => r?.id === ressourceId)) throw stop(404, MELDUNG.keineRessource, 'unbekannt')
return { ...bisher, ressourcen: ressourcen.map((r) => (r.id === ressourceId ? { ...r, feedToken: neuerFeedToken() } : r)) }
}, { version: null, by: von(req), now })
const token = gespeichert.einstellung.ressourcen.find((r) => r.id === ressourceId).feedToken
await logIfKundenansicht(req, 'termin_einstellungen', 'Kalender-Link erneuert')
// version: die Änderung zählt die Version hoch, die offene Einstellungsseite braucht die neue für ihr nächstes Speichern
res.json({ url: feedUrl(req.ctx.feature.siteKey, token), version: gespeichert.version })
}))
// ---------------------------------------------------------------- Buchungen lesen
const iso = (wert) => {
const zeit = wert ? new Date(wert) : null
return zeit && !Number.isNaN(zeit.getTime()) ? zeit.toISOString() : null
}
/** Eine Buchung für das Portal. datum = Berliner Tag 'JJJJ-MM-TT', zeit = 'HH:MM' (Berliner Wanduhr). */
function buchungJson(b, zustellFehlerIds) {
const start = new Date(b.startsAt)
const ende = new Date(b.endsAt)
const istSperre = b.quelle === 'sperre'
const { tag, uhrzeit: zeit } = ausUtc(start)
const leistung = istSperre ? '' : b.leistungName || ''
return {
id: b.$id,
nummer: buchungsNummer(b.$id),
status: b.status,
quelle: b.quelle || 'online',
startsAt: start.toISOString(),
endsAt: ende.toISOString(),
datum: tag,
zeit,
leistungId: b.leistungId || '',
leistung,
dauer: Math.round((ende.getTime() - start.getTime()) / 60000),
titel: istSperre ? b.leistungName || 'Gesperrt' : b.gastName ? `${leistung}: ${b.gastName}` : leistung,
person: b.ressourceName || '',
ressourceId: b.ressourceId || '',
gastName: b.gastName || '',
gastEmail: b.gastEmail || '',
gastTelefon: b.gastTelefon || '',
gastNachricht: b.gastNachricht || '',
erinnerung: b.erinnerung === true,
zustellFehler: zustellFehlerIds.has(b.$id),
angenommenAt: iso(b.angenommenAt),
widerrufenAt: iso(b.widerrufenAt),
abgesagtVon: b.abgesagtVon || '',
absageGrund: b.absageGrund || '',
}
}
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
/**
* Buchungen im Zeitraum `von` bis `bis` (Berliner Tage 'JJJJ-MM-TT', beide Tage eingeschlossen, beide Pflicht), nach Beginn
* sortiert, mit Sperren. Zeitraum: `bis` darf höchstens 62 Tage nach `von` liegen (bis ≤ von + 62 Tage, also bis zu 63
* Kalendertage) und nicht davor. `status` (optional) ist eine Liste bekannter Status, durch Komma getrennt; ohne Angabe
* kommen alle Status.
*/
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
const ctx = req.ctx
const { von: vonTag, bis: bisTag, status } = req.query
if (!tagGueltig(vonTag) || !tagGueltig(bisTag)) throw stop(400, MELDUNG.zeitraum, undefined, 'von')
if (bisTag < vonTag) throw stop(400, MELDUNG.zeitraumUmgekehrt, undefined, 'bis')
if (bisTag > plusTage(vonTag, MAX_TAGE)) throw stop(400, MELDUNG.zeitraumLang, undefined, 'bis')
let gewollt = null
if (typeof status === 'string' && status !== '') {
gewollt = status.split(',').map((s) => s.trim()).filter(Boolean)
if (gewollt.length === 0 || gewollt.some((s) => !STATUS.includes(s))) throw stop(400, MELDUNG.status, undefined, 'status')
} else if (status !== undefined && typeof status !== 'string') {
throw stop(400, MELDUNG.status, undefined, 'status')
}
const anfang = zuUtc(vonTag, '00:00')
const ende = zuUtc(bisTag, '24:00')
const liste = (await imZeitraum(ctx.feature.$id, anfang, ende))
.filter((b) => new Date(b.endsAt).getTime() > anfang.getTime()) // Pause nach dem Termin zählt nicht zum Zeitraum
.filter((b) => gewollt === null || gewollt.includes(b.status))
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
const mitMail = await zustellFehler(liste.filter((b) => b.quelle !== 'sperre').map((b) => b.$id))
res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) })
}))
/**
* Freie Startzeiten eines Tages für den Betrieb (Termin eintragen, Verschieben): ohne Vorlauf und Horizont, nie in der
* Vergangenheit. `leistung` und `ressource` (Vorgabe 'egal') wählen, was gesucht wird.
* Mit `ohne=<Buchungs-ID>` (Verschieben) gelten Leistung, Dauer und Person der Buchung, genau wie beim Verschieben selbst
* (ablauf.js verschiebeZeiten, Ruling R42); `leistung` und `ressource` aus der Abfrage werden dann ignoriert. Unbekannte
* Buchung, Buchung einer anderen Funktion oder Sperre => 404.
*/
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
const ctx = mitEinstellung(req)
const { leistung, ressource = 'egal', tag, ohne } = req.query
const now = uhr()
if (ohne !== undefined) {
if (typeof ohne !== 'string' || !ID_RE.test(ohne)) throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
const buchung = await hole(ohne)
if (!buchung || buchung.featureId !== ctx.feature.$id || buchung.quelle === 'sperre') throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
const { zeiten } = await verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln: true })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
}
if (typeof ressource !== 'string' || (ressource !== 'egal' && !(ctx.einstellung.ressourcen || []).some((r) => r.id === ressource))) {
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
}
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
const zeiten = freieZeiten({
einstellung: ctx.einstellung,
leistungId: leistung,
ressourceId: ressource,
tag,
belegung: belegungAus(amTag, now),
now,
ohneRegeln: true,
})
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}))
// ---------------------------------------------------------------- Buchungen schreiben
router.post('/buchungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const ctx = mitEinstellung(req)
const { projectId: _projekt, art, ...roh } = req.body ?? {}
if (art !== 'manuell' && art !== 'sperre') throw stop(400, MELDUNG.art, undefined, 'art')
const now = uhr()
const ergebnis = art === 'manuell' ? await manuell(ctx, roh, now) : await sperre(ctx, roh, now)
await logIfKundenansicht(req, 'termin_manuell', art)
res.status(201).json(ergebnis)
}))
router.post('/buchungen/:id/aktion', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const ctx = mitEinstellung(req)
const { projectId: _projekt, ...roh } = req.body ?? {}
const id = String(req.params.id)
const ergebnis = await aktion(ctx, id, roh, uhr())
// ein zweiter Klick ändert nichts und steht nicht im Protokoll
if (!ergebnis.schon) await logIfKundenansicht(req, 'termin_aktion', `${roh.aktion} ${buchungsNummer(id)}`)
res.json(ergebnis)
}))
router.delete('/buchungen/:id', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const ctx = mitEinstellung(req)
const id = String(req.params.id)
const ergebnis = await entsperre(ctx, id) // nur Sperren; alles andere ist 404
await logIfKundenansicht(req, 'termin_aktion', `entsperren ${buchungsNummer(id)}`)
res.json(ergebnis)
}))
// ---------------------------------------------------------------- Export
/**
* Eine Zelle der CSV-Datei: Anführungszeichen, Strichpunkt und Zeilenumbrüche werden gequotet; Zellen, die ein
* Tabellenprogramm als Formel lesen würde (= + - @ am Anfang, auch nach Leerraum, Tab oder CR; Tab und CR am Anfang),
* bekommen ein ' davor.
*/
function csvZelle(wert) {
let zelle = String(wert ?? '')
if (/^\s*[=+\-@]/.test(zelle) || /^[\t\r]/.test(zelle)) zelle = `'${zelle}`
return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle
}
router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => {
const ctx = req.ctx
// alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren
const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z')))
.filter((b) => b.quelle !== 'sperre')
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
const zeilen = [CSV_KOPF, ...liste.map((b) => {
const start = new Date(b.startsAt)
return [
buchungsNummer(b.$id),
STATUS_TEXT[b.status] ?? b.status,
datumKurz(start),
uhrzeit(start),
b.leistungName,
b.ressourceName,
b.gastName,
b.gastEmail,
b.gastTelefon,
b.gastNachricht,
]
})]
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="termine.csv"',
'X-Content-Type-Options': 'nosniff',
})
res.send(csv)
}))
export default router
+48 -2
View File
@@ -25,7 +25,8 @@ function formatRequestBody(body, method, { raw = false } = {}) {
return payload
}
async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) {
/** raw: true schickt den Body unverpackt (sonst wird er zu { data, documentId }; Pfade unter /users sind immer roh). */
async function adminFetch(path, { method = 'GET', body, queries = [], raw = false } = {}) {
if (!config.appwrite.apiKey) {
const error = new Error('APPWRITE_API_KEY fehlt in .env')
error.status = 500
@@ -37,7 +38,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) {
url.searchParams.append('queries[]', q)
}
const useRawBody = path.startsWith('/users')
const useRawBody = raw || path.startsWith('/users')
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
const response = await fetch(url.toString(), {
@@ -77,6 +78,12 @@ export const Query = {
limit: (n) => ({ method: 'limit', values: [n] }),
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }),
greaterThanEqual: (attribute, value) => ({ method: 'greaterThanEqual', attribute, values: [value] }),
lessThanEqual: (attribute, value) => ({ method: 'lessThanEqual', attribute, values: [value] }),
/** Beide Grenzen inklusiv */
between: (attribute, start, end) => ({ method: 'between', attribute, values: [start, end] }),
/** Seite nach dem Dokument mit dieser ID (zusammen mit orderAsc/orderDesc und limit) */
cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }),
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
}
@@ -157,6 +164,34 @@ export async function listDocuments(collectionId, queries = []) {
return result.documents
}
/**
* Ein Aufruf mit allen Seiten: sortiert nach $id, Seite fuer Seite per cursorAfter, bis eine Seite kuerzer als
* pageSize ist. queries enthalten nur Filter, nie limit/order/cursor (die setzt die Funktion selbst). Mehr als max
* Treffer ist ein Fehler (Schutz vor unbeabsichtigtem Vollabzug).
*/
export async function listAllDocuments(collectionId, queries = [], { pageSize = 500, max = 20000 } = {}) {
if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error(`listAllDocuments: pageSize muss eine ganze Zahl ab 1 sein, nicht ${pageSize}`)
const all = []
let cursor = null
for (;;) {
const page = await listDocuments(collectionId, [
...queries,
Query.limit(pageSize),
Query.orderAsc('$id'),
...(cursor ? [Query.cursorAfter(cursor)] : []),
])
all.push(...page)
if (all.length > max) {
const error = new Error(`listAllDocuments: mehr als ${max} Dokumente in ${collectionId}`)
error.status = 500
error.type = 'list_limit_exceeded'
throw error
}
if (page.length < pageSize) return all
cursor = page[page.length - 1].$id
}
}
export async function getCustomerByAppwriteUserId(appwriteUserId) {
const docs = await listDocuments(config.collections.customers, [
Query.equal('appwriteUserId', appwriteUserId),
@@ -218,6 +253,17 @@ export async function createDocument(collectionId, data, documentId = ID.unique(
})
}
/**
* Mehrere Dokumente in einer Anfrage (Appwrite 1.8.1 Bulk-Create, hoechstens 100): alles oder nichts. Ist eine
* $id schon vergeben, antwortet Appwrite 409 document_already_exists und schreibt nichts (err.status === 409).
* Jedes Dokument bringt seine $id mit: [{ $id, ...felder }]. Liefert die angelegten Dokumente.
*/
export async function createDocuments(collectionId, documents) {
if (documents.length === 0) return []
const result = await adminFetch(collectionPath(collectionId), { method: 'POST', body: { documents }, raw: true })
return result.documents
}
export async function deleteDocument(collectionId, documentId) {
return adminFetch(`${collectionPath(collectionId)}/${documentId}`, {
method: 'DELETE',
+9
View File
@@ -282,6 +282,15 @@ export async function deleteCustomerWithPortalAccess(customerId) {
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
}
// Terminplaner abschalten: Gaeste koennen ihre Termine noch absagen, die Daten loescht der Tageslauf 30 Tage nach disabledAt.
// Fehler halten das Loeschen nicht auf (wie bei der Analyse).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' })
} catch (err) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+12 -1
View File
@@ -5,7 +5,18 @@ import { createDocument, deleteDocument, listDocuments, Query } from './appwrite
* Protokoll der Kundenansicht (Datenschutzerklaerung Abschnitt 6): wer wann das Portal eines Kunden geoeffnet
* oder darin etwas geaendert hat. Schreiben nur ueber den Server-Schluessel; Eintraege leben 12 Monate.
*/
export const VIEW_ACTIONS = ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht']
export const VIEW_ACTIONS = [
'geoeffnet',
'beendet',
'abgelaufen',
'news_angelegt',
'news_bild',
'news_geaendert',
'news_geloescht',
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
]
const DETAIL_MAX = 300
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000
+5 -4
View File
@@ -5,7 +5,8 @@ import { config } from '../config.js'
/**
* HTML-Mails aus den Vorlagen in server/mail-templates (Claude-Design-Projekt
* "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team).
* "Webklar Bestätigungsmail gestalten"; K = an Kunden, T = intern ans Team, G = an Gäste unserer Kunden
* (Terminplaner, neutrales Layout ohne WEBklar-Logo)).
*
* Jede Vorlage beschreibt sich im Kopfkommentar selbst:
* Betreff: Text mit {{platzhaltern}}
@@ -23,10 +24,10 @@ const DESIGN_BASE_URL = 'https://project.webklar.com'
const cache = new Map()
/** IDs aller Vorlagen (K01, K02, ..., T11) */
/** IDs aller Vorlagen (G01, ..., K01, K02, ..., T11) */
export function templateIds() {
return readdirSync(TEMPLATE_DIR)
.filter((f) => /^[KT]\d{2} .*\.html$/.test(f))
.filter((f) => /^[GKT]\d{2} .*\.html$/.test(f))
.map((f) => f.slice(0, 3))
.sort()
}
@@ -43,7 +44,7 @@ export function loadTemplate(id) {
const file = readdirSync(TEMPLATE_DIR).find((f) => f.startsWith(`${id} `) && f.endsWith('.html'))
if (!file) throw new Error(`Mail-Vorlage ${id} fehlt`)
const source = readFileSync(path.join(TEMPLATE_DIR, file), 'utf8')
const header = source.match(/<!--[ \t]*\n\s*([KT]\d{2}) ·[\s\S]*?-->\n?/)
const header = source.match(/<!--[ \t]*\n\s*([GKT]\d{2}) ·[\s\S]*?-->\n?/)
if (header?.[1] !== id) throw new Error(`Mail-Vorlage ${file}: Kopfkommentar fehlt`)
const field = (label) => header[0].match(new RegExp(`^\\s*${label}[^:\\n]*:[ \\t]*(.*)$`, 'm'))?.[1]?.trim() || ''
const template = {
+38
View File
@@ -176,6 +176,44 @@ export async function sendCustomerMail(to, { attachments = [], bccTeam = true, .
})
}
// Genau ein Postfach (Terminplaner: kein Mail-Relais ueber Listen oder Anzeigenamen), hoechstens 254 Zeichen
const SINGLE_MAILBOX = /^[^\s,;<>"\\]+@[^\s,;<>"\\]+$/
const checkMailbox = (value, name) => {
if (typeof value !== 'string' || value.length > 254 || !SINGLE_MAILBOX.test(value)) {
throw new TypeError(`${name}: genau eine E-Mail-Adresse erwartet`)
}
}
const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').trim()
/**
* Terminplaner: Mail an einen Gast (Spec 2026-10-02 Abschnitt 6). Verschickt schon gerenderte Inhalte (Betreff, HTML, Text
* kommen aus termineOutbox/G-Vorlagen); Absendername = Betrieb, Adresse bleibt SMTP_FROM, Antworten gehen an `replyTo`
* (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an.
* Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung.
* @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string,
* ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} message
*/
export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
checkMailbox(to, 'to')
if (replyTo) checkMailbox(replyTo, 'replyTo')
const name = oneLine(fromName)
const subj = oneLine(subject)
if (!name) throw new TypeError('fromName fehlt')
if (!subj) throw new TypeError('subject fehlt')
if (ics && !['REQUEST', 'CANCEL', 'PUBLISH'].includes(icsMethod)) throw new TypeError('icsMethod muss REQUEST, CANCEL oder PUBLISH sein')
return mailer.sendMail({
from: { name, address: config.smtp.from },
replyTo: replyTo || undefined,
to,
subject: subj,
text,
html,
icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined,
})
}
/**
* Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC.
* Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist.
+44 -9
View File
@@ -6,7 +6,12 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
export const FEATURE_KEYS = ['news', 'termine']
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */
export const FEATURE_LABELS = {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
}
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
export const DISABLED_MESSAGE =
@@ -119,16 +124,19 @@ async function featureView(doc) {
}
}
export async function listCustomerFeatures(customerId) {
/** Alle Funktionsdokumente eines Kunden (alle Projekte, alle Funktionen). */
export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
}
/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const result = []
for (const p of projects) {
const features = {}
@@ -287,6 +295,8 @@ export async function activate(docId, { by, adminNote } = {}) {
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('active'),
activatedAt: new Date().toISOString(),
// Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker)
disabledAt: null,
...(note ? { adminNote: note } : {}),
})
}
@@ -302,6 +312,30 @@ export async function disable(docId, { by, adminNote } = {}) {
return doc
}
/**
* Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete
* behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem
* abgeschaltet und danach der erste Fehler weitergereicht.
* @returns {Promise<object[]>} die abgeschalteten Dokumente
*/
export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) {
assertKey(key)
const docs = (await listCustomerFeatureDocs(customerId)).filter(
(d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled'
)
const disabled = []
let failure = null
for (const doc of docs) {
try {
disabled.push(await disable(doc.$id, { by, adminNote }))
} catch (err) {
failure ||= err
}
}
if (failure) throw failure
return disabled
}
// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert.
// Dynamischer Import: analyse.js importiert dieses Modul.
async function stopAnalyseCollecting(doc) {
@@ -352,11 +386,12 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
return activated
}
export async function findActiveBySiteKey(siteKey) {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null
/** 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
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', 'news'),
Query.equal('featureKey', featureKey),
Query.equal('state', 'active'),
Query.limit(1),
])
File diff suppressed because it is too large. Load diff
+451
View File
@@ -0,0 +1,451 @@
import { randomUUID } from 'node:crypto'
import { withLocks } from '../keyedLock.js'
import { aendere, hole, legeAn, loesche } from './buchungen.js'
import { text as bereinigterText } from './einstellungen.js'
import { anGast } from './mails.js'
import { verwerfeWartende } from './outbox.js'
import {
MELDUNG,
VERWALTBAR,
fehlerMit,
fehlerSammler,
gibFreiSicher,
iso,
istObjekt,
mailSicher,
protokolliere,
pruefeEingabe,
pruefeSlot,
pruefeVerschiebung,
verschiebeKern,
zeitVergeben,
} from './ablauf.js'
import { blockEnde } from './slots.js'
import { buchungsNummer, hashToken, verwaltungsToken } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { datumLang, uhrzeit, zuUtc } from './zeit.js'
import { belegeZellen } from './zellen.js'
/**
* Betrieb im Portal (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4, "Betrieb im Portal"): Aktionen auf
* Buchungen, Termine von Hand und Sperren. Gleiche Regeln wie ablauf.js: Zustandsaenderungen unter
* `termine-buchung:<id>` nach frischem Lesen, `termine-res:` wird hier nie selbst gehalten, Mails gehen erst NACH der
* Aenderung und ausserhalb der Sperre in die Warteschlange (Fehler nur im Log), `now` ist immer ein Parameter.
*
* Der Betrieb handelt auf eigenen Wunsch, darum: keine K21-Mail an den Betrieb, keine Absagefrist, beim Verschieben kein
* Vorlauf und kein Horizont (ohneRegeln; nie in die Vergangenheit, nie auf belegte Zellen), keine Zustimmung zum
* vorzeitigen Beginn. Mails an den Gast gehen nur, wenn die Buchung eine Adresse hat (Handeintrag ohne Adresse,
* anonymisiert). Wer nie eine Einladung bekam (Status `angefragt`), bekommt bei Absage und Verschiebung keine Kalenderdatei (R21).
*
* Fehler: Error mit `status` und `fehler` = { <feld|_>: Text }, Texte in Du-Form (Portal). Unbekannte Buchung, Buchung einer
* anderen Funktion oder eine Sperre bei `aktion` 404 (code 'unbekannt'); nicht (mehr) moegliche Aktion 409 (code
* 'nicht_moeglich'); eine schon erfolgte Aktion (zweiter Klick) aendert nichts und meldet `schon: true`.
*/
const MIN_MS = 60 * 1000
const AKTIONEN = ['annehmen', 'ablehnen', 'absagen', 'verschieben', 'nichterschienen', 'anonymisieren']
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const BIS_RE = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/
const VON_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/
const GRUND_MAX = 100
const NACHRICHT_MAX = 500
const TITEL_MAX = 120
/** Laenge einer Belegung: hoechstens 100 Zellen zu 5 Minuten (ein Bulk-Create). */
const BELEGUNG_MAX_MIN = 500
const GELOESCHT = 'Gelöscht'
const SPERRE_TITEL = 'Gesperrt'
const MELDUNG_BETRIEB = {
unbekannt: 'Diesen Termin gibt es nicht.',
keineSperre: 'Diese Sperre gibt es nicht.',
aktion: 'Bitte eine Aktion auswählen.',
grund: 'Bitte einen Grund auswählen.',
grundLang: `Der Grund darf höchstens ${GRUND_MAX} Zeichen lang sein.`,
nichtOffen: 'Diese Anfrage ist nicht mehr offen.',
nichtAbsagbar: 'Dieser Termin ist nicht mehr offen. Er kann nicht abgesagt oder verschoben werden.',
vergangen: 'Der Termin liegt in der Vergangenheit. Lehne die Anfrage ab.',
vorBeginn: 'Das geht erst, wenn der Termin begonnen hat.',
nichtMarkierbar: 'Dieser Termin kann nicht als „nicht erschienen“ markiert werden.',
nochOffen: 'Dieser Termin ist noch nicht bestätigt.',
zuLang: `Dieser Termin ist länger als ${BELEGUNG_MAX_MIN} Minuten.`,
schritt: 'Bitte eine Uhrzeit in 5-Minuten-Schritten angeben (z. B. 09:00).',
endeVorBeginn: 'Das Ende muss nach dem Beginn liegen.',
sperreLang: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.',
sperreVorbei: 'Die Sperre muss in der Zukunft enden.',
sperreBelegt: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.',
titelLang: `Der Titel darf höchstens ${TITEL_MAX} Zeichen lang sein.`,
}
const nichtGefunden = (text = MELDUNG_BETRIEB.unbekannt) => fehlerMit(404, { _: text }, { code: 'unbekannt' })
const nichtMoeglich = (text) => fehlerMit(409, { _: text }, { code: 'nicht_moeglich' })
/** Kontext wie in ablauf.js (halte): Betriebsangaben und Funktion muessen da sein. */
function pruefeKontext(ctx, name) {
if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError(`${name}: ctx fehlt`)
}
const hatAdresse = (buchung) => typeof buchung?.gastEmail === 'string' && buchung.gastEmail.trim() !== ''
/** Mail an den Gast, falls die Buchung eine Adresse hat; ein Fehler macht die Aenderung nicht rueckgaengig (mailSicher). */
async function mailAnGast(art, ctx, buchung, extra, now) {
if (!hatAdresse(buchung)) return
await mailSicher(art, buchung.$id, () => anGast(art, ctx, buchung, extra, now))
}
// ---------------------------------------------------------------- Aktionen auf Buchungen
/** Prueft die Eingabe der Aktion; => { aktion, grund?, nachricht?, tag?, zeit? }, sonst 400 mit Feldfehlern. */
function pruefeAktion(roh) {
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
const art = roh.aktion
if (typeof art !== 'string' || !AKTIONEN.includes(art)) throw fehlerMit(400, { aktion: MELDUNG_BETRIEB.aktion })
if (art === 'verschieben') return { aktion: art, ...pruefeVerschiebung(roh) }
const { fehler, fehl } = fehlerSammler()
const eingabe = { aktion: art }
if (art === 'absagen') {
eingabe.grund = bereinigterText(roh.grund, GRUND_MAX + 1)
if (eingabe.grund === '') fehl('grund', MELDUNG_BETRIEB.grund)
else if ([...eingabe.grund].length > GRUND_MAX) fehl('grund', MELDUNG_BETRIEB.grundLang)
}
if (art === 'absagen' || art === 'ablehnen') {
eingabe.nachricht = bereinigterText(roh.nachricht, NACHRICHT_MAX + 1, { mehrzeilig: true })
if ([...eingabe.nachricht].length > NACHRICHT_MAX) fehl('nachricht', MELDUNG.nachrichtLang)
}
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
return eingabe
}
/** Anfrage annehmen: `angefragt` => `bestaetigt` mit angenommenAt (Beginn der Widerrufsfrist), G02 mit Einladung. */
async function annehmen(ctx, frisch, _eingabe, now) {
if (frisch.status === 'bestaetigt' && frisch.angenommenAt) return { antwort: { status: 'bestaetigt', schon: true } }
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
if (!(zeitMs(frisch.startsAt) > now.getTime())) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vergangen }, { code: 'vergangen' })
const buchung = await aendere(frisch.$id, { status: 'bestaetigt', angenommenAt: iso(now), updatedAt: iso(now) })
return { antwort: { status: 'bestaetigt' }, mail: () => mailAnGast('G02', ctx, buchung, { anfrageAngenommen: true }, now) }
}
/** Anfrage ablehnen: `abgelehnt`, Zellen frei, G04 (mit der Nachricht des Betriebs, falls angegeben). */
async function ablehnen(ctx, frisch, eingabe, now) {
if (frisch.status === 'abgelehnt') return { antwort: { status: 'abgelehnt', schon: true } }
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
const buchung = await aendere(frisch.$id, { status: 'abgelehnt', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return { antwort: { status: 'abgelehnt' }, mail: () => mailAnGast('G04', ctx, buchung, { nachricht: eingabe.nachricht }, now) }
}
/** Absage durch den Betrieb: `abgesagt` von `betrieb` mit Grund, Zellen frei, G06 (Kalenderabsage nur nach einer Einladung). */
async function absagen(ctx, frisch, eingabe, now) {
if (frisch.status === 'abgesagt') return { antwort: { status: 'abgesagt', schon: true } }
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
const buchung = await aendere(frisch.$id, {
status: 'abgesagt',
abgesagtAt: iso(now),
abgesagtVon: 'betrieb',
absageGrund: eingabe.grund,
updatedAt: iso(now),
})
await gibFreiSicher(frisch.$id)
const extra = { von: 'betrieb', grund: eingabe.grund, nachricht: eingabe.nachricht, ohneIcs: frisch.status === 'angefragt' }
return { antwort: { status: 'abgesagt' }, mail: () => mailAnGast('G06', ctx, buchung, extra, now) }
}
/** Verschieben durch den Betrieb: derselbe Kern wie beim Gast (verschiebeKern), aber ohne Frist, Vorlauf und Horizont. G07 an den Gast. */
async function verschieben(ctx, frisch, eingabe, now) {
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
const { alt, buchung, start } = await verschiebeKern(ctx, frisch, eingabe, now, { betrieb: true })
const extra = { altStart: alt.startsAt, ohneIcs: alt.status === 'angefragt' }
return {
antwort: { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } },
mail: () => mailAnGast('G07', ctx, buchung, extra, now),
}
}
/** Gast nicht erschienen: nur nach Beginn des Termins, aus `bestaetigt` (oder schon `erledigt`). */
async function nichterschienen(_ctx, frisch, _eingabe, now) {
if (frisch.status === 'nichterschienen') return { antwort: { status: 'nichterschienen', schon: true } }
if (!['bestaetigt', 'erledigt'].includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtMarkierbar)
if (now.getTime() < zeitMs(frisch.startsAt)) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vorBeginn }, { code: 'vor_beginn' })
await aendere(frisch.$id, { status: 'nichterschienen', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return { antwort: { status: 'nichterschienen' } }
}
/**
* Gastdaten auf Wunsch loeschen: Name "Gelöscht", E-Mail, Telefon, Nachricht und Zusatzfelder leer, keine Erinnerung,
* Links ungueltig (Hashes leer), absageGrund leer (er kann den vom Gast getippten Namen einer Widerrufserklaerung oder
* einen frei getippten Grund des Betriebs enthalten). Noch nicht versandte Mails der Buchung (Outbox, Status wartet)
* werden geloescht, sie halten Adresse und Inhalt; zuerst, damit ein Fehler dort noch nichts geaendert hat und ein
* zweiter Klick alles nachholt. Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon
* die Loeschfrist (30 Tage) laeuft. Eine noch nicht bestaetigte Haltung bleibt unberuehrt (409).
*/
async function anonymisieren(_ctx, frisch, _eingabe, now) {
if (frisch.status === 'gehalten') throw nichtMoeglich(MELDUNG_BETRIEB.nochOffen)
const behalteFrist = frisch.status === 'abgesagt' || frisch.status === 'abgelehnt'
await verwerfeWartende(frisch.$id)
const buchung = await aendere(frisch.$id, {
gastName: GELOESCHT,
gastEmail: '',
gastTelefon: '',
gastNachricht: '',
gastZusatz: '',
absageGrund: '',
erinnerung: false,
verifyHash: '',
manageHash: '',
updatedAt: iso(behalteFrist && frisch.updatedAt ? frisch.updatedAt : now),
})
return { antwort: { status: buchung.status } }
}
const AKTION = { annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren }
/**
* Aktion des Betriebs auf eine Buchung der Funktion.
* - annehmen: `angefragt` => `bestaetigt`, angenommenAt; G02 (Block anfrage_angenommen) mit Einladung (REQUEST, Sequenz
* der Buchung) und Verwaltungslink. Nicht fuer eine Anfrage, deren Beginn vorbei ist (409 'vergangen').
* - ablehnen: `angefragt` => `abgelehnt`, Zellen frei, G04 (optional `nachricht`).
* - absagen: `bestaetigt`/`angefragt` => `abgesagt` von `betrieb`, Pflicht `grund`, optional `nachricht`, Zellen frei,
* G06 (absage_von = Betriebsname; ohne Kalenderabsage fuer eine Anfrage). Keine Absagefrist.
* - verschieben: `{ tag, zeit }`, wie beim Gast, aber ohne Frist, Vorlauf, Horizont und Zustimmungspruefung; G07.
* - nichterschienen: erst ab Beginn (409 'vor_beginn').
* - anonymisieren: Gastfelder leeren, Status und Zeiten bleiben.
* Kein K21 an den Betrieb: er hat es selbst getan.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {string} bookingId
* @param {{ aktion: string, grund?: string, nachricht?: string, tag?: string, zeit?: string }} roh
* @param {Date} now
* @returns {Promise<{ status: string, schon?: true, termin?: { datum: string, zeit: string } }>}
* @throws Error 400 (Eingabe; beim Verschieben auch dieselbe Zeit), 404 (unbekannt), 409 ('nicht_moeglich', 'vergangen',
* 'vor_beginn'; beim Verschieben auch 'SLOT_VERGEBEN' Feld zeit)
*/
export async function aktion(ctx, bookingId, roh, now) {
pruefeNow(now, 'aktion')
pruefeKontext(ctx, 'aktion')
const eingabe = pruefeAktion(roh)
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden()
const ergebnis = await withLocks([`termine-buchung:${bookingId}`], async () => {
const frisch = await hole(bookingId)
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle === 'sperre') throw nichtGefunden()
return AKTION[eingabe.aktion](ctx, frisch, eingabe, now)
})
if (ergebnis.mail) await ergebnis.mail()
return ergebnis.antwort
}
// ---------------------------------------------------------------- Termine von Hand
/** Eine gescheiterte Neuanlage (Zellen nicht belegbar) wieder entfernen; scheitert das, wird sie als verfallen vermerkt. */
async function nimmZurueck(bookingId, now) {
try {
await withLocks([`termine-buchung:${bookingId}`], () => loesche(bookingId))
} catch (err) {
protokolliere('Buchung nicht entfernt', bookingId, err)
try {
await withLocks([`termine-buchung:${bookingId}`], () => aendere(bookingId, { status: 'verfallen', heldUntil: iso(now), updatedAt: iso(now) }))
} catch (fehler) {
protokolliere('Buchung nicht als verfallen vermerkt', bookingId, fehler)
}
}
}
/**
* Legt eine bestaetigte Buchung (Handeintrag oder Sperre) und danach ihre Zellen an. Reihenfolge wie beim Halten
* (zellen.js): die Buchung steht VOR den Zellen in der Datenbank; scheitert das Belegen, wird sie wieder entfernt.
* @param {() => Error} belegt erzeugt den Fehler fuer SLOT_VERGEBEN (409); andere Fehler gehen unveraendert durch
*/
async function legeBelegungAn(ctx, id, daten, ressourceId, start, block, now, belegt) {
const buchung = await legeAn(daten, id)
try {
await belegeZellen({ featureId: ctx.feature.$id, ressourceId, bookingId: id, start, blockEnde: block, now })
} catch (err) {
await nimmZurueck(id, now)
throw err?.code === 'SLOT_VERGEBEN' ? belegt() : err
}
return buchung
}
/**
* Termin von Hand eintragen (Telefon, Laufkundschaft): `quelle 'manuell'`, sofort `bestaetigt`, `verbraucher false`
* (kein Widerruf, keine Belehrung durch das System: bei telefonischen Buchungen Sache des Betriebs). Pflicht ist nur der
* Name; E-Mail, Telefon, Nachricht und Zusatzfelder sind freiwillig, egal was gastFelder fuer Gaeste vorgibt, werden aber,
* wenn angegeben, wie beim Gast geprueft. Leistung, Person oder "egal", Tag und Uhrzeit wie beim Gast, ohne Zustimmungen
* und Erinnerung. Die Zeit muss frei sein (freie Zeiten der Leistung), nur Vorlauf und Horizont gelten nicht (ohneRegeln;
* nie die Vergangenheit, nie belegte Zellen). G02 (Einladung, Verwaltungslink, ohne Widerrufsbelehrung) geht nur mit
* `bestaetigungSenden: true` an den Gast; dann ist die E-Mail-Adresse Pflicht (400 Feld email). Kein K20: der Betrieb weiss es.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {object} roh { leistung, ressource, tag, zeit, name, email?, telefon?, nachricht?, zusatz?, bestaetigungSenden? }
* @param {Date} now
* @returns {Promise<{ id: string, nummer: string, status: 'bestaetigt' }>}
* @throws Error 400 (Feldfehler) oder 409 code SLOT_VERGEBEN (Feld zeit)
*/
export async function manuell(ctx, roh, now) {
pruefeNow(now, 'manuell')
pruefeKontext(ctx, 'manuell')
const e = ctx.einstellung
const senden = roh?.bestaetigungSenden === true
const g = pruefeEingabe(e, roh, now, { manuell: true, bestaetigung: senden })
if (roh.bestaetigungSenden !== undefined && roh.bestaetigungSenden !== null && typeof roh.bestaetigungSenden !== 'boolean') {
throw fehlerMit(400, { bestaetigungSenden: MELDUNG.ungueltig })
}
const { start, ressourceId } = await pruefeSlot(ctx, g, now, { ohneRegeln: true })
const block = blockEnde(start, g.leistung)
if ((block.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) throw fehlerMit(400, { leistung: MELDUNG_BETRIEB.zuLang })
const id = randomUUID()
const buchung = await legeBelegungAn(
ctx,
id,
{
featureId: ctx.feature.$id,
customerId: ctx.feature.customerId,
projectId: ctx.feature.projectId,
leistungId: g.leistung.id,
leistungName: g.leistung.name,
ressourceId,
ressourceName: e.ressourcen.find((r) => r.id === ressourceId)?.name ?? '',
startsAt: iso(start),
endsAt: iso(new Date(start.getTime() + g.leistung.dauer * MIN_MS)),
blockEndsAt: iso(block),
status: 'bestaetigt',
quelle: 'manuell',
gastName: g.name,
gastEmail: g.email,
gastTelefon: g.telefon,
gastNachricht: g.nachricht,
gastZusatz: Object.keys(g.zusatz).length > 0 ? JSON.stringify(g.zusatz) : '',
erinnerung: false,
// der Verwaltungslink der Bestaetigung (und spaeterer Mails) findet die Buchung nur ueber diesen Hash
manageHash: hashToken(verwaltungsToken(id)),
bestaetigtAt: iso(now),
preisCent: g.leistung.preisCent,
preisArt: g.leistung.preisArt,
buchungsart: g.leistung.buchungsart,
verbraucher: false,
zahlart: 'vorort',
zahlStatus: 'keine',
icsSequenz: 0,
createdAt: iso(now),
updatedAt: iso(now),
},
ressourceId,
start,
block,
now,
zeitVergeben,
)
if (senden) await mailAnGast('G02', ctx, buchung, {}, now)
return { id, nummer: buchungsNummer(id), status: 'bestaetigt' }
}
// ---------------------------------------------------------------- Sperren
/** Prueft die Eingabe einer Sperre; => { ressource, tag, start, ende, titel }, sonst 400 mit Feldfehlern. */
function pruefeSperre(e, roh, now) {
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
const { fehler, fehl } = fehlerSammler()
const ressource = typeof roh.ressource === 'string' ? (e.ressourcen || []).find((r) => r.id === roh.ressource) : undefined
if (!ressource) fehl('ressource', MELDUNG.ressource)
const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null
if (tag === null) fehl('tag', MELDUNG.tag)
const vonOk = typeof roh.von === 'string' && VON_RE.test(roh.von)
const bisOk = typeof roh.bis === 'string' && BIS_RE.test(roh.bis)
if (!vonOk) fehl('von', MELDUNG.zeit)
else if (Number(roh.von.slice(3)) % 5 !== 0) fehl('von', MELDUNG_BETRIEB.schritt)
if (!bisOk) fehl('bis', MELDUNG.zeit)
else if (Number(roh.bis.slice(3)) % 5 !== 0) fehl('bis', MELDUNG_BETRIEB.schritt)
let start = null
let ende = null
if (tag !== null && !Object.hasOwn(fehler, 'von')) {
start = zuUtc(tag, roh.von)
if (start === null) fehl('von', MELDUNG.zeit) // Luecke der Maerz-Umstellung
}
if (tag !== null && !Object.hasOwn(fehler, 'bis')) {
ende = zuUtc(tag, roh.bis)
if (ende === null) fehl('bis', MELDUNG.zeit)
}
if (start && ende) {
if (ende.getTime() <= start.getTime()) fehl('bis', MELDUNG_BETRIEB.endeVorBeginn)
else if ((ende.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) fehl('bis', MELDUNG_BETRIEB.sperreLang)
else if (ende.getTime() <= now.getTime()) fehl('bis', MELDUNG_BETRIEB.sperreVorbei)
}
const titel = bereinigterText(roh.titel, TITEL_MAX + 1)
if ([...titel].length > TITEL_MAX) fehl('titel', MELDUNG_BETRIEB.titelLang)
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
return { ressource, start, ende, titel: titel === '' ? SPERRE_TITEL : titel }
}
/**
* Zeit sperren: eine bestaetigte Buchung ohne Gast (`quelle 'sperre'`, `leistungName` = Titel, Vorgabe "Gesperrt") auf einer
* Ressource an einem Berliner Tag von `von` bis `bis` ('HH:MM', Vielfache von 5 Minuten, `bis` auch '24:00'), mit Zellen
* wie jede Buchung. Hoechstens 500 Minuten (100 Zellen in einem Bulk-Create), sonst 400 Feld `bis`; laengere Zeiten sind
* Sache der Abwesenheiten. Das Ende muss in der Zukunft liegen. Liegt schon ein Termin oder eine Sperre in der Zeit,
* 409 (code SLOT_VERGEBEN, Feld von): erst den Termin absagen oder verschieben.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {{ ressource: string, tag: string, von: string, bis: string, titel?: string }} roh
* @param {Date} now
* @returns {Promise<{ id: string, status: 'bestaetigt' }>}
*/
export async function sperre(ctx, roh, now) {
pruefeNow(now, 'sperre')
pruefeKontext(ctx, 'sperre')
const s = pruefeSperre(ctx.einstellung, roh, now)
const id = randomUUID()
await legeBelegungAn(
ctx,
id,
{
featureId: ctx.feature.$id,
customerId: ctx.feature.customerId,
projectId: ctx.feature.projectId,
leistungId: '',
leistungName: s.titel,
ressourceId: s.ressource.id,
ressourceName: s.ressource.name,
startsAt: iso(s.start),
endsAt: iso(s.ende),
blockEndsAt: iso(s.ende),
status: 'bestaetigt',
quelle: 'sperre',
erinnerung: false,
bestaetigtAt: iso(now),
preisCent: 0,
preisArt: 'keiner',
verbraucher: false,
icsSequenz: 0,
createdAt: iso(now),
updatedAt: iso(now),
},
s.ressource.id,
s.start,
s.ende,
now,
() => fehlerMit(409, { von: MELDUNG_BETRIEB.sperreBelegt }, { code: 'SLOT_VERGEBEN' }),
)
return { id, status: 'bestaetigt' }
}
/**
* Sperre aufheben: loescht die Buchung und danach ihre Zellen (nur `quelle 'sperre'` der eigenen Funktion, sonst 404).
* Wie bei einer Absage gilt eine Zelle ohne Buchung als frei; scheitert das Freigeben, steht ein Eintrag im Log und der
* Tagesjob raeumt auf.
* @returns {Promise<{ status: 'entsperrt' }>}
*/
export async function entsperre(ctx, bookingId) {
pruefeKontext(ctx, 'entsperre')
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
return withLocks([`termine-buchung:${bookingId}`], async () => {
const frisch = await hole(bookingId)
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle !== 'sperre') throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
await loesche(bookingId)
await gibFreiSicher(bookingId)
return { status: 'entsperrt' }
})
}
+197
View File
@@ -0,0 +1,197 @@
import { randomUUID } from 'node:crypto'
import { config } from '../../config.js'
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { istBelegend } from './slots.js'
import { buchungsNummer } from './token.js'
import { pruefeNow } from './util.js'
/**
* Buchungsspeicher des Terminplaners: nur Datenzugriff auf termineBookings (Spezifikation 2026-10-02-terminplaner-
* design.md, Abschnitte 2 und 4), keine Geschaeftsregeln. Die Listen kommen seitenweise vollstaendig (listAllDocuments,
* Appwrite liefert ohne limit nur 25), `now` ist immer ein Parameter. Datumswerte aus Appwrite sind "+00:00"-Texte
* und werden vom Aufrufer mit new Date() gelesen. Abfragen ueber mehrere Funktionen (Jobs) haben kein featureId.
*/
const col = () => config.collections.termineBookings
/** Status, die eine Buchung belegen koennen (gehalten nur, solange heldUntil > now: istBelegend prueft es). */
const BELEGEND = ['gehalten', 'angefragt', 'bestaetigt']
/** Hoechste Erinnerungsvorlaufzeit (einstellungen.js: 2, 12, 24 oder 48 Stunden) und der Mindestabstand zum Termin. */
const ERINNERUNG_MAX_MS = 48 * 60 * 60 * 1000
const ERINNERUNG_MIN_MS = 60 * 60 * 1000
const STUNDE_MS = 60 * 60 * 1000
const HASH_FELDER = new Set(['verifyHash', 'manageHash'])
const HASH_RE = /^[0-9a-f]{64}$/
const NUMMER_RE = /^[A-Z0-9]{8}$/
/** Bezugszeit der Loeschfristen je Status (siehe loeschKandidaten). */
const LOESCH_BEZUG = {
verfallen: 'heldUntil',
abgesagt: 'updatedAt',
abgelehnt: 'updatedAt',
erledigt: 'endsAt',
nichterschienen: 'endsAt',
}
const iso = (wert) => new Date(wert).toISOString()
const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '')
/** Ohne die von Appwrite gelieferten $-Felder ($id, $createdAt, ...), die nie zurueckgeschrieben werden duerfen. */
const nurFelder = (daten) => Object.fromEntries(Object.entries(daten).filter(([key]) => !key.startsWith('$')))
/**
* Legt eine Buchung an. Die ID kommt vom Aufrufer (id oder daten.$id), sonst randomUUID(); der Aufrufer braucht sie
* vorher, weil Verwaltungstoken und Buchungsnummer aus ihr entstehen. createdAt/updatedAt: jetzt, wenn nicht gegeben.
* => das angelegte Dokument.
*/
export async function legeAn(daten, id) {
const felder = nurFelder(daten)
const jetzt = new Date().toISOString()
return createDocument(
col(),
{ ...felder, createdAt: felder.createdAt ?? jetzt, updatedAt: felder.updatedAt ?? jetzt },
id ?? daten.$id ?? randomUUID()
)
}
/** Eine Buchung per ID; null, wenn es sie nicht gibt. */
export async function hole(id) {
try {
return await getDocument(col(), id)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/** Aendert Felder einer Buchung und setzt updatedAt (jetzt, wenn nicht gegeben). => das geaenderte Dokument. */
export async function aendere(id, felder) {
const aenderung = nurFelder(felder)
return updateDocument(col(), id, { ...aenderung, updatedAt: aenderung.updatedAt ?? new Date().toISOString() })
}
/** Loescht eine Buchung (nur Sperren und gescheiterte Handeintraege; 404 = schon weg). */
export async function loesche(id) {
try {
await deleteDocument(col(), id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
/**
* Alle Buchungen (jeder Status) einer Funktion, die den Zeitraum [von, bis] beruehren: Beginn vor bis und Blockende
* nach von (strikt). Findet damit auch eine Belegung vom Vortag, die in den Tag hineinreicht.
*/
export async function imZeitraum(featureId, von, bis) {
return listAllDocuments(col(), [
Query.equal('featureId', featureId),
Query.lessThan('startsAt', iso(bis)),
Query.greaterThan('blockEndsAt', iso(von)),
])
}
/** Buchung zu einem Token-Hash (feld 'verifyHash' oder 'manageHash'); null bei leerem, kaputtem oder unbekanntem Hash. */
export async function perHash(feld, hash) {
if (!HASH_FELDER.has(feld)) throw new TypeError(`perHash: Feld ${feld} nicht erlaubt`)
if (typeof hash !== 'string' || !HASH_RE.test(hash)) return null
const [buchung] = await listDocuments(col(), [Query.equal(feld, hash), Query.limit(1)])
return buchung ?? null
}
/**
* Offene Termine einer Adresse in dieser Funktion: belegend (gueltige Haltung, Anfrage, bestaetigt) und noch nicht
* zu Ende. Die Adresse zaehlt klein und getrimmt. => Buchungen (die Grenze maxOffeneJeEmail prueft der Aufrufer).
*/
export async function offeneFuerEmail(featureId, email, now) {
const nowMs = pruefeNow(now, 'offeneFuerEmail')
const mail = kleinGetrimmt(email)
if (!mail) return []
const kandidaten = await listAllDocuments(col(), [
Query.equal('featureId', featureId),
Query.equal('gastEmail', mail),
Query.greaterThan('endsAt', iso(now)),
])
return kandidaten.filter((buchung) => istBelegend(buchung, nowMs))
}
/** Buchung zu Adresse und Buchungsnummer (Widerruf ohne Link); null, wenn beides zusammen nicht passt. */
export async function perEmailUndNummer(featureId, email, nummer) {
const mail = kleinGetrimmt(email)
const gesucht = typeof nummer === 'string' ? nummer.trim().toUpperCase() : ''
if (!mail || !NUMMER_RE.test(gesucht)) return null
const kandidaten = await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('gastEmail', mail)])
return kandidaten.find((buchung) => buchungsNummer(buchung.$id) === gesucht) ?? null
}
/**
* Kuenftige belegende Termine (Ende nach jetzt) je Leistung und je Ressource, ohne Sperren (kein Gast, kein Termin):
* { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } }, die Form von kuenftigeTermine in
* normalisiereEinstellung.
*/
export async function zaehleKuenftige(featureId, now) {
const nowMs = pruefeNow(now, 'zaehleKuenftige')
const liste = await listAllDocuments(col(), [
Query.equal('featureId', featureId),
Query.equal('status', BELEGEND),
Query.greaterThan('endsAt', iso(now)),
])
const leistungen = new Map()
const ressourcen = new Map()
const zaehle = (karte, id) => {
if (typeof id === 'string' && id !== '') karte.set(id, (karte.get(id) ?? 0) + 1)
}
for (const buchung of liste) {
if (buchung.quelle === 'sperre' || !istBelegend(buchung, nowMs)) continue
zaehle(leistungen, buchung.leistungId)
zaehle(ressourcen, buchung.ressourceId)
}
return { leistungen: Object.fromEntries(leistungen), ressourcen: Object.fromEntries(ressourcen) }
}
/** Haltungen (alle Funktionen), die abgelaufen sind (heldUntil <= jetzt): der Minutenjob macht sie zu verfallen. */
export async function abgelaufeneHaltungen(now) {
pruefeNow(now, 'abgelaufeneHaltungen')
return listAllDocuments(col(), [Query.equal('status', 'gehalten'), Query.lessThanEqual('heldUntil', iso(now))])
}
/**
* Kandidaten fuer die Erinnerung (alle Funktionen): bestaetigt, Erinnerung gewuenscht, noch keine gesendet, Beginn in
* (jetzt + 1 h, jetzt + 48 h]. Den Vorlauf der Funktion (stundenVorher) und den Vertragsschluss prueft der Job.
*/
export async function erinnerungsKandidaten(now) {
const nowMs = pruefeNow(now, 'erinnerungsKandidaten')
const liste = await listAllDocuments(col(), [
Query.equal('status', 'bestaetigt'),
Query.equal('erinnerung', true),
Query.greaterThan('startsAt', iso(nowMs + ERINNERUNG_MIN_MS)),
Query.lessThanEqual('startsAt', iso(nowMs + ERINNERUNG_MAX_MS)),
])
return liste.filter((buchung) => !buchung.erinnerungGesendetAt)
}
/** Bestaetigte Buchungen (alle Funktionen, auch Sperren), deren Ende mehr als 1 h her ist: der Job macht sie erledigt. */
export async function zuErledigen(now) {
const nowMs = pruefeNow(now, 'zuErledigen')
return listAllDocuments(col(), [Query.equal('status', 'bestaetigt'), Query.lessThan('endsAt', iso(nowMs - STUNDE_MS))])
}
/** Anfragen (alle Funktionen), deren Beginn vorbei ist: der Job lehnt sie vom System aus ab. */
export async function vergangeneAnfragen(now) {
pruefeNow(now, 'vergangeneAnfragen')
return listAllDocuments(col(), [Query.equal('status', 'angefragt'), Query.lessThan('startsAt', iso(now))])
}
/**
* Buchungen eines Status (alle Funktionen), deren Bezugszeit vor `vor` liegt. Bezugszeit je Status: verfallen =
* heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte
* Aenderung, also der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Die Frist je
* Funktion (aufbewahrungTage) wendet der Aufrufer auf das Ergebnis an. Andere Status: TypeError.
*/
export async function loeschKandidaten(status, vor) {
if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`)
return listAllDocuments(col(), [Query.equal('status', status), Query.lessThan(LOESCH_BEZUG[status], iso(vor))])
}
/** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */
export async function zaehleAnfragen(featureId) {
return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length
}
+702
View File
@@ -0,0 +1,702 @@
import { randomBytes } from 'node:crypto'
import { config } from '../../config.js'
import { withdrawalDeadline } from '../deadlines.js'
import { BUNDESLAENDER } from './feiertage.js'
import { heute, zuUtc } from './zeit.js'
/**
* Terminplaner-Einstellungen (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2, termineConfig.einstellung).
* Reine Funktionen ohne Appwrite (config nur fuer die Haltezeit in brauchtZustimmungVorzeitig): Pruefen und
* Normalisieren der Betriebs-Einstellung, Texte fuer Knoepfe und Preise, die oeffentliche Sicht fuer GET /config.
* `now` kommt immer als Parameter.
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
*/
const VORGABE_FARBE = '#7c3aed'
const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 }
const RASTER = [5, 10, 15, 20, 30, 60]
const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
const GAST_FELD = ['aus', 'optional', 'pflicht']
const ZAHLARTEN = { bar: 'bar', ec: 'EC-Karte', kreditkarte: 'Kreditkarte', ueberweisung: 'Überweisung', paypal: 'PayPal' }
const RESERVIERTE_SCHLUESSEL = ['__proto__', 'constructor', 'prototype']
const VORLAGE_RE = /^[a-z]{1,20}$/
const FEED_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const MIN_MS = 60000
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const idRe = (praefix) => new RegExp(`^${praefix}_[a-z0-9]{8}$`)
// ---------------------------------------------------------------- IDs und Token
/** 'r_' / 's_' / 'a_' + 8 Zeichen aus a-z0-9 (Ressource, Leistung, Abwesenheit). */
export function neueId(praefix) {
if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Praefix: ${praefix}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of randomBytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length] // 252 = 7 * 36, ohne Verzerrung
}
}
return `${praefix}_${zeichen}`
}
/** Kalender-Abo-Token einer Ressource: 32 Zufallsbytes, base64url (43 Zeichen). */
export function neuerFeedToken() {
return randomBytes(32).toString('base64url')
}
// ---------------------------------------------------------------- Bausteine zum Pruefen
// Steuerzeichen (\p{Cc}: ASCII und C1), unsichtbare Formatzeichen (\p{Cf}: Zero-Width Space, Richtungsmarken und
// -umschalter, Word Joiner, BOM, Arabic Letter Mark, Soft Hyphen u. a.) sowie Zeilen-/Absatztrenner. Sie erzeugen
// leere oder taeuschende Namen in Absendernamen, Betreffzeilen und auf oeffentlichen Seiten.
const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu
// ZWNJ und ZWJ gehoeren zu Emoji-Sequenzen und persischer Schrift und bleiben in Freitexten erhalten.
const VERBINDER = new Set(['\u200C', '\u200D'])
/** Entfernt Steuerzeichen und unsichtbare Formatzeichen; behalteVerbinder: ZWNJ/ZWJ bleiben, umbruch: '\n' bleibt. */
function ohneSteuerzeichen(s, { behalteVerbinder = false, umbruch = false } = {}) {
return s.replace(STEUERZEICHEN, (z) => ((behalteVerbinder && VERBINDER.has(z)) || (umbruch && z === '\n') ? z : ''))
}
// Leerraum und ZWNJ/ZWJ am Rand entfernen: ein Text nur aus Verbindern bleibt sonst unsichtbar und besteht die Pflichtpruefung.
const randTrim = (s) => s.replace(/^[\s\u200C\u200D]+|[\s\u200C\u200D]+$/g, '')
/** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */
export function text(wert, max, { mehrzeilig = false } = {}) {
if (typeof wert !== 'string') return ''
let s = wert
if (mehrzeilig) {
s = ohneSteuerzeichen(s.replace(/\r\n?/g, '\n').replace(/\t/g, ' '), { behalteVerbinder: true, umbruch: true }).replace(/\n{3,}/g, '\n\n')
} else {
s = ohneSteuerzeichen(s.replace(/[\r\n\t]+/g, ' '), { behalteVerbinder: true }).replace(/ {2,}/g, ' ')
}
s = randTrim(s)
const zeichen = [...s]
return zeichen.length > max ? randTrim(zeichen.slice(0, max).join('')) : s
}
/** Wert in {min..max} (ganzzahlig, Vielfaches von schritt) oder Vorgabe; fehlender Wert ohne Vorgabe ist ein Fehler. */
function zahl(fehl, pfad, wert, { vorgabe, min, max, schritt = 1, meldung }) {
if (wert === undefined || wert === null) {
if (vorgabe === undefined) fehl(pfad, meldung)
return vorgabe
}
if (!Number.isInteger(wert) || wert < min || wert > max || wert % schritt !== 0) {
fehl(pfad, meldung)
return vorgabe
}
return wert
}
function wahl(fehl, pfad, wert, erlaubt, vorgabe, meldung) {
if (wert === undefined || wert === null) return vorgabe
if (!erlaubt.includes(wert)) {
fehl(pfad, meldung)
return vorgabe
}
return wert
}
function wahrheit(fehl, pfad, wert, vorgabe) {
if (wert === undefined || wert === null) return vorgabe
if (typeof wert !== 'boolean') {
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
return vorgabe
}
return wert
}
/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und >= 1 Punkt in der Domain, hoechstens 254 Zeichen. */
export function emailAdresse(wert) {
if (typeof wert !== 'string') return null
const s = ohneSteuerzeichen(wert).trim().toLowerCase()
if (s.length === 0 || s.length > 254) return null
if (!/^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s)) return null
return s
}
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, leer = null. Diese Adressen
* landen als href auf oeffentlichen Seiten und in Mails: Anfuehrungszeichen, Apostroph und Backtick im Host werden
* abgelehnt, ein Apostroph im Pfad wird als %27 gespeichert (URL kodiert ihn dort nicht selbst).
*/
function urlFeld(fehl, pfad, wert) {
if (wert === undefined || wert === null) return null
if (typeof wert !== 'string') {
fehl(pfad, URL_MELDUNG)
return null
}
const s = ohneSteuerzeichen(wert).trim()
if (s === '') return null
let url = null
if (s.length <= 300 && !/\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 > 300) {
fehl(pfad, URL_MELDUNG)
return null
}
return href
}
/** ID eines Eintrags: fehlt sie, entsteht eine neue; sonst Format + Eindeutigkeit je Liste. */
function idFeld(fehl, pfad, wert, praefix, gesehen) {
let id = wert
if (id === undefined || id === null || id === '') {
do { id = neueId(praefix) } while (gesehen.has(id))
} else if (typeof id !== 'string' || !idRe(praefix).test(id)) {
fehl(pfad, 'Ungültige ID.')
return null
} else if (gesehen.has(id)) {
fehl(pfad, 'Diese ID kommt doppelt vor.')
return null
}
gesehen.add(id)
return id
}
function tagGueltig(tag) {
return typeof tag === 'string' && zuUtc(tag, '12:00') !== null
}
/** 'HH:MM' -> Minuten (5er-Raster, Ende darf 24:00 sein), 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
}
const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
/** Liste von Fenstern [['09:00','12:30'], …]: 5er-Raster, aufsteigend, ohne Ueberlappung, Ende > Beginn. */
function fensterListe(fehl, pfad, wert) {
if (!Array.isArray(wert)) {
fehl(pfad, 'Ungültige Zeitfenster.')
return []
}
const aus = []
let vorher = null
for (const fenster of wert) {
const von = Array.isArray(fenster) && fenster.length === 2 ? minuten(fenster[0], false) : null
const bis = von === null ? null : minuten(fenster[1], true)
if (von === null || bis === null) {
fehl(pfad, 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben (Ende bis 24:00).')
return []
}
if (bis <= von) {
fehl(pfad, 'Das Ende muss nach dem Beginn liegen.')
return []
}
if (vorher && von < vorher.bis) {
fehl(pfad, von < vorher.von ? 'Zeitfenster müssen aufsteigend sortiert sein.' : 'Zeitfenster überlappen sich.')
return []
}
vorher = { von, bis }
aus.push([hhmm(von), hhmm(bis)])
}
return aus
}
// ---------------------------------------------------------------- Abschnitte
function normalisiereBetrieb(fehl, roh) {
const b = istObjekt(roh) ? roh : {}
const name = text(b.name, 80)
if (!name) fehl('betrieb.name', 'Bitte gib den Namen deines Betriebs an.')
const email = emailAdresse(b.email)
if (!email) fehl('betrieb.email', 'Bitte gib eine gültige E-Mail-Adresse an (genau ein Postfach).')
return {
name,
email: email || '',
telefon: text(b.telefon, 40),
kleinunternehmer: wahrheit(fehl, 'betrieb.kleinunternehmer', b.kleinunternehmer, false),
adresse: text(b.adresse, 200),
impressumUrl: urlFeld(fehl, 'betrieb.impressumUrl', b.impressumUrl),
datenschutzUrl: urlFeld(fehl, 'betrieb.datenschutzUrl', b.datenschutzUrl),
agbUrl: urlFeld(fehl, 'betrieb.agbUrl', b.agbUrl),
websiteUrl: urlFeld(fehl, 'betrieb.websiteUrl', b.websiteUrl),
}
}
/** Liste mit Obergrenze: undefined/null = leer; Nicht-Liste und zu lang = Fehler am Listenpfad. */
function liste(fehl, pfad, wert, max, meldung) {
if (wert === undefined || wert === null) return []
if (!Array.isArray(wert)) {
fehl(pfad, 'Ungültige Liste.')
return []
}
if (wert.length > max) {
fehl(pfad, meldung)
return []
}
return wert
}
function normalisiereWochenplan(fehl, pfad, plan) {
if (plan === undefined || plan === null) return {}
if (!istObjekt(plan)) {
fehl(pfad, 'Ungültiger Wochenplan.')
return {}
}
const aus = {}
for (const tag of Object.keys(plan)) {
if (!/^[1-7]$/.test(tag)) {
fehl(`${pfad}.${tag.slice(0, 20)}`, 'Ungültiger Wochentag (1 = Montag bis 7 = Sonntag).')
continue
}
const fenster = fensterListe(fehl, `${pfad}.${tag}`, plan[tag])
if (fenster.length) aus[tag] = fenster
}
return aus
}
function normalisiereRessourcen(fehl, roh, alt) {
const feedTokens = new Map(
(Array.isArray(alt?.ressourcen) ? alt.ressourcen : [])
.filter((r) => typeof r?.id === 'string' && typeof r.feedToken === 'string' && FEED_TOKEN_RE.test(r.feedToken))
.map((r) => [r.id, r.feedToken]),
)
const gesehen = new Set()
const aus = []
liste(fehl, 'ressourcen', roh, GRENZEN.ressourcen, `Es sind höchstens ${GRENZEN.ressourcen} Personen oder Plätze möglich.`).forEach((r, i) => {
const pfad = `ressourcen.${i}`
if (!istObjekt(r)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, r.id, 'r', gesehen)
const name = text(r.name, 80)
if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.')
aus.push({
id,
typ: wahl(fehl, `${pfad}.typ`, r.typ, ['person', 'platz'], 'person', 'Bitte „Person“ oder „Platz“ wählen.'),
name,
aktiv: wahrheit(fehl, `${pfad}.aktiv`, r.aktiv, true),
plan: normalisiereWochenplan(fehl, `${pfad}.plan`, r.plan),
feedToken: (id && feedTokens.get(id)) || neuerFeedToken(), // nie vom Client
})
})
return aus
}
function normalisiereLeistungen(fehl, roh, ressourcen) {
const alleIds = new Set(ressourcen.map((r) => r.id))
const aktiveIds = new Set(ressourcen.filter((r) => r.aktiv).map((r) => r.id))
const gesehen = new Set()
const aus = []
liste(fehl, 'leistungen', roh, GRENZEN.leistungen, `Es sind höchstens ${GRENZEN.leistungen} Leistungen möglich.`).forEach((l, i) => {
const pfad = `leistungen.${i}`
if (!istObjekt(l)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, l.id, 's', gesehen)
const name = text(l.name, 100)
if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.')
const dauer = zahl(fehl, `${pfad}.dauer`, l.dauer, { min: 5, max: 480, schritt: 5, meldung: 'Die Dauer muss zwischen 5 und 480 Minuten liegen (in 5-Minuten-Schritten).' })
const pufferNach = zahl(fehl, `${pfad}.pufferNach`, l.pufferNach, { vorgabe: 0, min: 0, max: 120, schritt: 5, meldung: 'Die Pause danach muss zwischen 0 und 120 Minuten liegen (in 5-Minuten-Schritten).' })
if (dauer !== undefined && dauer + pufferNach > 500) fehl(`${pfad}.dauer`, 'Dauer und Pause danach dürfen zusammen höchstens 500 Minuten betragen.')
const preisArt = wahl(fehl, `${pfad}.preisArt`, l.preisArt, ['fest', 'ab', 'keiner'], 'keiner', 'Bitte „Festpreis“, „ab“ oder „kein Preis“ wählen.')
const preisCent = preisArt === 'keiner'
? 0
: zahl(fehl, `${pfad}.preisCent`, l.preisCent, { vorgabe: 0, min: 0, max: 1000000, meldung: 'Der Preis muss zwischen 0 und 10.000 Euro liegen.' })
const aktiv = wahrheit(fehl, `${pfad}.aktiv`, l.aktiv, true)
const zuordnung = []
if (l.ressourcen !== undefined && l.ressourcen !== null && !Array.isArray(l.ressourcen)) {
fehl(`${pfad}.ressourcen`, 'Ungültige Zuordnung.')
} else {
for (const rid of l.ressourcen || []) {
if (typeof rid !== 'string' || !alleIds.has(rid)) {
fehl(`${pfad}.ressourcen`, 'Die Zuordnung enthält eine unbekannte Person oder einen unbekannten Platz.')
} else if (!zuordnung.includes(rid)) {
zuordnung.push(rid)
}
}
}
if (aktiv && !zuordnung.some((rid) => aktiveIds.has(rid))) {
fehl(`${pfad}.ressourcen`, 'Eine aktive Leistung braucht mindestens eine aktive Person oder einen aktiven Platz.')
}
aus.push({
id,
name,
beschreibung: text(l.beschreibung, 300),
dauer,
pufferNach,
preisArt,
preisCent,
buchungsart: wahl(fehl, `${pfad}.buchungsart`, l.buchungsart, ['sofort', 'anfrage'], 'sofort', 'Bitte „sofort buchen“ oder „Anfrage“ wählen.'),
ressourcen: zuordnung,
aktiv,
reihenfolge: i + 1,
})
})
return aus
}
function normalisiereAbwesenheiten(fehl, roh, ressourcen) {
const alleIds = new Set(ressourcen.map((r) => r.id))
const gesehen = new Set()
const aus = []
liste(fehl, 'abwesenheiten', roh, GRENZEN.abwesenheiten, `Es sind höchstens ${GRENZEN.abwesenheiten} Abwesenheiten möglich.`).forEach((a, i) => {
const pfad = `abwesenheiten.${i}`
if (!istObjekt(a)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, a.id, 'a', gesehen)
let ressourceId = null
if (a.ressourceId !== undefined && a.ressourceId !== null) {
if (typeof a.ressourceId === 'string' && alleIds.has(a.ressourceId)) ressourceId = a.ressourceId
else fehl(`${pfad}.ressourceId`, 'Unbekannte Person oder unbekannter Platz.')
}
const vonOk = tagGueltig(a.von)
const bisOk = tagGueltig(a.bis)
if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.')
if (!bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.')
if (vonOk && bisOk && a.von > a.bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
aus.push({ id, ressourceId, von: vonOk ? a.von : '', bis: bisOk ? a.bis : '', titel: text(a.titel, 80) })
})
return aus
}
function normalisiereSonderoeffnungen(fehl, roh) {
if (roh === undefined || roh === null) return {}
if (!istObjekt(roh)) {
fehl('sonderoeffnungen', 'Ungültige Sonderöffnungen.')
return {}
}
const tage = Object.keys(roh)
if (tage.length > GRENZEN.sonderoeffnungen) {
fehl('sonderoeffnungen', `Es sind höchstens ${GRENZEN.sonderoeffnungen} Sonderöffnungstage möglich.`)
return {}
}
const aus = {}
for (const tag of tage.sort()) {
const pfad = `sonderoeffnungen.${tag.slice(0, 20)}`
if (!tagGueltig(tag)) {
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
continue
}
aus[tag] = fensterListe(fehl, pfad, roh[tag]) // [] = an diesem Tag geschlossen
}
return aus
}
function normalisiereGastFelder(fehl, roh) {
const g = istObjekt(roh) ? roh : {}
const meldung = 'Bitte „aus“, „optional“ oder „Pflicht“ wählen.'
const zusatz = []
const schluessel = new Set()
liste(fehl, 'gastFelder.zusatz', g.zusatz, GRENZEN.zusatzfelder, `Es sind höchstens ${GRENZEN.zusatzfelder} Zusatzfelder möglich.`).forEach((z, i) => {
const pfad = `gastFelder.zusatz.${i}`
if (!istObjekt(z)) return fehl(pfad, 'Ungültiger Eintrag.')
const key = typeof z.key === 'string' ? z.key : ''
if (!/^[a-z0-9_]{1,20}$/.test(key) || RESERVIERTE_SCHLUESSEL.includes(key)) {
fehl(`${pfad}.key`, 'Der Schlüssel besteht aus 1 bis 20 Kleinbuchstaben, Ziffern oder Unterstrichen.')
} else if (schluessel.has(key)) {
fehl(`${pfad}.key`, 'Dieser Schlüssel kommt doppelt vor.')
}
schluessel.add(key)
const label = text(z.label, 60)
if (!label) fehl(`${pfad}.label`, 'Bitte gib eine Beschriftung an.')
const typ = wahl(fehl, `${pfad}.typ`, z.typ, ['text', 'auswahl'], 'text', 'Bitte „Text“ oder „Auswahl“ wählen.')
let optionen = []
if (typ === 'auswahl') {
const roheOptionen = Array.isArray(z.optionen) ? z.optionen : []
optionen = [...new Set(roheOptionen.map((o) => text(o, 60)).filter(Boolean))]
if (roheOptionen.length > GRENZEN.optionen || optionen.length === 0) {
fehl(`${pfad}.optionen`, `Bitte gib 1 bis ${GRENZEN.optionen} Auswahlmöglichkeiten an.`)
optionen = optionen.slice(0, GRENZEN.optionen)
}
}
zusatz.push({ key, label, typ, optionen, pflicht: wahrheit(fehl, `${pfad}.pflicht`, z.pflicht, false) })
})
return {
telefon: wahl(fehl, 'gastFelder.telefon', g.telefon, GAST_FELD, 'optional', meldung),
nachricht: wahl(fehl, 'gastFelder.nachricht', g.nachricht, GAST_FELD, 'aus', meldung),
zusatz,
}
}
function normalisiereZahlung(fehl, roh) {
const z = istObjekt(roh) ? roh : {}
if (z.modus !== undefined && z.modus !== null && z.modus !== 'vorort') {
fehl('zahlung.modus', z.modus === 'online' || z.modus === 'beides' ? 'Online-Zahlung kommt bald' : 'Bitte „vor Ort“ wählen.')
}
let arten = ['bar', 'ec', 'kreditkarte']
if (z.vorOrtArten !== undefined && z.vorOrtArten !== null) {
if (!Array.isArray(z.vorOrtArten) || z.vorOrtArten.some((a) => !Object.hasOwn(ZAHLARTEN, a))) {
fehl('zahlung.vorOrtArten', 'Bitte wähle Zahlarten aus der Liste.')
} else {
arten = Object.keys(ZAHLARTEN).filter((a) => z.vorOrtArten.includes(a)) // feste Reihenfolge, ohne Doppelte
}
}
return { modus: 'vorort', vorOrtArten: arten }
}
/** Naechste Fassung 'YYYY-MM-DD-n': am selben Tag n + 1, sonst 1. */
function naechsteStornoVersion(bisherige, tag) {
const m = /^(\d{4}-\d{2}-\d{2})-(\d+)$/.exec(bisherige)
return m && m[1] === tag ? `${tag}-${Number(m[2]) + 1}` : `${tag}-1`
}
function normalisiereStorno(roh, alt, now) {
const neu = text(istObjekt(roh) ? roh.text : '', 2000, { mehrzeilig: true })
const altText = typeof alt?.text === 'string' ? alt.text : ''
const altVersion = typeof alt?.version === 'string' ? alt.version : ''
// Version nie vom Client: neue Fassung nur bei geaendertem (oder bisher unversioniertem) Text; wird der Text
// entfernt, bleibt die letzte Fassungsnummer stehen, damit sie spaeter nicht erneut vergeben wird.
const version = neu !== '' && (neu !== altText || altVersion === '') ? naechsteStornoVersion(altVersion, heute(now)) : altVersion
return { text: neu, version }
}
function pruefeEntfernte(fehl, alteListe, neueListe, zaehler, pfad, meldung) {
const bleibt = new Set(neueListe.map((e) => e.id))
for (const eintrag of Array.isArray(alteListe) ? alteListe : []) {
if (!eintrag || typeof eintrag.id !== 'string' || bleibt.has(eintrag.id)) continue
const n = istObjekt(zaehler) && Object.hasOwn(zaehler, eintrag.id) ? zaehler[eintrag.id] : 0
if (Number.isInteger(n) && n > 0) fehl(`${pfad}.${eintrag.id}`, meldung(eintrag, n))
}
}
const termine = (n) => (n === 1 ? '1 künftigen Termin' : `${n} künftige Termine`)
const MELDUNG_LEISTUNG = (_, n) => `Für diese Leistung gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.`
const MELDUNG_RESSOURCE = (r, n) => (r.typ === 'platz'
? `Für diesen Platz gibt es noch ${termine(n)}. Deaktiviere ihn stattdessen.`
: `Für diese Person gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.`)
// ---------------------------------------------------------------- Normalisieren
/**
* Prueft und normalisiert eine Einstellung (Eingabe vom Client oder aus einer Vorlage).
* bisher = zuletzt gespeicherte Einstellung (Feed-Token, Storno-Fassung, Entfernen-Pruefung);
* kuenftigeTermine = { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } } kuenftiger belegender Termine.
* Pflichtangaben fuer Gaeste (fehlendePflichtangaben): Telefon immer, Anschrift bei Widerrufsbelehrung. entwurf: true
* laesst nur diese beiden Pruefungen aus (Entwurf aus einer Vorlage, Kunde ohne Telefon/Anschrift im Konto); als
* oeffentlich buchbar gilt eine solche Einstellung erst, wenn die Angaben da sind (kontextPerSiteKey).
* => { einstellung } oder { fehler: { [pfad]: text } }.
*/
export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, entwurf = false, now } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereEinstellung: now fehlt')
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
if (!istObjekt(eingabe)) return { fehler: { _: 'Die Einstellungen sind ungültig.' } }
const alt = istObjekt(bisher) ? bisher : null
let vorlage = eingabe.vorlage ?? alt?.vorlage ?? null
if (vorlage !== null && !(typeof vorlage === 'string' && VORLAGE_RE.test(vorlage))) {
fehl('vorlage', 'Ungültige Vorlage.')
vorlage = null
}
const betrieb = normalisiereBetrieb(fehl, eingabe.betrieb)
const ressourcen = normalisiereRessourcen(fehl, eingabe.ressourcen, alt)
const leistungen = normalisiereLeistungen(fehl, eingabe.leistungen, ressourcen)
pruefeEntfernte(fehl, alt?.leistungen, leistungen, kuenftigeTermine?.leistungen, 'leistungen', MELDUNG_LEISTUNG)
pruefeEntfernte(fehl, alt?.ressourcen, ressourcen, kuenftigeTermine?.ressourcen, 'ressourcen', MELDUNG_RESSOURCE)
const regeln = istObjekt(eingabe.regeln) ? eingabe.regeln : {}
const erinnerung = istObjekt(eingabe.erinnerung) ? eingabe.erinnerung : {}
const recht = istObjekt(eingabe.recht) ? eingabe.recht : {}
const einstellung = {
schema: 1,
vorlage,
betrieb,
anrede: wahl(fehl, 'anrede', eingabe.anrede, ['sie', 'du'], 'sie', 'Bitte „Sie“ oder „Du“ wählen.'),
farbe: farbe(fehl, eingabe.farbe),
bundesland: wahl(fehl, 'bundesland', eingabe.bundesland, BUNDESLAENDER, 'RP', 'Bitte wähle ein Bundesland.'),
feiertageGeschlossen: wahrheit(fehl, 'feiertageGeschlossen', eingabe.feiertageGeschlossen, true),
sonderoeffnungen: normalisiereSonderoeffnungen(fehl, eingabe.sonderoeffnungen),
abwesenheiten: normalisiereAbwesenheiten(fehl, eingabe.abwesenheiten, ressourcen),
ressourcen,
leistungen,
regeln: {
raster: wahl(fehl, 'regeln.raster', regeln.raster, RASTER, 15, 'Das Raster muss 5, 10, 15, 20, 30 oder 60 Minuten betragen.'),
vorlaufMin: zahl(fehl, 'regeln.vorlaufMin', regeln.vorlaufMin, { vorgabe: 120, min: 0, max: 20160, meldung: 'Die Vorlaufzeit muss zwischen 0 Minuten und 14 Tagen liegen.' }),
horizontTage: zahl(fehl, 'regeln.horizontTage', regeln.horizontTage, { vorgabe: 60, min: 1, max: 365, meldung: 'Der Buchungszeitraum muss zwischen 1 und 365 Tagen liegen.' }),
absageBisStd: zahl(fehl, 'regeln.absageBisStd', regeln.absageBisStd, { vorgabe: 24, min: 0, max: 336, meldung: 'Die Absagefrist muss zwischen 0 und 336 Stunden liegen.' }),
maxOffeneJeEmail: zahl(fehl, 'regeln.maxOffeneJeEmail', regeln.maxOffeneJeEmail, { vorgabe: 3, min: 1, max: 10, meldung: 'Erlaubt sind 1 bis 10 offene Termine je E-Mail-Adresse.' }),
personWaehlbar: wahrheit(fehl, 'regeln.personWaehlbar', regeln.personWaehlbar, true),
},
gastFelder: normalisiereGastFelder(fehl, eingabe.gastFelder),
erinnerung: {
anbieten: wahrheit(fehl, 'erinnerung.anbieten', erinnerung.anbieten, true),
stundenVorher: wahl(fehl, 'erinnerung.stundenVorher', erinnerung.stundenVorher, ERINNERUNG_STUNDEN, 24, 'Die Erinnerung geht 2, 12, 24 oder 48 Stunden vorher raus.'),
},
storno: normalisiereStorno(eingabe.storno, alt?.storno, now),
recht: {
gaesteSind: wahl(fehl, 'recht.gaesteSind', recht.gaesteSind, ['verbraucher', 'unternehmer', 'gemischt'], 'verbraucher', 'Bitte „Privatpersonen“, „Unternehmen“ oder „beides“ wählen.'),
freizeit: wahrheit(fehl, 'recht.freizeit', recht.freizeit, false),
},
zahlung: normalisiereZahlung(fehl, eingabe.zahlung),
aufbewahrungTage: zahl(fehl, 'aufbewahrungTage', eingabe.aufbewahrungTage, { vorgabe: 90, min: 14, max: 365, meldung: 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.' }),
}
if (!entwurf) {
for (const pfad of fehlendePflichtangaben(einstellung)) fehl(pfad, MELDUNG_PFLICHT[pfad])
}
if (Object.keys(fehler).length === 0 && JSON.stringify(einstellung).length > GRENZEN.json) {
fehl('_', 'Die Einstellungen sind zu umfangreich. Bitte entferne Zeitfenster oder Einträge.')
}
return Object.keys(fehler).length ? { fehler } : { einstellung }
}
function farbe(fehl, wert) {
if (wert === undefined || wert === null) return VORGABE_FARBE
const s = typeof wert === 'string' ? wert.trim().toLowerCase() : ''
if (!/^#[0-9a-f]{6}$/.test(s)) {
fehl('farbe', 'Bitte wähle eine Farbe im Format #rrggbb.')
return VORGABE_FARBE
}
return s
}
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
const MELDUNG_PFLICHT = {
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
}
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
function sichtbareLeistungen(einstellung) {
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
}
/**
* Zeigt die oeffentliche Sicht eine Widerrufsbelehrung (oeffentlicheEinstellung.widerruf)? Verbraucher-Gaeste und
* mindestens eine sichtbare Leistung, gleich ob sofort oder als Anfrage (Ruling R55: auch ein Anfrage-Vertrag, der mit
* der Annahme zustande kommt, ist ein Fernabsatzvertrag; die Belehrung steht im G02 jeder Verbraucher-Buchung).
*/
export function zeigtWiderruf(einstellung) {
return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).length > 0
}
/**
* Pflichtangaben des Betriebs, die in einer Einstellung fehlen: 'betrieb.telefon' (immer noetig) und 'betrieb.adresse'
* (noetig, sobald die Widerrufsbelehrung erscheint, zeigtWiderruf). Reihenfolge Telefon, Anschrift; [] = vollstaendig.
* Ohne diese Angaben ist der Terminplaner fuer Gaeste nicht buchbar.
*/
export function fehlendePflichtangaben(einstellung) {
const betrieb = einstellung?.betrieb || {}
const fehlt = []
if (typeof betrieb.telefon !== 'string' || betrieb.telefon.trim() === '') fehlt.push('betrieb.telefon')
if (zeigtWiderruf(einstellung) && (typeof betrieb.adresse !== 'string' || betrieb.adresse.trim() === '')) fehlt.push('betrieb.adresse')
return fehlt
}
// ---------------------------------------------------------------- Texte und Regeln
/** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */
export function hatPreis(leistung) {
return !!leistung && leistung.preisArt !== 'keiner' && Number.isInteger(leistung.preisCent) && leistung.preisCent > 0
}
/** '45,00 €' | 'ab 45,00 €' | '' (kein Preis). */
export function preisText(leistung) {
if (!hatPreis(leistung)) return ''
const euro = String(Math.floor(leistung.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
const cent = String(leistung.preisCent % 100).padStart(2, '0')
return `${leistung.preisArt === 'ab' ? 'ab ' : ''}${euro},${cent} €`
}
/** Knopf im Widget; bestaetigen = Knopf auf der Bestaetigungsseite. */
export function knopfText(leistung, { bestaetigen = false } = {}) {
if (leistung?.buchungsart === 'anfrage') return bestaetigen ? 'Anfrage absenden' : 'Termin anfragen'
if (hatPreis(leistung)) return bestaetigen ? 'Zahlungspflichtig bestätigen' : 'Zahlungspflichtig buchen'
return bestaetigen ? 'Termin bestätigen' : 'Termin buchen'
}
/** Gaeste gelten als Verbraucher, ausser der Betrieb hat nur Unternehmen oder bietet eine Freizeitleistung an. */
export function istVerbraucher(einstellung) {
const recht = einstellung?.recht || {}
return recht.gaesteSind !== 'unternehmer' && !recht.freizeit
}
/**
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): Preis + Verbraucher und
* - sofort buchbar: Terminbeginn bis zum Ende der Widerrufsfrist (withdrawalDeadline, Berliner Kalender, §§ 187, 188, 193
* BGB). Der Vertrag kommt spaetestens nach der Haltezeit (config.termine.holdMinutes) zustande, gerechnet wird mit
* `now` plus Haltezeit.
* - Anfrage: immer. Den Tag der Annahme kennt niemand im Voraus, die Frist beginnt erst dann; die Zustimmung zu viel zu
* verlangen schadet nicht.
* Ein unbrauchbarer Beginn verlangt die Zustimmung lieber.
*/
export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) {
if (!hatPreis(leistung) || !istVerbraucher(einstellung)) return false
if (leistung.buchungsart === 'anfrage') return true
if (leistung.buchungsart !== 'sofort') return false
const fristEnde = withdrawalDeadline(new Date(now.getTime() + config.termine.holdMinutes * MIN_MS)).endsAt.getTime()
return !(start.getTime() > fristEnde)
}
export function preisHinweis(einstellung) {
return einstellung?.betrieb?.kleinunternehmer === true ? 'kein Ausweis der USt nach § 19 UStG' : 'inkl. USt'
}
/** 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' (§ 312j Abs. 1 BGB: Zahlungsart steht vor der Buchung). */
export function zahlungText(einstellung) {
const arten = (einstellung?.zahlung?.vorOrtArten || []).filter((a) => Object.hasOwn(ZAHLARTEN, a)).map((a) => ZAHLARTEN[a])
return arten.length ? `Bezahlung vor Ort: ${arten.join(', ')}` : 'Bezahlung vor Ort'
}
// ---------------------------------------------------------------- Oeffentliche Sicht
/**
* Felder fuer GET /config (ohne aktiv, hinweis, texte, formToken – die setzt die Route). Nur aktive Leistungen mit
* mindestens einer aktiven Ressource, nur aktive Ressourcen; nie feedToken, Betriebs-E-Mail oder interne Regeln.
*/
export function oeffentlicheEinstellung(einstellung, now) {
const e = einstellung
const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const aktiveIds = new Set(ressourcen.map((r) => r.id))
const verbraucher = istVerbraucher(e)
const leistungen = []
for (const l of e.leistungen || []) {
if (!l.aktiv) continue
const ids = (l.ressourcen || []).filter((id) => aktiveIds.has(id))
if (ids.length === 0) continue
leistungen.push({
id: l.id,
name: l.name,
beschreibung: l.beschreibung,
dauer: l.dauer,
preis: preisText(l),
preisArt: l.preisArt,
buchungsart: l.buchungsart,
knopf: knopfText(l),
verbraucher,
ressourcen: ids,
})
}
return {
heute: heute(now),
betrieb: {
name: e.betrieb.name,
telefon: e.betrieb.telefon,
adresse: e.betrieb.adresse,
impressumUrl: e.betrieb.impressumUrl,
datenschutzUrl: e.betrieb.datenschutzUrl,
agbUrl: e.betrieb.agbUrl, // https-Adresse oder null (urlFeld); das Widget zeigt sie als Link neben den Stornobedingungen
},
anrede: e.anrede,
farbe: e.farbe,
leistungen,
ressourcen: ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ })),
personWaehlbar: e.regeln.personWaehlbar,
regeln: { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd },
zahlung: { modus: 'vorort', text: zahlungText(e) },
preisHinweis: preisHinweis(e),
storno: e.storno?.text ? { text: e.storno.text, version: e.storno.version } : null,
widerruf: zeigtWiderruf(e),
erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher },
gastFelder: structuredClone(e.gastFelder),
}
}
+26
View File
@@ -0,0 +1,26 @@
import { isHoliday } from 'feiertagejs'
/**
* Gesetzliche Feiertage je Bundesland (feiertagejs 1.5.1, Tage als Berliner Kalendertage).
* Reine Funktion ohne Zugriff auf Appwrite; die Betriebs-Einstellung (Feiertage sperren ja/nein) entscheidet der Aufrufer.
*/
/** Die 16 Bundeslaender (Kuerzel wie in feiertagejs, ohne 'BUND'/'ALL'/'AUGSBURG'). */
export const BUNDESLAENDER = Object.freeze([
'BW', 'BY', 'BE', 'BB', 'HB', 'HH', 'HE', 'MV', 'NI', 'NW', 'RP', 'SL', 'SN', 'ST', 'SH', 'TH',
])
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
/**
* Ist tag ('YYYY-MM-DD') in diesem Bundesland ein gesetzlicher Feiertag?
* Unbekanntes oder leeres Land und ungueltiger Tag ergeben false (kein Fehler, der die oeffentliche Abfrage kippt).
*/
export function istFeiertag(tag, land) {
if (typeof tag !== 'string' || !TAG_RE.test(tag) || !BUNDESLAENDER.includes(land)) return false
try {
return isHoliday(tag, land)
} catch {
return false
}
}
+135
View File
@@ -0,0 +1,135 @@
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Kalenderdateien des Terminplaners (iCalendar nach RFC 5545, Terminmitteilungen nach RFC 5546).
* Eigener kleiner Schreiber ohne Abhaengigkeit; Zeiten immer in UTC (`...Z`), damit keine VTIMEZONE noetig ist.
*
* - Zeilen enden mit CRLF und werden bei 75 Oktetten (UTF-8-Bytes, nie mitten in einem Zeichen) gefaltet.
* - TEXT-Werte (Titel, Ort, Beschreibung, Kalendername): `\`, `;`, `,` und Zeilenumbrueche werden maskiert, damit ein
* Wert nie eine neue Eigenschaft beginnen kann. Alles andere (UID, E-Mail, CN) verliert CR/LF und Steuerzeichen.
* - Pure Funktionen: kein config, keine Uhr; `jetzt` (DTSTAMP) kommt vom Aufrufer.
*
* UID: stabil je Buchung (`<bookingId>@termine.webklar.com`), SEQUENCE steigt bei jeder Aenderung des Termins.
*/
const PRODID = '-//WEBklar//Terminplaner//DE'
const METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
const encoder = new TextEncoder()
// Steuerzeichen ausser Tab (und LF/CR, die je nach Feld maskiert oder entfernt werden)
const STEUER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
const sauber = (wert) => String(wert ?? '').toWellFormed()
/** Wert ohne CR/LF und Steuerzeichen (UID, Parameter, E-Mail). */
const einzeilig = (wert) => sauber(wert).replace(/[\r\n]/g, '').replace(STEUER, '')
/** TEXT nach RFC 5545 3.3.11: Zeilenumbrueche werden zu `\n`, `\`, `;` und `,` bekommen einen Backslash. */
const text = (wert) =>
sauber(wert)
.replace(STEUER, '')
.replace(/[\\;,]/g, '\\$&')
.replace(/\r\n|[\r\n]/g, '\\n')
/** Parameterwert in Anfuehrungszeichen (CN): ohne `"`, CR/LF und Steuerzeichen. */
const parameter = (wert) => einzeilig(wert).replace(/"/g, '')
/** mailto-Adresse: genau ein Postfach, ohne Leerraum und Trennzeichen. */
const adresse = (wert) => einzeilig(wert).replace(/[\s<>",;\\]/g, '')
/** Zeitpunkt als `20261005T080000Z` (Sekundenbruchteile fallen weg). */
const utc = (ms) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
/** Eine logische Zeile bei 75 Oktetten falten; Folgezeilen beginnen mit einem Leerzeichen (zaehlt als Oktett). */
function falte(zeile) {
if (encoder.encode(zeile).length <= 75) return zeile
const teile = []
let aktuell = ''
let oktette = 0
for (const zeichen of zeile) {
const n = encoder.encode(zeichen).length
if (oktette + n > 75) {
teile.push(aktuell)
aktuell = ' '
oktette = 1
}
aktuell += zeichen
oktette += n
}
teile.push(aktuell)
return teile.join('\r\n')
}
function pruefeZeit(wert, name) {
const ms = zeitMs(wert)
if (!Number.isFinite(ms)) throw new TypeError(`${name} ist kein gueltiger Zeitpunkt`)
return ms
}
/** `;CN="Name"` (nur mit Name) und `mailto:`-Adresse einer Person; ohne Adresse kein gueltiger Eintrag. */
function person(p, feld) {
const email = adresse(p?.email)
pruefeText(email, `${feld}.email`)
const name = parameter(p.name)
return { cn: name ? `;CN="${name}"` : '', mailto: `mailto:${email}` }
}
/** Zeilen eines VEVENT. `methode` entscheidet ueber ATTENDEE (nicht bei PUBLISH) und STATUS. */
function veranstaltung(t, methode, jetztMs) {
pruefeText(einzeilig(t.uid), 'uid')
pruefeText(text(t.titel), 'titel')
const sequenz = t.sequenz ?? 0
if (!Number.isInteger(sequenz) || sequenz < 0) throw new TypeError('sequenz ist keine ganze Zahl ab 0')
const start = pruefeZeit(t.start, 'start')
const ende = pruefeZeit(t.ende, 'ende')
if (ende <= start) throw new TypeError('ende liegt nicht nach start')
const org = t.organisator
if (!org && methode !== 'PUBLISH') throw new TypeError(`organisator fehlt (${methode} braucht den Organisator)`)
const zeilen = ['BEGIN:VEVENT', `UID:${einzeilig(t.uid)}`, `DTSTAMP:${utc(jetztMs)}`, `SEQUENCE:${sequenz}`, `DTSTART:${utc(start)}`, `DTEND:${utc(ende)}`, `SUMMARY:${text(t.titel)}`]
if (text(t.beschreibung)) zeilen.push(`DESCRIPTION:${text(t.beschreibung)}`)
if (text(t.ort)) zeilen.push(`LOCATION:${text(t.ort)}`)
if (org) {
const o = person(org, 'organisator')
zeilen.push(`ORGANIZER${o.cn}:${o.mailto}`)
}
// RFC 5546: PUBLISH enthaelt keine Teilnehmer (Betriebsmail und Abo tragen keine Gaeste ein)
if (t.teilnehmer && methode !== 'PUBLISH') {
const g = person(t.teilnehmer, 'teilnehmer')
const partstat = methode === 'CANCEL' ? 'DECLINED' : 'ACCEPTED'
zeilen.push(`ATTENDEE${g.cn};ROLE=REQ-PARTICIPANT;PARTSTAT=${partstat};RSVP=FALSE:${g.mailto}`)
}
zeilen.push(`STATUS:${methode === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, 'TRANSP:OPAQUE', 'END:VEVENT')
return zeilen
}
function kalender(methode, termine, jetzt, kopf = []) {
const jetztMs = pruefeNow(jetzt, 'jetzt')
const zeilen = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${PRODID}`, 'CALSCALE:GREGORIAN', `METHOD:${methode}`, ...kopf]
for (const t of termine) zeilen.push(...veranstaltung(t, methode, jetztMs))
zeilen.push('END:VCALENDAR')
return `${zeilen.map(falte).join('\r\n')}\r\n`
}
/**
* Kalenderdatei mit genau einem Termin (Anhang einer Mail).
* @param {{ uid: string, sequenz?: number, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start: Date|string|number,
* ende: Date|string|number, titel: string, ort?: string, beschreibung?: string,
* organisator: { name: string, email: string }, teilnehmer?: { name: string, email: string } | null, jetzt: Date }} termin
* `methode` CANCEL setzt STATUS:CANCELLED (gleiche UID, hoehere SEQUENCE); PUBLISH traegt keinen ATTENDEE.
* @returns {string} CRLF-getrennt, mit abschliessendem CRLF
*/
export function icsTermin(termin) {
if (!METHODEN.includes(termin?.methode)) throw new TypeError(`methode muss ${METHODEN.join(', ')} sein`)
return kalender(termin.methode, [termin], termin.jetzt)
}
/**
* Kalender-Abo (METHOD:PUBLISH, mehrere Termine, ohne Gaeste). Jeder Eintrag wie bei icsTermin (`methode` und
* `teilnehmer` werden ignoriert; `organisator` ist hier freiwillig).
* @param {object[]} termine
* @param {{ name: string, jetzt: Date }} optionen
*/
export function icsKalender(termine, { name, jetzt }) {
return kalender('PUBLISH', termine, jetzt, [...(text(name) ? [`X-WR-CALNAME:${text(name)}`] : []), 'X-PUBLISHED-TTL:PT15M'])
}
+455
View File
@@ -0,0 +1,455 @@
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import {
abgelaufeneHaltungen,
aendere,
erinnerungsKandidaten,
hole,
loesche,
loeschKandidaten,
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
/**
* Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js
* ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden
* fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in
* der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail)
* taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen
*
* Gemeinsame Regeln:
* - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung).
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
* - Jede Zustandsaenderung einer Buchung laeuft unter `termine-buchung:<id>` mit frischem Lesen und erneuter Pruefung der
* Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job
* ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange.
* - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre).
*/
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
const AUFBEWAHRUNG_MAX = 365
const laufend = new Set()
const ueberlappGemeldet = new Set()
/** Zeitpunkt (ms) der letzten Warnung wegen fehlender Collection je Job. */
const letzteWarnung = new Map()
const fehlendeCollection = (err) => err?.status === 404 && (err.type === 'collection_not_found' || err.type === 'database_not_found')
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** Fehler eines Schritts ins Log (ohne Gastdaten) und in die Zaehlung; fehlende Collection hoechstens einmal je Stunde und Job. */
function melde(lauf, was, err, bookingId = null) {
lauf.fehler += 1
if (fehlendeCollection(err)) {
const zuletzt = letzteWarnung.get(lauf.job)
if (zuletzt !== undefined && lauf.nowMs - zuletzt < STUNDE_MS) return
letzteWarnung.set(lauf.job, lauf.nowMs)
console.warn(`[termine-jobs] ${lauf.job}, Schritt „${was}“: ${err.type}, die Termine-Einrichtung ist unvollständig (Warnung höchstens einmal je Stunde)`)
return
}
const nummer = bookingId ? ` (${buchungsNummer(bookingId)})` : ''
console.error(`[termine-jobs] ${lauf.job}: ${was}${nummer}: ${fehlerText(err)}`)
}
/** Fuehrt einen Schritt aus; ein Fehler wird gemeldet und ergibt `ersatz`. */
async function schritt(lauf, was, fn, ersatz) {
try {
return await fn()
} catch (err) {
melde(lauf, was, err)
return ersatz
}
}
/**
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
*/
async function fuehreAus(job, now, arbeit) {
const nowMs = pruefeNow(now, job)
if (termineAus()) return { aus: true }
if (laufend.has(job)) {
if (!ueberlappGemeldet.has(job)) {
ueberlappGemeldet.add(job)
console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(job)
try {
return await arbeit({ job, nowMs, fehler: 0 })
} finally {
laufend.delete(job)
ueberlappGemeldet.delete(job)
}
}
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0 })
/**
* Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt.
* => Ergebnis von fn | false (Buchung fehlt) | undefined (Fehler, im Log; die Liste laeuft weiter)
*/
async function mitFrischer(lauf, was, id, fn) {
try {
return await withLocks([`termine-buchung:${id}`], async () => {
const frisch = await hole(id)
return frisch ? fn(frisch) : false
})
} catch (err) {
melde(lauf, was, err, id)
return undefined
}
}
// ---------------------------------------------------------------- Minutenjob
/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */
async function verfalleHaltungen(lauf, now) {
let verfallen = 0
for (const kandidat of await abgelaufeneHaltungen(now)) {
const ok = await mitFrischer(lauf, 'Haltung nicht verfallen', kandidat.$id, async (frisch) => {
if (frisch.status !== 'gehalten' || !(zeitMs(frisch.heldUntil) <= lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'verfallen', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) verfallen += 1
}
return verfallen
}
/**
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden.
* @param {Date} now
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
*/
export async function minutenJob(now) {
return fuehreAus('minutenJob', now, async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
})
}
// ---------------------------------------------------------------- Fuenf-Minuten-Job
/**
* Ist die Erinnerung jetzt zu senden? bestaetigt, Wunsch, kein Marker, Adresse da, Beginn in (jetzt + 1 h, jetzt + stundenVorher]
* und Vertragsschluss VOR Beginn - stundenVorher (wer erst danach gebucht hat, bekommt keine). Ohne Vertragsschluss nein.
*/
function erinnerungFaellig(buchung, stundenVorher, nowMs) {
if (buchung.status !== 'bestaetigt' || buchung.erinnerung !== true || buchung.erinnerungGesendetAt) return false
if (typeof buchung.gastEmail !== 'string' || buchung.gastEmail === '') return false
const start = zeitMs(buchung.startsAt)
const fruehestens = start - stundenVorher * STUNDE_MS
if (!(start > nowMs + STUNDE_MS && fruehestens <= nowMs)) return false
return vertragAm(buchung) < fruehestens
}
/**
* Erinnerungen: unter der Buchungssperre frisch lesen und pruefen, erst den Marker erinnerungGesendetAt setzen, dann (ausserhalb
* der Sperre) G05 einreihen. Scheitert das Einreihen, bleibt der Marker: eine Erinnerung geht hoechstens einmal raus.
* Der Kontext einer Funktion (Einstellung mit stundenVorher) wird je Lauf einmal gelesen. Ohne Funktion oder Einstellung: keine
* Erinnerung, kein Marker.
*/
async function sendeErinnerungen(lauf, now) {
const kontexte = new Map()
const kontext = (featureId) => {
if (!kontexte.has(featureId)) {
kontexte.set(
featureId,
kontextPerFeature(featureId).catch((err) => {
melde(lauf, 'Funktion nicht gelesen', err)
return null
}),
)
}
return kontexte.get(featureId)
}
let gesendet = 0
for (const kandidat of await erinnerungsKandidaten(now)) {
const id = kandidat.$id
const ctx = await kontext(kandidat.featureId)
const stundenVorher = ctx?.einstellung?.erinnerung?.stundenVorher
if (!Number.isInteger(stundenVorher) || stundenVorher < 1) continue
if (!erinnerungFaellig(kandidat, stundenVorher, lauf.nowMs)) continue
const markiert = await mitFrischer(lauf, 'Erinnerung nicht vermerkt', id, async (frisch) =>
erinnerungFaellig(frisch, stundenVorher, lauf.nowMs) ? aendere(id, { erinnerungGesendetAt: iso(now), updatedAt: iso(now) }) : false,
)
if (!markiert) continue
try {
await anGast('G05', ctx, markiert, {}, now)
gesendet += 1
} catch (err) {
melde(lauf, 'G05 nicht eingereiht', err, id)
}
}
return gesendet
}
/** bestaetigt mit Ende vor mehr als 1 Stunde (auch Sperren) => erledigt. Zellen bleiben (vergangen, der Tagesjob raeumt sie). */
async function erledige(lauf, now) {
let erledigt = 0
for (const kandidat of await zuErledigen(now)) {
const ok = await mitFrischer(lauf, 'Termin nicht erledigt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'bestaetigt' || !(zeitMs(frisch.endsAt) < lauf.nowMs - STUNDE_MS)) return false
await aendere(frisch.$id, { status: 'erledigt', updatedAt: iso(now) })
return true
})
if (ok) erledigt += 1
}
return erledigt
}
/** angefragt mit Beginn in der Vergangenheit => abgelehnt vom System, Zellen frei, keine Mail. */
async function lehneVergangeneAnfragenAb(lauf, now) {
let abgelehnt = 0
for (const kandidat of await vergangeneAnfragen(now)) {
const ok = await mitFrischer(lauf, 'Anfrage nicht abgelehnt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'angefragt' || !(zeitMs(frisch.startsAt) < lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'abgelehnt', abgesagtVon: 'system', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) abgelehnt += 1
}
return abgelehnt
}
/**
* Alle 5 Minuten: Erinnerungen, erledigte Termine, vergangene Anfragen.
* @param {Date} now
* @returns {Promise<{ erinnerungen: number, erledigt: number, abgelehnt: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
*/
export async function fuenfMinutenJob(now) {
return fuehreAus('fuenfMinutenJob', now, async (lauf) => {
const erinnerungen = await schritt(lauf, 'Erinnerungen', () => sendeErinnerungen(lauf, now), 0)
const erledigt = await schritt(lauf, 'Erledigt', () => erledige(lauf, now), 0)
const abgelehnt = await schritt(lauf, 'Vergangene Anfragen', () => lehneVergangeneAnfragenAb(lauf, now), 0)
return { erinnerungen, erledigt, abgelehnt, fehler: lauf.fehler }
})
}
// ---------------------------------------------------------------- Tagesjob
/**
* Aufbewahrung (Tage nach Terminende) einer Funktion. Nicht gespeichert oder unlesbar (null aus ladeEinstellung) oder ausserhalb
* 14-365: 90. Scheitert das Lesen selbst (Datenbank), null: die Buchungen dieser Funktion bleiben bis zum naechsten Lauf, es wird
* nie frueher geloescht als eingestellt.
*/
async function aufbewahrungTage(lauf, featureId) {
if (typeof featureId !== 'string' || featureId === '') return AUFBEWAHRUNG_VORGABE
try {
const tage = (await ladeEinstellung(featureId))?.einstellung?.aufbewahrungTage
return Number.isInteger(tage) && tage >= AUFBEWAHRUNG_MIN && tage <= AUFBEWAHRUNG_MAX ? tage : AUFBEWAHRUNG_VORGABE
} catch (err) {
melde(lauf, 'Aufbewahrung nicht lesbar, Funktion bleibt unberührt', err)
return null
}
}
/** Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). => true | false | undefined (Fehler) */
function loescheMitZellen(lauf, id, status) {
return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => {
if (status !== null && frisch.status !== status) return false
await gibZellenFrei(id)
await loesche(id)
return true
})
}
async function loeschfristen(lauf, now) {
const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }
const tage = new Map()
const tageFuer = (featureId) => {
if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId))
return tage.get(featureId)
}
// Je Status: Kandidaten ab der fruehesten moeglichen Frist; bei erledigt/nichterschienen entscheidet die Frist der Funktion
const fristen = [
['verfallen', VERFALLEN_BEHALTEN_MS, false],
['abgesagt', ABGESAGT_BEHALTEN_MS, false],
['abgelehnt', ABGESAGT_BEHALTEN_MS, false],
['erledigt', AUFBEWAHRUNG_MIN * TAG_MS, true],
['nichterschienen', AUFBEWAHRUNG_MIN * TAG_MS, true],
]
for (const [status, behalten, jeFunktion] of fristen) {
await schritt(lauf, `Löschfrist ${status}`, async () => {
for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - behalten))) {
if (jeFunktion) {
const t = await tageFuer(kandidat.featureId)
if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue
}
if (await loescheMitZellen(lauf, kandidat.$id, status)) geloescht[status] += 1
}
})
}
return geloescht
}
/** So viele Buchungen einer abgeschalteten Funktion hoechstens, bevor ihr Zustand erneut gelesen wird. */
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
}
return false
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen. Vor der Buchungsliste, nach jeweils 50 Buchungen und vor dem Loeschen der Einstellung wird die Funktion frisch gelesen
* (nochAbgeschaltet); gilt sie nicht mehr als abgeschaltet, endet die Loeschung fuer diese Funktion sofort (Log, kein Wurf).
* Scheitert eine Buchung, bleibt die Einstellung bis zum naechsten Lauf. Zellen ohne Buchung raeumt danach der Tagesjob.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung).
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0 }
const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const buchungen = await schritt(lauf, 'Buchungen abgeschalteter Funktion', () => listAllDocuments(config.collections.termineBookings, [Query.equal('featureId', funktion.$id)]), null)
if (buchungen === null || !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) continue
let geloescht = 0
let vollstaendig = true
for (const [i, buchung] of buchungen.entries()) {
if (i > 0 && i % ABGESCHALTET_NEU_PRUEFEN_ALLE === 0 && !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) {
vollstaendig = false
break
}
const weg = await loescheMitZellen(lauf, buchung.$id, null)
if (weg) geloescht += 1
if (weg === undefined) vollstaendig = false
}
erg.buchungen += geloescht
let einstellungWeg = false
if (vollstaendig) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktion.$id}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktion.$id, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktion.$id)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
if (geloescht > 0 || einstellungWeg) erg.funktionen += 1
}
return erg
}
/**
* Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen. Baustein des
* Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number }>}
*/
export function wendeLoeschfristenAn(now) {
return loeschfristen(einzelLauf('loeschfristen', now), now)
}
/**
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen. Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ funktionen: number, buchungen: number }>}
*/
export function loescheAbgeschalteteFunktionen(now) {
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
}
/**
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
* Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen.
* @param {Date} now
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
*/
export async function taeglicherJob(now) {
return fuehreAus('taeglicherJob', now, async (lauf) => {
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const loeschung = await loeschfristen(lauf, now)
const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 })
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0)
return { festgefahren, loeschfristen: loeschung, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler }
})
}
/**
* Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs
* (Spur der unwiderruflichen Loeschungen). null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen).
* @param {object} ergebnis Rueckgabe von taeglicherJob
* @returns {string|null}
*/
export function tagesbericht(ergebnis) {
if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null
const l = ergebnis.loeschfristen
const a = ergebnis.abgeschaltet
const gesamt = Object.values(l).reduce((summe, n) => summe + n, 0) + a.buchungen
return (
`[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` +
`erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` +
`${ergebnis.festgefahren} Mails nicht zugestellt, ${ergebnis.fehler} Fehler`
)
}
/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */
export function resetJobsForTests() {
laufend.clear()
ueberlappGemeldet.clear()
letzteWarnung.clear()
}
+216
View File
@@ -0,0 +1,216 @@
import { config } from '../../config.js'
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
import { analyseHosts } from '../analyse.js'
import { withLocks } from '../keyedLock.js'
import { findActiveBySiteKey } from '../siteFeatures.js'
import { fehlendePflichtangaben } from './einstellungen.js'
import { termineAus } from './schalter.js'
import { pruefeNow, pruefeText } from './util.js'
/**
* Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und
* Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
*
* ctx = { feature, project, einstellung, version }
* feature portalFeatures-Dokument (featureKey 'termine')
* project websiteProjects-Dokument oder null (Projekt fehlt)
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
*
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
* Einstellung, in der die Pflichtangaben fuer Gaeste stehen (fehlendePflichtangaben, Ruling R41: ein Entwurf aus einer
* Vorlage ist noch nicht buchbar); Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
* gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
* ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0).
* Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen.
*/
const FUNKTION = 'termine'
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const CACHE_MS = 30 * 1000
const CACHE_MAX = 10000
const cache = new Map()
/** Wird mit jedem Speichern erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
let generation = 0
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
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
}
}
/**
* { einstellung, version } aus einem termineConfig-Dokument. Unlesbares JSON (mit Log) ergibt einstellung null, die Version
* des Dokuments bleibt: wer die kaputte Einstellung ueberschreiben will, speichert mit genau dieser Version.
*/
function liesDokument(doc) {
const version = Number.isInteger(doc.version) ? doc.version : 0
try {
const einstellung = JSON.parse(doc.einstellung)
if (!istObjekt(einstellung)) throw new Error('kein Objekt')
return { einstellung, version }
} catch (err) {
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
return { einstellung: null, version }
}
}
/**
* Die gespeicherte Einstellung einer Funktion.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<{ einstellung: object, version: number }|null>} null, wenn es noch keine (lesbare) gibt
*/
export async function ladeEinstellung(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
const gelesen = doc ? liesDokument(doc) : null
return gelesen?.einstellung ? gelesen : null
}
async function baueKontext(feature) {
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
holeOderNull(config.collections.termineConfig, feature.$id),
])
const gelesen = doc ? liesDokument(doc) : null
return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 }
}
/**
* Kontext fuer die oeffentliche Schnittstelle: aktive Funktion 'termine' mit diesem siteKey und gespeicherter Einstellung.
* @param {string} siteKey
* @param {Date} [now] Zeitpunkt fuer den 30-Sekunden-Speicher
* @returns {Promise<object|null>} null bei Modus off, ungueltigem oder unbekanntem siteKey, anderem Zustand als active
* und fehlender Einstellung
*/
export async function kontextPerSiteKey(siteKey, now = new Date()) {
if (termineAus()) return null
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
const nowMs = pruefeNow(now, 'kontextPerSiteKey')
const gemerkt = cache.get(siteKey)
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
const stand = generation
const feature = await findActiveBySiteKey(siteKey, FUNKTION)
let ctx = feature ? await baueKontext(feature) : null
if (ctx && (!ctx.einstellung || fehlendePflichtangaben(ctx.einstellung).length > 0)) ctx = null
if (stand === generation) merke(siteKey, ctx, nowMs)
return ctx
}
function merke(siteKey, ctx, nowMs) {
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
}
/**
* Kontext fuer Portal und Gaesteseiten (Token): Funktion 'termine' im Zustand active oder disabled, ohne Speicher.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<object|null>} null bei Modus off, unbekannter ID, anderer Funktion oder anderem Zustand
*/
export async function kontextPerFeature(featureId) {
if (termineAus()) return null
if (typeof featureId !== 'string' || featureId === '') return null
const feature = await holeOderNull(config.collections.portalFeatures, featureId)
if (!feature || feature.featureKey !== FUNKTION || !['active', 'disabled'].includes(feature.state)) return null
return baueKontext(feature)
}
const veraltet = () =>
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
status: 409,
code: 'EINSTELLUNG_VERALTET',
})
/**
* Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
* { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre
* termine-cfg:<featureId>. Danach ist der Speicher von kontextPerSiteKey leer.
* Statt der Einstellung darf eine Funktion `async (bisher) => einstellung` uebergeben werden (Ruling R40): sie laeuft
* INNERHALB der Sperre nach der Versionspruefung, bekommt die gespeicherte Einstellung (Objekt) oder null und liefert die
* zu speichernde; sie darf werfen (der Fehler geht unveraendert durch, es wird nichts geschrieben). So laufen Pruefungen,
* die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie
* das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand
* aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch.
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
* @param {object|((bisher: object|null) => object|Promise<object>)} einstellungOderFn
* @param {{ version: number|null, by?: string, now?: Date }} optionen
* @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version
*/
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date() } = {}) {
pruefeText(feature?.$id, 'feature.$id')
const alsFunktion = typeof einstellungOderFn === 'function'
if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt')
const nowMs = pruefeNow(now, 'speichereEinstellung')
return withLocks([`termine-cfg:${feature.$id}`], async () => {
const col = config.collections.termineConfig
const vorhanden = await holeOderNull(col, feature.$id)
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
if (version !== null && version !== aktuell) throw veraltet()
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? liesDokument(vorhanden).einstellung : null) : einstellungOderFn
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
const json = JSON.stringify(einstellung)
const felder = {
customerId: feature.customerId,
projectId: feature.projectId,
einstellung: json,
version: aktuell + 1,
updatedAt: new Date(nowMs).toISOString(),
updatedBy: String(by ?? '').slice(0, 100),
}
try {
if (vorhanden) await updateDocument(col, feature.$id, felder)
else await createDocument(col, felder, feature.$id)
} finally {
// auch bei einem Fehler: unklar, was in der Datenbank steht
generation += 1
cache.clear()
}
return { einstellung: JSON.parse(json), version: aktuell + 1 }
})
}
/**
* Adresse der Website des Betriebs (Link auf Gaesteseiten und im Mailfuss), immer die echte Seite statt der Vorschau:
* betrieb.websiteUrl; sonst https:// + Live-Domain des Projekts (wenn project.liveDomain einen gueltigen Host ergibt);
* sonst https:// + <subdomain>.project.webklar.com; sonst die Portal-Adresse (config.publicBaseUrl).
*/
export function websiteUrl(ctx) {
const eigene = ctx?.einstellung?.betrieb?.websiteUrl
if (typeof eigene === 'string' && eigene !== '') return eigene
const project = ctx?.project
if (project) {
// analyseHosts: [<subdomain>.project.webklar.com, Live-Domain], ungueltige und doppelte Hosts fehlen
const hosts = analyseHosts(project)
const vorschau = project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
const live = project.liveDomain ? hosts.find((host) => host !== vorschau) : undefined
const host = live ?? hosts.find((h) => h === vorschau)
if (host) return `https://${host}`
}
return config.publicBaseUrl
}
/** Leert den Speicher von kontextPerSiteKey (Tests). */
export function resetKontextForTests() {
generation += 1
cache.clear()
}
+206
View File
@@ -0,0 +1,206 @@
import { isIPv4, isIPv6 } from 'node:net'
import { pruefeNow, pruefeText } from './util.js'
/**
* Grenzen des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 8): Zaehler im Speicher dieses
* Prozesses, je Schluessel ein festes Fenster ab dem ersten gezaehlten Ereignis. Die Schluessel enthalten Adressen oder
* IPs und verlassen den Speicher nie (kein Log, keine Datenbank). Hoechstens 10 000 Schluessel: ist die Map voll,
* faellt der aelteste (zuerst begonnene) Zaehler weg; ein abgelaufenes Fenster wird beim naechsten Zaehlen neu begonnen.
* Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht).
*
* Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt).
* Mails des Widerrufs: darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende
* Buchung, ein Treffer bekommt seine Mails immer.
* Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe
* je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01
* (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* ein ganzes /64, die Adresse darin wechselt beliebig.
*
* Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben
* beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion
* und siteKey (G01 und G08 einen Tag, Haltungen eine Stunde) nie verdraengen. Ist eine Map voll, werden zuerst alle
* abgelaufenen Zaehler entfernt (jeder Eintrag merkt sich das Ende seines Fensters); erst wenn dann noch kein Platz ist,
* faellt der aelteste noch laufende Zaehler dieser Map weg.
*/
const MAX_SCHLUESSEL = 10000
const STUNDE_MS = 60 * 60 * 1000
const TAG_MS = 24 * STUNDE_MS
/** G08 (Eingangsbestaetigung des Widerrufs): je Empfaengeradresse und Tag, je Funktion und Stunde. */
const G08_JE_ADRESSE = 3
const G08_JE_FUNKTION = 20
/** K21 "nicht zugeordnet": je Funktion und Stunde. */
const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30
const MINUTE_MS = 60 * 1000
/** Lesende Abrufe und Aufrufe mit Token: je IP und Minute, je siteKey und Minute. */
const ANFRAGEN_JE_IP = 120
const ANFRAGEN_JE_SITE = 600
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
const BUCHUNGEN_JE_IP = 10
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
const HALTUNGEN_JE_SITE = 60
/** Widerrufserklaerungen: je IP und Stunde. */
const WIDERRUFE_JE_IP = 10
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
const G01_JE_ADRESSE = 5
/**
* Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne.
* fruehesterAblauf ist eine untere Schranke fuer das kleinste ende: solange nowMs darunter liegt, kann es nichts
* Abgelaufenes geben und das Aufraeumen einer vollen Map entfaellt (kein Durchlauf je neuem Schluessel).
*/
const neuerSpeicher = () => ({ map: new Map(), fruehesterAblauf: Infinity })
const ipZaehler = neuerSpeicher()
const sonstigeZaehler = neuerSpeicher()
/** Zaehler je IP ('ip:...') und alle anderen liegen in getrennten Maps. */
const speicherFuer = (schluessel) => (schluessel.startsWith('ip:') ? ipZaehler : sonstigeZaehler)
/** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */
function laufend(schluessel, fensterMs, nowMs) {
const eintrag = speicherFuer(schluessel).map.get(schluessel)
return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined
}
/** Entfernt alle abgelaufenen Zaehler und merkt sich das fruehste Ende der uebrigen. */
function raeumeAbgelaufene(speicher, nowMs) {
let fruehester = Infinity
for (const [schluessel, eintrag] of speicher.map) {
if (eintrag.ende <= nowMs) speicher.map.delete(schluessel)
else if (eintrag.ende < fruehester) fruehester = eintrag.ende
}
speicher.fruehesterAblauf = fruehester
}
/** Beginnt ein neues Fenster fuer den Schluessel; macht in einer vollen Map zuerst Platz (abgelaufene, dann der aelteste). */
function beginne(schluessel, fensterMs, nowMs) {
const speicher = speicherFuer(schluessel)
speicher.map.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge
if (speicher.map.size >= MAX_SCHLUESSEL && nowMs >= speicher.fruehesterAblauf) raeumeAbgelaufene(speicher, nowMs)
if (speicher.map.size >= MAX_SCHLUESSEL) speicher.map.delete(speicher.map.keys().next().value)
const ende = nowMs + fensterMs
speicher.map.set(schluessel, { start: nowMs, ende, n: 1 })
if (ende < speicher.fruehesterAblauf) speicher.fruehesterAblauf = ende
}
/**
* Zaehlt ein Ereignis je Regel, aber nur, wenn keine Regel ihre Grenze erreicht hat (alle oder keine).
* @param {{ schluessel: string, max: number, fensterMs: number }[]} regeln
* @param {Date} now
* @returns {boolean} true = erlaubt (gezaehlt), false = mindestens eine Grenze erreicht
*/
export function zaehleAlle(regeln, now) {
const nowMs = pruefeNow(now, 'zaehleAlle')
if (!Array.isArray(regeln) || regeln.length === 0) throw new TypeError('zaehleAlle: regeln fehlen')
for (const { schluessel, max, fensterMs } of regeln) {
pruefeText(schluessel, 'schluessel')
if (!Number.isInteger(max) || max < 0 || !(fensterMs > 0)) throw new TypeError('zaehleAlle: max oder fensterMs ungueltig')
if ((laufend(schluessel, fensterMs, nowMs)?.n ?? 0) >= max) return false
}
for (const { schluessel, fensterMs } of regeln) {
const eintrag = laufend(schluessel, fensterMs, nowMs)
if (eintrag) eintrag.n += 1
else beginne(schluessel, fensterMs, nowMs)
}
return true
}
/** Ein Ereignis zaehlen: hoechstens max je Fenster. => true = erlaubt (gezaehlt), false = Grenze erreicht. */
export function zaehle(schluessel, max, fensterMs, now) {
return zaehleAlle([{ schluessel, max, fensterMs }], now)
}
/** Darf eine G08 an diese (normalisierte) Adresse gehen? Hoechstens 3 je Adresse und Tag, 20 je Funktion und Stunde. */
export function darfG08(featureId, adresse, now) {
return zaehleAlle(
[
{ schluessel: `g08-adresse:${adresse}`, max: G08_JE_ADRESSE, fensterMs: TAG_MS },
{ schluessel: `g08-funktion:${featureId}`, max: G08_JE_FUNKTION, fensterMs: STUNDE_MS },
],
now,
)
}
/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 30 je Funktion und Stunde. */
export function darfK21NichtZugeordnet(featureId, now) {
return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now)
}
// ---------------------------------------------------------------- IP, siteKey, G01
const acht = (gruppen) => gruppen.length === 8 && gruppen.every((g) => /^[0-9a-f]{1,4}$/.test(g))
/** Die acht Gruppen einer IPv6-Adresse (ohne Zonenkennung, Kurzform "::" und eingebettete IPv4 aufgeloest) oder null. */
function ipv6Gruppen(adresse) {
let s = adresse.split('%')[0]
const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s)
if (v4) {
const [a, b, c, d] = v4.slice(1).map(Number)
s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`
}
const teile = s.split('::')
if (teile.length > 2) return null
const links = teile[0] === '' ? [] : teile[0].split(':')
const rechts = teile.length === 2 && teile[1] !== '' ? teile[1].split(':') : []
const gruppen = teile.length === 2 ? [...links, ...Array(Math.max(0, 8 - links.length - rechts.length)).fill('0'), ...rechts] : links
return acht(gruppen) ? gruppen : null
}
/**
* Zaehlschluessel einer Client-Adresse (req.ip): IPv4 unveraendert, IPv4 in IPv6 (::ffff:a.b.c.d) als IPv4, IPv6 je /64
* (die ersten vier Gruppen, vierstellig); fehlt die Adresse oder ist sie unlesbar: 'unbekannt' (alle teilen sich einen Zaehler).
*/
export function ipSchluessel(ip) {
const s = typeof ip === 'string' ? ip.trim().toLowerCase() : ''
if (isIPv4(s)) return s
if (!isIPv6(s)) return 'unbekannt'
const gruppen = ipv6Gruppen(s)
if (!gruppen) return 'unbekannt'
const werte = gruppen.map((g) => parseInt(g, 16))
// ::ffff:a.b.c.d (Gruppen 0-4 null, Gruppe 5 = ffff) ist ein IPv4-Client hinter einem Dual-Stack-Socket
if (werte.slice(0, 5).every((w) => w === 0) && werte[5] === 0xffff) {
return `${werte[6] >> 8}.${werte[6] & 255}.${werte[7] >> 8}.${werte[7] & 255}`
}
return `v6:${werte.slice(0, 4).map((w) => w.toString(16).padStart(4, '0')).join(':')}`
}
/** Darf diese IP (IPv6 je /64) jetzt einen lesenden Abruf oder Token-Aufruf machen? Hoechstens 120 je Minute. */
export function darfAnfrage(ip, now) {
return zaehle(`ip:anfrage:${ipSchluessel(ip)}`, ANFRAGEN_JE_IP, MINUTE_MS, now)
}
/** Darf diese Funktion (siteKey) jetzt einen lesenden Abruf beantworten? Hoechstens 600 je Minute fuer alle zusammen. */
export function darfAnfrageSite(siteKey, now) {
return zaehle(`anfrage-site:${siteKey}`, ANFRAGEN_JE_SITE, MINUTE_MS, now)
}
/** Darf diese IP (IPv6 je /64) jetzt ein Buchungsformular abschicken? Hoechstens 10 je 10 Minuten (jeder Versuch zaehlt). */
export function darfBuchungsversuch(ip, now) {
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
}
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
export function darfHaltung(siteKey, now) {
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
}
/** Darf diese IP (IPv6 je /64) jetzt eine Widerrufserklaerung abschicken? Hoechstens 10 je Stunde. */
export function darfWiderrufsversuch(ip, now) {
return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now)
}
/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */
export function darfG01(featureId, adresse, now) {
return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now)
}
/** Leert alle Zaehler (Tests). */
export function resetTermineLimitsForTests() {
for (const speicher of [ipZaehler, sonstigeZaehler]) {
speicher.map.clear()
speicher.fruehesterAblauf = Infinity
}
}
+542
View File
@@ -0,0 +1,542 @@
import { config } from '../../config.js'
import { getDocument } from '../appwriteAdmin.js'
import { preisHinweis, preisText, zahlungText } from './einstellungen.js'
import { icsTermin } from './ics.js'
import { websiteUrl } from './kontext.js'
import { einreihen } from './outbox.js'
import { widerrufsbelehrung } from './rechtstexte.js'
import { buchungsNummer, verwaltungsToken } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { datumKurz, datumLang, uhrzeit } from './zeit.js'
/**
* Mails des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6): Betreffzeilen, Werte und
* Bloecke fuer die Vorlagen G01-G08 (an Gaeste) und K20/K21 (an den Betrieb) und das Einreihen in die Warteschlange.
* `art` ist immer die Vorlagen-ID ('G01' ... 'K21'); sie steht auch im Feld `art` des Outbox-Eintrags.
*
* Regeln fuer alle Mails:
* - Genau ein Anrede-Block (sie oder du) je G-Mail, nach einstellung.anrede; K20/K21 sind immer Du-Form ohne diese Bloecke.
* - Leere freiwillige Werte erscheinen als "–". Links auf Impressum und Datenschutz des Betriebs fallen auf die Website
* des Betriebs zurueck (websiteUrl(ctx), zuletzt die Portal-Adresse; ein href darf nicht "–" sein).
* - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer.
* - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse);
* in keinem Betreff steht ein Gastname.
* - Verwaltungslink `${publicBaseUrl}/termin.html#m=<verwaltungsToken>` (G02, G03, G05, G07), Bestaetigungslink `#v=<token>`
* (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`.
*
* `buchung` ist das Buchungsdokument; fuer G07 und alle Mails nach einer Aenderung der Zustand NACH der Aenderung.
* Ohne Buchung (null oder nur Gastangaben) gehen nur G01 `zu_viele`, G08 `nicht_zugeordnet` und K21 `nicht_zugeordnet`.
*
* extra je Vorlage (fall = Variante der Vorlage; fehlt sie dort, wo sie Pflicht ist, wirft werteFuer):
* G01 fall 'bestaetigen' (Vorgabe) | 'zu_viele'; verifyToken (nur bei 'bestaetigen'); an (Adresse, wenn keine Buchung existiert)
* G02 anfrageAngenommen: true, wenn der Betrieb eine Anfrage angenommen hat
* G04 nachricht (optionale Nachricht des Betriebs)
* G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1)
* G07 altStart (bisheriger Beginn, Pflicht)
* G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen,
* es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben)
* G08 fall 'zugeordnet' | 'nach_frist' | 'begonnen' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (Adresse der
* Erklaerung, Pflicht bei 'nicht_zugeordnet'). 'begonnen' (Widerruf nach Terminbeginn, Frist noch nicht um) und
* 'nicht_zugeordnet' tragen keinen Fallblock (neutraler Satz) und keine Buchungsnummer
* K20 fall (= block) 'anfrage' oder nichts
* K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' | 'begonnen' (Pflicht);
* altStart (verschoben); eingang (widerrufen, nach_frist, begonnen, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail,
* gastTelefon, gastNachricht } aus der Erklaerung
* K20/K21 kundeName (Anrede des Kunden; sonst Name aus dem Kundendokument, zuletzt der Betriebsname)
* Fuer Mails ohne Buchung gibt extra.bookingId den Bezug im Outbox-Eintrag an (Vorgabe '-').
*/
const LEER = '–'
const OHNE_BUCHUNG = '-'
const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']
const BETRIEBS_ARTEN = ['K20', 'K21']
const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' }
const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
Object.values(wert).forEach(einfrieren)
}
return wert
}
/**
* Betreffzeilen: BETREFFE[art][anrede][fall] mit {betrieb}, {leistung}, {datum} (TT.MM.JJJJ) und {zeit} (HH:MM).
* K20/K21 stehen in der Du-Form (wie K15-K19). Kein Betreff enthaelt einen Gastnamen.
*/
export const BETREFFE = einfrieren({
G01: {
sie: { bestaetigen: 'Bitte bestätigen Sie Ihren Termin bei {betrieb}', zu_viele: 'Ihr Termin bei {betrieb} konnte nicht vorgemerkt werden' },
du: { bestaetigen: 'Bitte bestätige deinen Termin bei {betrieb}', zu_viele: 'Dein Termin bei {betrieb} konnte nicht vorgemerkt werden' },
},
G02: { sie: { standard: 'Ihr Termin bei {betrieb} am {datum}' }, du: { standard: 'Dein Termin bei {betrieb} am {datum}' } },
G03: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } },
G04: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } },
G05: {
sie: { standard: 'Erinnerung: Ihr Termin am {datum} um {zeit}' },
du: { standard: 'Erinnerung: Dein Termin am {datum} um {zeit}' },
},
G06: { sie: { standard: 'Ihr Termin am {datum} wurde abgesagt' }, du: { standard: 'Dein Termin am {datum} wurde abgesagt' } },
G07: { sie: { standard: 'Ihr Termin wurde verschoben' }, du: { standard: 'Dein Termin wurde verschoben' } },
G08: { sie: { standard: 'Eingangsbestätigung Ihres Widerrufs' }, du: { standard: 'Eingangsbestätigung deines Widerrufs' } },
K20: {
du: {
buchung: 'Neue Buchung: {leistung} am {datum} um {zeit}',
anfrage: 'Neue Terminanfrage: {leistung} am {datum} um {zeit}',
},
},
K21: {
du: {
abgesagt: 'Termin abgesagt: {leistung} am {datum} um {zeit}',
verschoben: 'Termin verschoben: {leistung}, jetzt am {datum} um {zeit}',
widerrufen: 'Termin widerrufen: {leistung} am {datum} um {zeit}',
nach_frist: 'Widerruf nach Ablauf der Frist: {leistung} am {datum} um {zeit}',
begonnen: 'Widerruf nach Terminbeginn: {leistung} am {datum} um {zeit}',
nicht_zugeordnet: 'Widerruf ohne passende Buchung',
},
},
})
function betreff(art, anrede, fall, variablen) {
const muster = BETREFFE[art]?.[anrede]?.[fall]
if (!muster) throw new TypeError(`Kein Betreff für ${art}/${anrede}/${fall}`)
return muster.replace(/\{(\w+)\}/g, (_, name) => {
if (!Object.hasOwn(variablen, name)) throw new TypeError(`Betreff ${art}: Wert für {${name}} fehlt`)
return variablen[name]
})
}
// ---------------------------------------------------------------- Bausteine
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
/** Text oder "–". */
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
function pflichtText(wert, name) {
if (!hatText(wert)) throw new TypeError(`${name} fehlt`)
return wert.trim()
}
function pflichtDatum(wert, name) {
const ms = zeitMs(wert)
if (!Number.isFinite(ms)) throw new TypeError(`${name} fehlt oder ist kein Zeitpunkt`)
return new Date(ms)
}
function wahlFall(fall, erlaubt, name) {
if (!erlaubt.includes(fall)) throw new TypeError(`${name}: fall muss ${erlaubt.join(', ')} sein`)
return fall
}
const verwaltungsLink = (buchung) => `${config.publicBaseUrl}/termin.html#m=${verwaltungsToken(pflichtText(buchung?.$id, 'buchung.$id'))}`
const portalLink = () => `${config.publicBaseUrl}/dashboard.html#termine`
function bestaetigungsLink(token) {
if (typeof token !== 'string' || !VERIFY_TOKEN_RE.test(token)) throw new TypeError('extra.verifyToken fehlt oder ist ungültig')
return `${config.publicBaseUrl}/termin.html#v=${token}`
}
/** Werte des Fusses und Kopfes aller Gaestemails. */
function betriebsWerte(ctx) {
const e = ctx.einstellung
const b = e.betrieb
const seite = websiteUrl(ctx)
return {
farbe: e.farbe,
betrieb_name: b.name,
betrieb_adresse: oderLeer(b.adresse),
betrieb_telefon: oderLeer(b.telefon),
betrieb_email: b.email,
impressum_url: b.impressumUrl || seite,
datenschutz_url: b.datenschutzUrl || seite,
}
}
/** leistung, datum ("Montag, 12.10.2026"), zeit ("10:00") und die Kurzformen fuer Betreffzeilen. */
function terminWerte(buchung) {
const start = pflichtDatum(buchung?.startsAt, 'buchung.startsAt')
return {
leistung: pflichtText(buchung.leistungName, 'buchung.leistungName'),
datum: datumLang(start),
zeit: uhrzeit(start),
kurz: { datum: datumKurz(start), zeit: uhrzeit(start) },
}
}
/** "45 Minuten", "1 Stunde", "1 Stunde 30 Minuten". */
export function dauerText(buchung) {
const minuten = Math.round((pflichtDatum(buchung?.endsAt, 'buchung.endsAt') - pflichtDatum(buchung?.startsAt, 'buchung.startsAt')) / 60000)
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
return [stunden ? `${stunden} ${stunden === 1 ? 'Stunde' : 'Stunden'}` : '', rest || !stunden ? `${rest} ${rest === 1 ? 'Minute' : 'Minuten'}` : '']
.filter(Boolean)
.join(' ')
}
/** Person oder Platz zeigen die Gaestemails nur, wenn der Betrieb mehrere Ressourcen hat. */
const personWert = (e, buchung) => (hatText(buchung?.ressourceName) && (e.ressourcen || []).length > 1 ? buchung.ressourceName.trim() : '')
/** Bei Gast-Datum "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */
const eingangText = (eingang) => {
const d = pflichtDatum(eingang, 'extra.eingang')
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
}
/**
* Welcher Hinweis zum Widerruf gehoert in G02? 'widerruf' (Verbraucherbuchung), 'kein_widerruf' (Freizeitleistung mit
* festem Termin fuer Verbraucher, § 312g Abs. 2 Nr. 9 BGB) oder null (Unternehmer, Handbuchung).
*/
function widerrufFall(e, buchung) {
if (buchung.quelle === 'manuell') return null
if (buchung.verbraucher === true) return 'widerruf'
if (e.recht?.freizeit === true && e.recht.gaesteSind !== 'unternehmer') return 'kein_widerruf'
return null
}
// ---------------------------------------------------------------- Werte je Vorlage
function gastG01({ ctx, anrede, buchung, extra }) {
const b = ctx.einstellung.betrieb
const fall = wahlFall(extra.fall ?? 'bestaetigen', ['bestaetigen', 'zu_viele'], 'G01')
const values = { ...betriebsWerte(ctx), leistung: LEER, datum: LEER, zeit: LEER, link: LEER }
if (fall === 'bestaetigen') {
const t = terminWerte(buchung)
Object.assign(values, { leistung: t.leistung, datum: t.datum, zeit: t.zeit, link: bestaetigungsLink(extra.verifyToken) })
}
values.betreff = betreff('G01', anrede, fall, { betrieb: b.name })
return { values, blocks: { [anrede]: true, [fall]: true } }
}
function gastG02({ ctx, anrede, buchung, extra }) {
const e = ctx.einstellung
const t = terminWerte(buchung)
const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent })
const person = personWert(e, buchung)
const storno = e.storno?.text
const fall = widerrufFall(e, buchung)
const link = verwaltungsLink(buchung)
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
person: oderLeer(person),
datum: t.datum,
zeit: t.zeit,
dauer: dauerText(buchung),
preis: preis ? `${preis} (${preisHinweis(e)})` : LEER,
zahlung: preis ? zahlungText(e) : LEER,
nummer: buchungsNummer(buchung.$id),
link,
ort: oderLeer(e.betrieb.adresse),
storno_text: oderLeer(storno),
// wirft, wenn Anschrift oder Telefon fehlen: eine unvollstaendige Belehrung geht nie raus
widerrufsbelehrung: fall === 'widerruf' ? widerrufsbelehrung(e.betrieb) : LEER,
widerruf_link: link,
betreff: betreff('G02', anrede, 'standard', { betrieb: e.betrieb.name, datum: t.kurz.datum }),
}
const blocks = { [anrede]: true }
if (preis) blocks.preis = true
if (person) blocks.person = true
if (hatText(storno)) blocks.storno = true
if (fall) blocks[fall] = true
if (extra.anfrageAngenommen === true) blocks.anfrage_angenommen = true
return { values, blocks }
}
function gastG03({ ctx, anrede, buchung }) {
const t = terminWerte(buchung)
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
datum: t.datum,
zeit: t.zeit,
link: verwaltungsLink(buchung),
betreff: betreff('G03', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }),
}
return { values, blocks: { [anrede]: true } }
}
function gastG04({ ctx, anrede, buchung, extra }) {
const t = terminWerte(buchung)
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
datum: t.datum,
zeit: t.zeit,
nachricht: oderLeer(extra.nachricht),
betreff: betreff('G04', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }),
}
return { values, blocks: { [anrede]: true, ...(hatText(extra.nachricht) ? { nachricht: true } : {}) } }
}
function gastG05({ ctx, anrede, buchung }) {
const t = terminWerte(buchung)
const person = personWert(ctx.einstellung, buchung)
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
person: oderLeer(person),
datum: t.datum,
zeit: t.zeit,
link: verwaltungsLink(buchung),
betreff: betreff('G05', anrede, 'standard', { datum: t.kurz.datum, zeit: t.kurz.zeit }),
}
return { values, blocks: { [anrede]: true, ...(person ? { person: true } : {}) } }
}
function gastG06({ ctx, anrede, buchung, extra }) {
const b = ctx.einstellung.betrieb
const von = wahlFall(extra.von, ['gast', 'betrieb', 'system'], 'G06 extra.von')
const t = terminWerte(buchung)
// Grund und Nachricht kommen vom Betrieb; sagt der Gast selbst ab, gibt es beides nicht
const grund = von === 'gast' ? '' : extra.grund
const nachricht = von === 'gast' ? '' : extra.nachricht
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
datum: t.datum,
zeit: t.zeit,
// anredeneutral, weil der Wert allein in der Zeile "Abgesagt von" steht
absage_von: von === 'gast' ? (anrede === 'du' ? 'Du selbst' : 'Sie selbst') : b.name,
grund: oderLeer(grund),
nachricht: oderLeer(nachricht),
betreff: betreff('G06', anrede, 'standard', { datum: t.kurz.datum }),
}
const blocks = { [anrede]: true }
if (hatText(grund)) blocks.grund = true
if (hatText(nachricht)) blocks.nachricht = true
return { values, blocks }
}
function gastG07({ ctx, anrede, buchung, extra }) {
const t = terminWerte(buchung)
const alt = pflichtDatum(extra.altStart, 'extra.altStart')
const values = {
...betriebsWerte(ctx),
gast_name: oderLeer(buchung.gastName),
leistung: t.leistung,
alt_datum: datumLang(alt),
alt_zeit: uhrzeit(alt),
datum: t.datum,
zeit: t.zeit,
link: verwaltungsLink(buchung),
betreff: betreff('G07', anrede, 'standard', {}),
}
return { values, blocks: { [anrede]: true } }
}
function gastG08({ ctx, anrede, buchung, extra }) {
const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'begonnen', 'nicht_zugeordnet'], 'G08')
const mitBlock = fall === 'zugeordnet' || fall === 'nach_frist'
const values = {
...betriebsWerte(ctx),
eingang: eingangText(extra.eingang),
// die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes
nummer: mitBlock ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER,
betreff: betreff('G08', anrede, 'standard', {}),
}
return { values, blocks: { [anrede]: true, ...(mitBlock ? { [fall]: true } : {}) } }
}
const GAST_BAUER = {
G01: gastG01,
G02: gastG02,
G03: gastG03,
G04: gastG04,
G05: gastG05,
G06: gastG06,
G07: gastG07,
G08: gastG08,
}
/** Gemeinsame Gastangaben und Termin der Betriebsmails K20/K21 (Werte, die die Vorlage immer erwartet). */
function betriebsMailWerte(ctx, buchung, extra) {
const e = ctx.einstellung
return {
kunde_name: oderLeer(extra.kundeName ?? e.betrieb.name),
gast_name: oderLeer(buchung?.gastName),
gast_email: oderLeer(buchung?.gastEmail),
gast_telefon: oderLeer(buchung?.gastTelefon),
gast_nachricht: oderLeer(buchung?.gastNachricht),
portal_link: portalLink(),
}
}
function betriebK20({ ctx, buchung, extra }) {
const fall = wahlFall(extra.fall ?? 'buchung', ['buchung', 'anfrage'], 'K20')
const t = terminWerte(buchung)
const values = {
...betriebsMailWerte(ctx, buchung, extra),
leistung: t.leistung,
person: oderLeer(buchung.ressourceName),
datum: t.datum,
zeit: t.zeit,
betreff: betreff('K20', 'du', fall, { leistung: t.leistung, datum: t.kurz.datum, zeit: t.kurz.zeit }),
}
return { values, blocks: fall === 'anfrage' ? { anfrage: true } : {} }
}
function betriebK21({ ctx, buchung, extra }) {
const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist', 'begonnen'], 'K21')
const values = {
...betriebsMailWerte(ctx, buchung, extra),
leistung: LEER,
person: LEER,
datum: LEER,
zeit: LEER,
alt_datum: LEER,
alt_zeit: LEER,
eingang: LEER,
}
let kurz = { datum: LEER, zeit: LEER }
if (fall !== 'nicht_zugeordnet') {
const t = terminWerte(buchung)
Object.assign(values, { leistung: t.leistung, person: oderLeer(buchung.ressourceName), datum: t.datum, zeit: t.zeit })
kurz = t.kurz
}
if (fall === 'verschoben') {
const alt = pflichtDatum(extra.altStart, 'extra.altStart')
Object.assign(values, { alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt) })
}
if (['widerrufen', 'nach_frist', 'begonnen', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang)
values.betreff = betreff('K21', 'du', fall, { leistung: values.leistung, datum: kurz.datum, zeit: kurz.zeit })
return { values, blocks: { [fall]: true } }
}
/**
* Betreff, Werte und Bloecke einer Mail (Vorlage `art`), bereit fuer renderMail/einreihen. Siehe Kopfkommentar zu extra.
* @param {string} art Vorlagen-ID ('G01' ... 'G08', 'K20', 'K21')
* @param {{ einstellung: object, project?: object|null, feature?: object }} ctx
* @param {object|null} buchung
* @param {object} [extra]
* @returns {{ values: Record<string, string>, blocks: Record<string, true> }} values.betreff ist der Betreff
*/
export function werteFuer(art, ctx, buchung, extra = {}) {
const e = ctx?.einstellung
if (!e?.betrieb) throw new TypeError('werteFuer: ctx.einstellung fehlt')
const anrede = e.anrede === 'du' ? 'du' : 'sie'
const argumente = { ctx, anrede, buchung, extra: extra ?? {} }
if (GAST_BAUER[art]) return GAST_BAUER[art](argumente)
if (art === 'K20') return betriebK20(argumente)
if (art === 'K21') return betriebK21(argumente)
throw new TypeError(`werteFuer: unbekannte Art ${String(art).slice(0, 20)}`)
}
// ---------------------------------------------------------------- Kalenderdateien
/** Kalenderdatei fuer G02/G07 (REQUEST), G06 (CANCEL) und K20 (PUBLISH, ohne Gast); sonst null. */
function kalenderDatei(art, ctx, buchung, extra, jetzt) {
const methode = ICS_METHODE[art]
if (!methode) return null
const b = ctx.einstellung.betrieb
const nummer = buchungsNummer(buchung.$id)
const aktuell = Number.isInteger(buchung.icsSequenz) ? buchung.icsSequenz : 0
// CANCEL traegt eine hoehere Sequenz als die zuletzt gesendete Einladung
const sequenz = Number.isInteger(extra.sequenz) ? extra.sequenz : aktuell + (methode === 'CANCEL' ? 1 : 0)
return {
methode,
datei: icsTermin({
uid: `${buchung.$id}@termine.webklar.com`,
sequenz,
methode,
start: buchung.startsAt,
ende: buchung.endsAt,
titel: art === 'K20' ? `${buchung.leistungName}: ${oderLeer(buchung.gastName)}` : `${buchung.leistungName} – ${b.name}`,
ort: b.adresse,
beschreibung: `Buchungsnummer: ${nummer}`,
organisator: { name: b.name, email: b.email },
teilnehmer: methode === 'PUBLISH' ? null : { name: buchung.gastName, email: buchung.gastEmail },
jetzt,
}),
}
}
// ---------------------------------------------------------------- Einreihen
/**
* Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils
* ausser mit extra.ohneIcs.
* @param {string} art 'G01' ... 'G08'
* @param {object} ctx
* @param {object|null} buchung
* @param {object} [extra] siehe Kopfkommentar
* @param {Date} [now]
* @returns {Promise<object>} der Outbox-Eintrag (Status wartet; der erste Sendeversuch laeuft im Hintergrund)
*/
export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`)
pruefeNow(now, 'anGast')
extra = extra ?? {}
const { values, blocks } = werteFuer(art, ctx, buchung, extra)
const b = ctx.einstellung.betrieb
const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now)
return einreihen(
{
featureId: ctx.feature?.$id,
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
art,
an: hatText(extra.an) ? extra.an : buchung?.gastEmail,
fromName: b.name,
replyTo: b.email,
template: art,
values,
blocks,
ics: ics?.datei ?? null,
icsMethod: ics?.methode ?? 'REQUEST',
},
now,
)
}
/** Name des Kunden fuer die Anrede in K20/K21; ohne lesbares Kundendokument der Betriebsname. */
async function kundenName(ctx) {
try {
const kunde = await getDocument(config.collections.customers, ctx.feature.customerId)
return kunde?.name || kunde?.email || ctx.einstellung.betrieb.name
} catch (err) {
if (err?.status !== 404) console.warn(`[termine-mails] Kunde ${ctx.feature?.customerId} nicht lesbar: ${err.message}`)
return ctx.einstellung.betrieb.name
}
}
/**
* Reiht eine Mail an den Betrieb ein (K20/K21, Absender "WEBklar", an betrieb.email, nie BCC). K20 traegt einen
* Kalendereintrag (PUBLISH), ausser bei einer unbestaetigten Anfrage; K21 hat keinen.
* @param {string} art 'K20' | 'K21'
* @param {object} ctx
* @param {object|null} buchung
* @param {{ block?: string|null, extra?: object }} [optionen] block = Variante der Vorlage (K20 'anfrage'; K21 Pflicht)
* @param {Date} [now]
* @returns {Promise<object>} der Outbox-Eintrag
*/
export async function anBetrieb(art, ctx, buchung, { block = null, extra = {} } = {}, now = new Date()) {
if (!BETRIEBS_ARTEN.includes(art)) throw new TypeError(`anBetrieb: ${String(art).slice(0, 20)} ist keine Betriebsmail`)
pruefeNow(now, 'anBetrieb')
extra = extra ?? {}
const kundeName = hatText(extra.kundeName) ? extra.kundeName : await kundenName(ctx)
const zusatz = { ...extra, kundeName, ...(block ? { fall: block } : {}) }
const { values, blocks } = werteFuer(art, ctx, buchung, zusatz)
const ics = art === 'K20' && block !== 'anfrage' ? kalenderDatei(art, ctx, buchung, zusatz, now) : null
return einreihen(
{
featureId: ctx.feature?.$id,
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
art,
an: ctx.einstellung.betrieb.email,
fromName: 'WEBklar',
replyTo: null,
template: art,
values,
blocks,
ics: ics?.datei ?? null,
icsMethod: ics?.methode ?? 'PUBLISH',
},
now,
)
}
+400
View File
@@ -0,0 +1,400 @@
import { config } from '../../config.js'
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendGuestMail } from '../mailer.js'
import { renderMail } from '../mailTemplates.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Mail-Warteschlange des Terminplaners (termineOutbox; Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6).
* Alle Termine-Mails laufen hier durch: erst speichern (einreihen), dann senden (sendeEintrag, der einzige Sendepfad).
*
* Sendepfad (sendeEintrag): Pause und Stundenbudget pruefen, Sperre `termine-mail:<id>` (je Eintrag, parallele Laeufe
* senden nie doppelt), Eintrag neu lesen, senden, Ergebnis speichern. Fehlerklassen:
* - TypeError (ungueltige Adresse, fehlender Betreff ...) => sofort `fehler`, kein Versuch am Server
* - EAUTH / SMTP 535 (Anmeldung) => 15 Minuten Pause fuer ALLE Eintraege; der Eintrag bleibt
* `wartet`, auch `versuche` bleibt (kein Fehlversuch der Mail)
* - sonstige 5xx => sofort `fehler`
* - 4xx, Verbindungsfehler, nicht konfiguriertes SMTP => erneut nach 1, 5 und 30 Minuten, nach dem vierten
* Fehlschlag `fehler`
* Nach `gesendet` werden an, html, text und ics geleert (personenbezogene Daten).
*
* `naechsterVersuch` ist die Faelligkeit des Eintrags: bei `wartet` der naechste Sendeversuch, bei `gesendet` der
* Loeschtermin (Versand + 7 Tage), bei `fehler` ebenfalls (Fehlschlag + 48 Stunden). So findet raeumeAuf alles ueber
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
*
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
* Sendezeitpunkte bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt (gleichzeitige Versuche koennen das Budget
* nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist immer ein Parameter (feste Uhr in Tests);
* ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
*/
const col = () => config.collections.termineOutbox
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
/** Wartezeit nach dem 1., 2. und 3. Fehlschlag; der vierte ist endgueltig. */
const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS]
const ANMELDE_PAUSE_MS = 15 * MIN_MS
const GESENDET_BEHALTEN_MS = 7 * TAG_MS
const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS
/** Laenger als das in `wartet` (ab createdAt) ist ein Eintrag festgefahren (beendeFestgefahrene). */
const WARTEN_MAX_MS = 48 * STUNDE_MS
const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
/** Buchungs-IDs je Abfrage in zustellFehler (haelt die Adresse kurz). */
const ID_BLOCK = 50
const iso = (wertMs) => new Date(wertMs).toISOString()
// Zustand des Prozesses
let pauseBisMs = 0
/** Zeitpunkte (ms) der Sendeversuche im letzten Stundenfenster (belegte Plaetze). */
let sendeZeiten = []
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
const laufend = new Set()
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
const unverbucht = new Map()
/** Anzahl freier Plaetze im Stundenbudget zum Zeitpunkt nowMs; raeumt abgelaufene Plaetze aus dem Fenster. */
function freieBudgetPlaetze(nowMs) {
const grenze = nowMs - STUNDE_MS
sendeZeiten = sendeZeiten.filter((zeit) => zeit > grenze)
return Math.max(0, config.termine.mailsProStunde - sendeZeiten.length)
}
/** Pause oder ausgeschoepftes Budget (nur lesen). */
const gebremst = (nowMs) => pauseBisMs > nowMs || freieBudgetPlaetze(nowMs) === 0
/** Belegt einen Platz im Budget (synchron, ohne Zwischenschritt); false bei Pause oder leerem Budget. */
function belegePlatz(nowMs) {
if (gebremst(nowMs)) return false
sendeZeiten.push(nowMs)
return true
}
function gibPlatzFrei(nowMs) {
const stelle = sendeZeiten.indexOf(nowMs)
if (stelle >= 0) sendeZeiten.splice(stelle, 1)
}
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
function fehlerArt(err) {
if (err instanceof TypeError) return 'dauerhaft'
const antwort = Number(err?.responseCode)
if (err?.code === 'EAUTH' || antwort === 535) return 'anmeldung'
if (antwort >= 500 && antwort <= 599) return 'dauerhaft'
return 'vorlaeufig'
}
/** Kurzer Fehlertext fuers Feld `fehler` (hoechstens 300 Zeichen). */
const fehlerText = (err) =>
[err?.code, err?.responseCode, err?.message]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.slice(0, 300)
/**
* Mail einreihen: rendert die Vorlage sofort, speichert den Eintrag als `wartet` und stoesst den ersten Sendeversuch
* an, ohne auf ihn zu warten (Fehler dabei stehen im Log, der Eintrag bleibt fuer verarbeite). Wirft bei ungueltigen
* Eingaben (TypeError) oder wenn das Speichern scheitert; dann ist nichts gespeichert und nichts gesendet.
* `ics` und `icsMethod` gehoeren zusammen: die METHOD-Zeile der Kalenderdatei muss icsMethod entsprechen.
* @param {{ featureId: string, bookingId: string, art: string, an: string, fromName: string, replyTo?: string|null,
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} daten
* @param {Date} [now]
* @returns {Promise<object>} das gespeicherte Dokument (Status wartet)
*/
export async function einreihen(
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST' },
now = new Date(),
) {
for (const [wert, name] of [[featureId, 'featureId'], [bookingId, 'bookingId'], [art, 'art'], [an, 'an'], [fromName, 'fromName'], [template, 'template']]) {
pruefeText(wert, name)
}
const nowMs = pruefeNow(now, 'einreihen')
if (ics) {
if (!ICS_METHODEN.includes(icsMethod)) throw new TypeError(`icsMethod muss ${ICS_METHODEN.join(', ')} sein`)
if (!new RegExp(`^METHOD:${icsMethod}\\r?$`, 'm').test(ics)) throw new TypeError(`ics: METHOD passt nicht zu icsMethod ${icsMethod}`)
}
const mail = renderMail(template, values, blocks)
for (const problem of mail.problems) console.warn(`[termine-outbox] Vorlage ${template}: ${problem}`)
const doc = await createDocument(col(), {
featureId,
bookingId,
art,
an,
fromName,
replyTo: replyTo || '',
subject: mail.subject,
html: mail.html,
text: mail.text,
ics: ics || '',
icsMethod: ics ? icsMethod : '',
status: 'wartet',
versuche: 0,
naechsterVersuch: iso(nowMs),
fehler: '',
createdAt: iso(nowMs),
})
sendeEintrag(doc.$id, now).catch((err) => console.error(`[termine-outbox] Eintrag ${doc.$id}: ${err.message}`))
return doc
}
/**
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
* laeuft und das Stundenbudget reicht.
* @param {string} id
* @param {Date} [now]
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, noch nicht faellig, vorlaeufiger Fehler); fehler = dieser
* Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen
*/
export function sendeEintrag(id, now = new Date()) {
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
laufend.add(lauf)
return lauf
}
async function sende(id, now) {
const nowMs = pruefeNow(now, 'sendeEintrag')
pruefeText(id, 'id')
if (gebremst(nowMs)) return 'wartet'
return withLocks([`termine-mail:${id}`], async () => {
let doc
try {
doc = await getDocument(col(), id)
} catch (err) {
if (err.status === 404) return 'uebersprungen'
throw err
}
if (doc.status !== 'wartet') {
unverbucht.delete(id)
return 'uebersprungen'
}
if (unverbucht.has(id)) {
await speichereGesendet(doc, unverbucht.get(id))
return 'gesendet'
}
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen das Budget nicht ueberziehen
if (!belegePlatz(nowMs)) return 'wartet'
try {
await sendGuestMail({
to: doc.an,
fromName: doc.fromName,
replyTo: doc.replyTo || null,
subject: doc.subject,
html: doc.html,
text: doc.text,
ics: doc.ics || null,
icsMethod: doc.icsMethod || 'REQUEST',
})
} catch (err) {
gibPlatzFrei(nowMs)
return speichereFehlschlag(doc, err, nowMs)
}
await speichereGesendet(doc, nowMs)
return 'gesendet'
})
}
/**
* Nach dem Versand: Status, Zeiten setzen und personenbezogene Felder leeren. Scheitert das Speichern (auch beim
* zweiten Versuch), merkt sich der Prozess den Versand, damit der Eintrag nicht noch einmal gesendet wird.
*/
async function speichereGesendet(doc, gesendetMs) {
const felder = {
status: 'gesendet',
versuche: (Number(doc.versuche) || 0) + 1,
gesendetAt: iso(gesendetMs),
naechsterVersuch: iso(gesendetMs + GESENDET_BEHALTEN_MS),
fehler: '',
an: '',
html: '',
text: '',
ics: '',
}
let letzterFehler
for (let versuch = 0; versuch < 2; versuch++) {
try {
await updateDocument(col(), doc.$id, felder)
unverbucht.delete(doc.$id)
return
} catch (err) {
letzterFehler = err
}
}
unverbucht.set(doc.$id, gesendetMs)
console.error(`[termine-outbox] Eintrag ${doc.$id} gesendet, Speichern gescheitert: ${letzterFehler.message}`)
}
async function speichereFehlschlag(doc, err, nowMs) {
const art = fehlerArt(err)
if (art === 'anmeldung') {
pauseBisMs = Math.max(pauseBisMs, nowMs + ANMELDE_PAUSE_MS)
console.error(`[termine-outbox] SMTP-Anmeldung fehlgeschlagen (${err.code || err.responseCode}), Warteschlange pausiert bis ${iso(pauseBisMs)}`)
return 'wartet'
}
const versuche = (Number(doc.versuche) || 0) + 1
const wartezeit = WIEDERHOLUNG_MS[versuche - 1]
if (art === 'vorlaeufig' && wartezeit !== undefined) {
await updateDocument(col(), doc.$id, { versuche, naechsterVersuch: iso(nowMs + wartezeit), fehler: fehlerText(err) })
return 'wartet'
}
await updateDocument(col(), doc.$id, { status: 'fehler', versuche, naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS), fehler: fehlerText(err) })
console.error(`[termine-outbox] Eintrag ${doc.$id} endgueltig gescheitert nach ${versuche} Versuch(en): ${err.code || ''} ${err.responseCode || ''}`.trim())
return 'fehler'
}
/**
* Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen.
* Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
* @param {Date} now
* @returns {Promise<{ faellig: number, gesendet: number }>}
*/
export async function verarbeite(now) {
const nowMs = pruefeNow(now, 'verarbeite')
if (pauseBisMs > nowMs) return { faellig: 0, gesendet: 0 }
const faellig = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1))
let gesendet = 0
for (const eintrag of faellig) {
if (gebremst(nowMs)) break
try {
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1
} catch (err) {
console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`)
}
}
return { faellig: faellig.length, gesendet }
}
/** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */
export async function warteAufVersand() {
while (laufend.size > 0) await Promise.allSettled([...laufend])
}
/**
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler). Das Portal zeigt dort
* „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler).
* @param {string[]} bookingIds
* @returns {Promise<Set<string>>}
*/
export async function zustellFehler(bookingIds) {
const ids = [...new Set((bookingIds ?? []).filter((id) => typeof id === 'string' && id !== ''))]
const betroffen = new Set()
for (let ab = 0; ab < ids.length; ab += ID_BLOCK) {
const eintraege = await listAllDocuments(col(), [Query.equal('bookingId', ids.slice(ab, ab + ID_BLOCK)), Query.equal('status', 'fehler')])
for (const eintrag of eintraege) betroffen.add(eintrag.bookingId)
}
return betroffen
}
/**
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand und fehlgeschlagene 48 Stunden nach dem Fehler
* (beides steht in naechsterVersuch). Wartende Eintraege bleiben.
* @param {Date} now
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
export async function raeumeAuf(now) {
const nowMs = pruefeNow(now, 'raeumeAuf')
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
let geloescht = 0
for (const eintrag of alt) {
try {
await deleteDocument(col(), eintrag.$id)
geloescht += 1
} catch (err) {
if (err.status !== 404) throw err // 404: ein paralleler Lauf war schneller
}
}
return geloescht
}
/**
* Gastdaten loeschen (Portal "Gastdaten löschen"): noch nicht versandte Mails einer Buchung (Status wartet) werden
* geloescht, denn sie halten Adresse und Inhalt. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem Lesen:
* ein Eintrag, der inzwischen gesendet wurde (seine Felder sind dann schon leer), bleibt; ein inzwischen geloeschter
* stoert nicht. Gesendete und fehlgeschlagene Eintraege behandelt raeumeAuf.
* @param {string} bookingId
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
export async function verwerfeWartende(bookingId) {
pruefeText(bookingId, 'bookingId')
const wartend = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.equal('bookingId', bookingId)])
let geloescht = 0
for (const eintrag of wartend) {
const weg = await withLocks([`termine-mail:${eintrag.$id}`], async () => {
let frisch
try {
frisch = await getDocument(col(), eintrag.$id)
} catch (err) {
if (err.status === 404) return false
throw err
}
if (frisch.status !== 'wartet') return false
try {
await deleteDocument(col(), frisch.$id)
} catch (err) {
if (err.status === 404) return false
throw err
}
unverbucht.delete(frisch.$id)
return true
})
if (weg) geloescht += 1
}
return geloescht
}
/**
* Taeglicher Job, vor raeumeAuf: Eintraege, die laenger als 48 Stunden (ab createdAt) in `wartet` stehen, werden zu
* `fehler` (das Portal zeigt dann den Zustellhinweis) und bekommen als Loeschtermin jetzt + 48 Stunden, damit raeumeAuf
* sie erst dann loescht und nicht noch im selben Lauf. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem
* Lesen: ein Eintrag, der inzwischen gesendet wurde (oder dessen Versand gerade laeuft), bleibt, wie er ist.
* @param {Date} now
* @returns {Promise<number>} Anzahl beendeter Eintraege
*/
export async function beendeFestgefahrene(now) {
const nowMs = pruefeNow(now, 'beendeFestgefahrene')
const grenze = nowMs - WARTEN_MAX_MS
const alt = (await listAllDocuments(col(), [Query.equal('status', 'wartet')])).filter((eintrag) => zeitMs(eintrag.createdAt) < grenze)
let beendet = 0
for (const eintrag of alt) {
const geaendert = await withLocks([`termine-mail:${eintrag.$id}`], async () => {
let frisch
try {
frisch = await getDocument(col(), eintrag.$id)
} catch (err) {
if (err.status === 404) return false
throw err
}
if (frisch.status !== 'wartet' || !(zeitMs(frisch.createdAt) < grenze) || unverbucht.has(frisch.$id)) return false
await updateDocument(col(), frisch.$id, {
status: 'fehler',
naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS),
fehler: 'Nicht zugestellt: länger als 48 Stunden in der Warteschlange.',
})
return true
})
if (geaendert) beendet += 1
}
return beendet
}
/** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */
export function pausiertBis() {
return pauseBisMs ? new Date(pauseBisMs) : null
}
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
export function resetOutboxForTests() {
pauseBisMs = 0
sendeZeiten = []
unverbucht.clear()
}
+169
View File
@@ -0,0 +1,169 @@
/**
* ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
*
* Rechtstext-Bausteine des Terminplaners fuer Gaeste (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 11).
* Reine Funktionen und Konstanten, ohne Appwrite und ohne config. Die Texte sind Rechtstexte und stehen deshalb immer
* in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem Formular folgt der Anrede des Betriebs.
*
* widerrufsbelehrung: Muster der Anlage 1 und Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB
* (docs/rechtliches/quellen/, abgerufen 2026-10-02), Gestaltungshinweise fuer einen Dienstleistungsvertrag angewendet:
* Fristbeginn „des Vertragsabschlusses.“, Betriebsdaten (Name, Anschrift, Telefon, E-Mail, alle Pflicht), Hinweis auf die
* Widerrufsfunktion als „anderer geeigneter Hinweis“ (ohne Internetadresse) im woertlichen Satzrahmen, Wertersatz fuer Dienstleistungen. Alles Uebrige ist der Gesetzestext, unveraendert.
* Ausgelassen: Textbausteine zu Waren (Besitz, Rueckzahlungsverweigerung, Rueckgabe, Rueckkosten, Wertverlust) und der
* Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft
* den Wortlaut gegen die Quelldateien.
*
* Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen.
*/
export const RECHTSTEXTE_VERSION = '2026-10-02'
const ZEILENUMBRUCH = '\n'
const ABSATZ = '\n\n'
/** Einzeiliger Text: jeder Leerraum (auch Umbrueche) wird zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
const einzeilig = (wert) => (typeof wert === 'string' ? wert.replace(/\s+/g, ' ').trim() : '')
/**
* Betriebsdaten fuer die Texte. Name und E-Mail sind immer Pflicht (die Einstellung prueft sie). Mit vollstaendig: true
* auch Anschrift und Telefonnummer (das Muster der Anlage 1 verlangt alle vier): fehlt eine Angabe oder ist sie leer,
* entsteht ein TypeError, nie ein unvollstaendiger Text.
*/
function betriebsdaten(betrieb, { vollstaendig = false } = {}) {
const name = einzeilig(betrieb?.name)
const email = einzeilig(betrieb?.email)
if (!name || !email) throw new TypeError('Betriebsname und E-Mail-Adresse sind für die Rechtstexte erforderlich.')
const adresse = einzeilig(betrieb.adresse)
const telefon = einzeilig(betrieb.telefon)
if (vollstaendig && (!adresse || !telefon)) {
throw new TypeError('Anschrift und Telefonnummer des Betriebs sind für die Widerrufsbelehrung erforderlich.')
}
return { name, email, adresse, telefon }
}
const verbinde = (teile) => teile.filter(Boolean).join(', ')
// ---------------------------------------------------------------- Widerrufsbelehrung (Anlage 1) und Muster-Widerrufsformular (Anlage 2)
const FRIST = 'des Vertragsabschlusses.' // Gestaltungshinweis a): Dienstleistungsvertrag
// Widerrufsfunktion: Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben.“); in die
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt ein geeigneter Hinweis statt einer Internetadresse.
const WIDERRUFSFUNKTION =
'Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website '
+ 'sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. '
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
// Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen.
const WERTERSATZ =
'Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, '
+ 'der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, '
+ 'bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.'
/** Kontaktangaben fuer „Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (…)“: Name, Anschrift, Telefonnummer, E-Mail-Adresse. */
function kontakt({ name, adresse, telefon, email }) {
return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`])
}
function belehrungsAbsaetze(daten) {
return [
'Widerrufsbelehrung',
'Widerrufsrecht',
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
`Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag ${FRIST}`,
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt(daten)}) mittels einer eindeutigen Erklärung `
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
WIDERRUFSFUNKTION,
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
'Folgen des Widerrufs',
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
+ '(mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, '
+ 'günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, '
+ 'an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. '
+ 'Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, '
+ 'es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
WERTERSATZ,
]
}
function formularAbsaetze({ name, adresse, email }) {
const zeilen = [
`An ${verbinde([name, adresse, email])}:`,
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
'Bestellt am (*)/erhalten am (*)',
'Name des/der Verbraucher(s)',
'Anschrift des/der Verbraucher(s)',
'Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)',
'Datum',
]
return [
'Muster-Widerrufsformular',
'(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
zeilen.map((zeile) => `– ${zeile}`).join(ZEILENUMBRUCH),
'(*) Unzutreffendes streichen.',
]
}
/**
* Widerrufsbelehrung und Muster-Widerrufsformular fuer einen Dienstleistungs-Termin, Sie-Form, als Text mit Zeilenumbruechen.
* betrieb: { name, email, adresse, telefon } aus der Einstellung, alle vier Pflicht. Fehlt eine Angabe oder ist sie leer: TypeError
* (eine unvollstaendige gesetzliche Belehrung wird nie stillschweigend erzeugt). Die Einstellungspruefung soll Anschrift und
* Telefon verlangen, sobald Gaeste ein Widerrufsrecht haben koennen (spaeterer Task); bis dahin faengt diese Funktion es ab.
*/
export function widerrufsbelehrung(betrieb) {
const daten = betriebsdaten(betrieb, { vollstaendig: true })
return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ)
}
// ---------------------------------------------------------------- Hinweis unter dem Buchungsformular (recherche-recht.md 11.2)
const LOESCHFRIST_ABGESAGT_TAGE = 30
const LOESCHFRIST_UNBESTAETIGT_STUNDEN = 48
const AUFBEWAHRUNG_VORGABE_TAGE = 90
/**
* Kurzer Datenschutz-Hinweis (Art. 13 DSGVO gestuft) vor dem Absenden; Sie oder Du nach einstellung.anrede.
* Die Loeschfristen entsprechen dem Loeschlauf: nicht bestaetigt 48 Stunden nach Ablauf der Bestaetigungsfrist, abgesagt
* 30 Tage, vergangene Termine einstellung.aufbewahrungTage nach dem Termin. Der Link auf die Datenschutzerklaerung des
* Betriebs (betrieb.datenschutzUrl) steht nicht im Text; das Widget setzt ihn daneben. Die Betriebs-E-Mail steht nicht im Text.
*/
export function formularHinweis(einstellung) {
const daten = betriebsdaten(einstellung?.betrieb)
const du = einstellung.anrede === 'du'
const tage = Number.isInteger(einstellung.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
const verantwortlich = verbinde([daten.name, daten.adresse])
const frist = `bis ${tage} Tage nach dem Termin, abgesagte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage, `
+ `nicht bestätigte Buchungen ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf der Bestätigungsfrist.`
const betrieben = 'Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland).'
const cookies = 'Es werden keine Cookies gesetzt.'
return du
? `Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). ${betrieben} `
+ `Wir speichern deine Daten ${frist} ${cookies} Bitte mach keine Angaben zu deiner Gesundheit. `
+ `Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
: `Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). ${betrieben} `
+ `Wir speichern Ihre Daten ${frist} ${cookies} Bitte machen Sie keine Angaben zu Ihrer Gesundheit. `
+ `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
}
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
/**
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, § 357a Abs. 2 BGB): nur bei Leistung mit Preis und
* Verbrauchern, bei sofort buchbaren Leistungen mit Terminbeginn vor Ablauf der Widerrufsfrist, bei Anfragen immer
* (brauchtZustimmungVorzeitig). Sie-Form auch bei Du-Betrieben.
*/
export const ZUSTIMMUNG_VORZEITIG = (name) =>
`Ich verlange ausdrücklich, dass ${name} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. `
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen 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).'
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */
export const ZUSTIMMUNG_STORNO = 'Ich habe die Stornobedingungen gelesen.'
/** Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher. */
export const ZUSTIMMUNG_ERINNERUNG = (stunden) => `Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.`
/** Hinweis ueber dem Nachrichtenfeld (Gesundheitsbereich ist ausgeschlossen, Spezifikation Abschnitt 4). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
+30
View File
@@ -0,0 +1,30 @@
import { config } from '../../config.js'
/**
* Terminplaner-Schalter (Funktionsschluessel 'termine'): TERMINE_ENABLED = off (Vorgabe) | pilot | on.
* Liest config.termine bei jedem Aufruf. Bei off laufen keine Termine-Jobs und keine Termine-Abfragen.
*/
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
export function termineModus() {
const mode = config.termine.mode
return mode === 'pilot' || mode === 'on' ? mode : 'off'
}
export function termineAus() {
return termineModus() === 'off'
}
/**
* Darf dieser Kunde den Terminplaner nutzen? pilot = nur Adressen aus TERMINE_PILOT_EMAILS ('*' = alle; eine leere
* Liste laesst niemanden durch); on = alle Kunden (TP1: pilot ohne E-Mail-Filter); off = niemand.
*/
export function termineFuerKunde(email) {
const modus = termineModus()
if (modus === 'off') return false
if (modus === 'on') return true
const erlaubt = config.termine.pilotEmails
if (erlaubt.includes('*')) return true
const adresse = String(email || '').trim().toLowerCase()
return adresse !== '' && erlaubt.includes(adresse)
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Terminplaner: Appwrite-Schema der vier Collections (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2).
* 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/termine-setup.mjs legt alles an.
*
* 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 bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
export const TERMINE_COLLECTIONS = {
// Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Das Feld heisst einstellung,
// nicht data: appwriteAdmin.formatRequestBody haelt Bodies mit data fuer fertig verpackt.
termineConfig: {
attributes: [
str('customerId', 64),
str('projectId', 64),
str('einstellung', 200000),
int('version'),
dt('updatedAt'),
str('updatedBy', 100),
],
indexes: [],
},
// Eine Buchung je Dokument (ID randomUUID)
termineBookings: {
attributes: [
str('featureId', 64),
str('customerId', 64),
str('projectId', 64),
str('leistungId', 64),
str('leistungName', 120),
str('ressourceId', 64),
str('ressourceName', 80),
dt('startsAt'),
dt('endsAt'),
dt('blockEndsAt'),
str('status', 16),
str('quelle', 16),
str('gastName', 100),
str('gastEmail', 254),
str('gastTelefon', 40),
str('gastNachricht', 500),
str('gastZusatz', 2000),
bool('erinnerung'),
dt('erinnerungGesendetAt'),
dt('heldUntil'),
str('verifyHash', 64),
str('manageHash', 64),
dt('bestaetigtAt'),
dt('angenommenAt'),
dt('abgesagtAt'),
str('abgesagtVon', 16),
str('absageGrund', 300),
dt('widerrufenAt'),
str('nachweis', 4000),
int('preisCent'),
str('preisArt', 8),
str('buchungsart', 8),
bool('verbraucher'),
str('zahlart', 8),
str('zahlStatus', 16),
str('stripeSessionId', 128),
str('stripePaymentIntent', 64),
int('icsSequenz'),
dt('createdAt'),
dt('updatedAt'),
],
indexes: [
index('idx_feature_start', 'featureId', 'startsAt'),
index('idx_status_held', 'status', 'heldUntil'),
index('idx_feature_email', 'featureId', 'gastEmail'),
index('idx_verify', 'verifyHash'),
index('idx_manage', 'manageHash'),
index('idx_status_start', 'status', 'startsAt'),
],
},
// Belegungszellen gegen Doppelbuchung; Dokument-ID c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)
termineClaims: {
attributes: [str('featureId', 64), str('ressourceId', 64), str('bookingId', 64), dt('zelle'), dt('bis')],
indexes: [index('idx_booking', 'bookingId'), index('idx_bis', 'bis')],
},
// Mail-Warteschlange (einziger Sendepfad); enthaelt personenbezogene Daten, daher kurze Aufbewahrung
termineOutbox: {
attributes: [
str('featureId', 64),
str('bookingId', 64),
str('art', 16),
str('an', 254),
str('fromName', 120),
str('replyTo', 254),
str('subject', 300),
str('html', 100000),
str('text', 50000),
str('ics', 20000),
str('icsMethod', 8),
str('status', 16),
int('versuche'),
dt('naechsterVersuch'),
dt('gesendetAt'),
str('fehler', 300),
dt('createdAt'),
],
indexes: [index('idx_status_next', 'status', 'naechsterVersuch')],
},
}
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
export const TERMINE_DATETIMES = Object.fromEntries(
Object.entries(TERMINE_COLLECTIONS).map(([name, { attributes }]) => [
name,
attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key),
]),
)
+252
View File
@@ -0,0 +1,252 @@
import { istFeiertag } from './feiertage.js'
import { pruefeNow, zeitMs } from './util.js'
import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js'
/**
* Terminplaner-Verfuegbarkeit (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 3): reine Funktionen ohne
* Appwrite und ohne config; `now` kommt immer als Parameter. Oeffnungsfenster sind Berliner Wanduhr, Belegungen
* sind UTC-Zeitpunkte und werden nur als Zeitpunkte verglichen (nie je Kalendertag), damit eine Belegung vom Vortag
* (22:00 bis 06:00) und die Zeitumstellung stimmen.
*
* Fenster eines Tages: Oeffnungsfenster aus dem Wochenplan der Ressource oder aus der Sonderoeffnung des Datums
* (gilt fuer alle Ressourcen, ersetzt Plan und Feiertagssperre; leere Liste = geschlossen), minus Feiertag (wenn
* feiertageGeschlossen und keine Sonderoeffnung), minus Abwesenheiten: eine Abwesenheit der Ressource oder des
* Betriebs (ressourceId null) nimmt den Tag der betroffenen Ressourcen immer heraus, auch an einem Sonderoeffnungstag.
*/
const MIN = 60000
const RASTER_VORGABE = 15
const FEIERTAG_CACHE_MAX = 5000
/** Status, die eine Buchung nicht (mehr) belegen und nicht als Last des Tages zaehlen. */
const NICHT_BELEGT = new Set(['abgesagt', 'abgelehnt', 'verfallen'])
const pad = (n) => String(n).padStart(2, '0')
const hhmm = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`
/** 'HH:MM' -> Minuten seit Mitternacht ('24:00' = 1440), sonst null. */
function minuten(wert) {
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 || (h === 24 && min !== 0)) return null
return h * 60 + min
}
// istFeiertag kostet ~1,4 ms: das Ergebnis ist je Tag und Land fest, also einmal rechnen und merken.
const feiertagMerker = new Map()
function feiertag(tag, land) {
const schluessel = `${tag}|${land}`
let wert = feiertagMerker.get(schluessel)
if (wert === undefined) {
wert = istFeiertag(tag, land)
if (feiertagMerker.size >= FEIERTAG_CACHE_MAX) feiertagMerker.clear()
feiertagMerker.set(schluessel, wert)
}
return wert
}
// ---------------------------------------------------------------- Belegung
/** Belegt die Buchung zum Zeitpunkt jetztMs? gehalten nur solange heldUntil > jetzt, angefragt und bestaetigt immer. */
export function istBelegend(buchung, jetztMs) {
if (buchung.status === 'angefragt' || buchung.status === 'bestaetigt') return true
if (buchung.status === 'gehalten') return zeitMs(buchung.heldUntil) > jetztMs // abgelaufen oder ohne Haltezeit = frei
return false
}
/**
* Belegte Zeiten aus Buchungs-Dokumenten (Appwrite-Form: $id, ressourceId, status, startsAt, blockEndsAt, heldUntil):
* belegend sind gehalten (nur solange heldUntil > now), angefragt und bestaetigt (auch Sperren). ende = blockEndsAt.
* ohneBuchung ($id) nimmt eine Buchung heraus, z. B. beim Verschieben. Kaputte Eintraege werden uebersprungen.
* => [{ ressourceId, start: Date, ende: Date }]
*/
export function belegungAus(buchungen, now, { ohneBuchung = null } = {}) {
pruefeNow(now, 'belegungAus')
const jetztMs = now.getTime()
const aus = []
for (const b of Array.isArray(buchungen) ? buchungen : []) {
if (!b || typeof b !== 'object' || typeof b.ressourceId !== 'string') continue
if (ohneBuchung !== null && b.$id === ohneBuchung) continue
if (!istBelegend(b, jetztMs)) continue
const start = zeitMs(b.startsAt)
const ende = zeitMs(b.blockEndsAt)
if (!Number.isFinite(start) || !Number.isFinite(ende) || ende <= start) continue
aus.push({ ressourceId: b.ressourceId, start: new Date(start), ende: new Date(ende) })
}
return aus
}
/** Ende des belegten Blocks: Beginn + Dauer + Pause danach, in echten Minuten (auch ueber die Zeitumstellung). */
export function blockEnde(start, leistung) {
return new Date(start.getTime() + ((leistung.dauer || 0) + (leistung.pufferNach || 0)) * MIN)
}
// ---------------------------------------------------------------- Startzeiten eines Fensters
/** Naechste existierende Wanduhrzeit ab minute (Luecke der Maerz-Umstellung ueberspringen) als ms, sonst null. */
function naechsterInstant(tag, minute) {
for (let m = minute; m <= 1440 && m <= minute + 60; m += 5) {
const d = zuUtc(tag, hhmm(m))
if (d) return d.getTime()
}
return null
}
/**
* Startzeiten eines Fensters [von, bis] (Minuten der Berliner Wanduhr) im Raster ab Fensterbeginn, deren Block
* (blockMs) ganz im Fenster liegt. => { endeMs, starts: [{ uhrzeit, ms }] } oder null (unbrauchbares Fenster).
* Wanduhrzeiten ohne Zeitpunkt (Maerz-Luecke) entfallen, doppelte (Oktober) sind das erste Vorkommen.
*/
function fensterStarts(tag, von, bis, raster, blockMs) {
const beginn = zuUtc(tag, hhmm(von))
const schluss = zuUtc(tag, hhmm(bis))
const starts = []
if (beginn && schluss && schluss.getTime() - beginn.getTime() === (bis - von) * MIN) {
// Kein Uebergang im Fenster: Wanduhr und echte Zeit laufen gleich, die Zeitpunkte ergeben sich durch Addieren.
const endeMs = schluss.getTime()
for (let m = von, ms = beginn.getTime(); m < bis && ms + blockMs <= endeMs; m += raster, ms += raster * MIN) starts.push({ uhrzeit: hhmm(m), ms })
return { endeMs, starts }
}
// Umstelltag: jede Startzeit einzeln umrechnen. Die Zeitpunkte wachsen mit der Wanduhr, sobald einer nicht mehr passt, passt keiner mehr.
const endeMs = schluss ? schluss.getTime() : naechsterInstant(tag, bis)
if (endeMs === null) return null
for (let m = von; m < bis; m += raster) {
const d = zuUtc(tag, hhmm(m))
if (!d) continue
if (d.getTime() + blockMs > endeMs) break
starts.push({ uhrzeit: hhmm(m), ms: d.getTime() })
}
return { endeMs, starts }
}
// ---------------------------------------------------------------- Kontext und Tagesberechnung
/** Gemeinsame Vorbereitung fuer freieZeiten und freieTage; null, wenn nichts buchbar sein kann. */
function kontext({ einstellung, leistungId, ressourceId = 'egal', belegung, now, ohneRegeln = false }, name) {
pruefeNow(now, name)
const leistung = (einstellung?.leistungen || []).find((l) => l?.id === leistungId)
if (!leistung || !leistung.aktiv || !(leistung.dauer > 0)) return null
const zugeordnet = new Set(leistung.ressourcen || [])
let ressourcen = (einstellung.ressourcen || []).filter((r) => r?.aktiv && zugeordnet.has(r.id))
if (ressourceId !== 'egal') ressourcen = ressourcen.filter((r) => r.id === ressourceId)
if (ressourcen.length === 0) return null
const belegtJeRessource = new Map()
for (const b of Array.isArray(belegung) ? belegung : []) {
const start = zeitMs(b?.start)
const ende = zeitMs(b?.ende)
if (typeof b?.ressourceId !== 'string' || !(ende > start)) continue
if (!belegtJeRessource.has(b.ressourceId)) belegtJeRessource.set(b.ressourceId, [])
belegtJeRessource.get(b.ressourceId).push([start, ende])
}
const regeln = einstellung.regeln || {}
const jetzt = now.getTime()
const heuteTag = heute(now)
return {
einstellung,
ressourcen,
raster: Number.isInteger(regeln.raster) && regeln.raster > 0 ? regeln.raster : RASTER_VORGABE,
blockMs: (leistung.dauer + (leistung.pufferNach || 0)) * MIN,
belegt: belegtJeRessource,
heute: heuteTag,
fruehestensMs: ohneRegeln ? jetzt : jetzt + (regeln.vorlaufMin || 0) * MIN, // nie in der Vergangenheit
letzterTag: ohneRegeln || !Number.isInteger(regeln.horizontTage) ? null : plusTage(heuteTag, regeln.horizontTage),
}
}
const abwesend = (abwesenheiten, ressourceId, tag) => (abwesenheiten || []).some(
(a) => a && (a.ressourceId === null || a.ressourceId === undefined || a.ressourceId === ressourceId) && a.von <= tag && tag <= a.bis,
)
/**
* Freie Startzeiten eines (gueltigen) Berliner Kalendertags, aufsteigend; nurPruefen bricht nach der ersten ab.
* => [{ uhrzeit, start: Date, ressourceIds }]
*/
function tagZeiten(ctx, tag, nurPruefen) {
if (tag < ctx.heute) return []
if (ctx.letzterTag !== null && tag > ctx.letzterTag) return []
const e = ctx.einstellung
const sonder = Object.hasOwn(e.sonderoeffnungen || {}, tag) && Array.isArray(e.sonderoeffnungen[tag]) ? e.sonderoeffnungen[tag] : null
const feiertagZu = sonder === null && e.feiertageGeschlossen !== false && feiertag(tag, e.bundesland)
const wt = wochentag(tag)
const treffer = new Map() // ms -> Eintrag
for (const r of ctx.ressourcen) {
let fenster = []
if (!abwesend(e.abwesenheiten, r.id, tag)) fenster = sonder ?? (feiertagZu ? [] : (r.plan?.[wt] || []))
const belegtR = ctx.belegt.get(r.id) || []
for (const f of Array.isArray(fenster) ? fenster : []) {
const von = minuten(f?.[0])
const bis = minuten(f?.[1])
if (von === null || bis === null || bis <= von) continue
const fs = fensterStarts(tag, von, bis, ctx.raster, ctx.blockMs)
if (!fs || fs.starts.length === 0) continue
const imFenster = belegtR.filter(([s, en]) => s < fs.endeMs && en > fs.starts[0].ms)
for (const { uhrzeit, ms } of fs.starts) {
if (ms < ctx.fruehestensMs) continue
const ende = ms + ctx.blockMs
if (imFenster.some(([s, en]) => s < ende && en > ms)) continue
const vorhanden = treffer.get(ms)
if (vorhanden) vorhanden.ressourceIds.push(r.id)
else treffer.set(ms, { uhrzeit, start: new Date(ms), ressourceIds: [r.id] })
if (nurPruefen) return [...treffer.values()]
}
}
}
return [...treffer.values()].sort((a, b) => a.start - b.start)
}
// ---------------------------------------------------------------- Oeffentliche Abfragen
/**
* Freie Startzeiten einer Leistung an einem Berliner Kalendertag. ressourceId: eine Ressource oder 'egal'
* (Vereinigung ueber alle aktiven Ressourcen der Leistung, ressourceIds in der Reihenfolge der Einstellung).
* belegung = belegungAus(...). ohneRegeln hebt Vorlauf und Horizont auf (nie die Vergangenheit), z. B. fuer manuelle Termine.
* Ungueltiger Tag, unbekannte oder inaktive Leistung, nicht zugeordnete oder inaktive Ressource => [].
* => [{ uhrzeit: 'HH:MM', start: Date, ressourceIds }]
*/
export function freieZeiten(parameter) {
const ctx = kontext(parameter, 'freieZeiten')
const tag = parameter.tag
if (!ctx || typeof tag !== 'string' || zuUtc(tag, '12:00') === null) return []
return tagZeiten(ctx, tag, false)
}
/** Tage ('YYYY-MM-DD') des Monats ('YYYY-MM') mit mindestens einer freien Startzeit; sonst wie freieZeiten. */
export function freieTage(parameter) {
const info = monatInfo(parameter.monat)
const ctx = kontext(parameter, 'freieTage')
if (!info || !ctx) return []
const tage = []
for (let i = 1; i <= info.anzahlTage; i += 1) {
const tag = `${parameter.monat}-${pad(i)}`
if (tagZeiten(ctx, tag, true).length > 0) tage.push(tag)
}
return tage
}
/**
* Wahl unter mehreren freien Ressourcen ('egal'): die mit den wenigsten Buchungen an dem Tag; bei Gleichstand die
* in der Einstellung zuerst genannte. Abgesagte, abgelehnte und verfallene Buchungen sowie Sperren zaehlen nicht.
* Mit now (optional) zaehlt eine Haltung nur, solange heldUntil > now. Keine Kandidaten => null.
*/
export function waehleRessource(ids, buchungenAmTag, einstellung, now = null) {
if (now !== null) pruefeNow(now, 'waehleRessource')
const jetztMs = now === null ? null : now.getTime()
const kandidaten = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string'))]
if (kandidaten.length === 0) return null
const anzahl = new Map(kandidaten.map((id) => [id, 0]))
for (const b of Array.isArray(buchungenAmTag) ? buchungenAmTag : []) {
if (!b || b.quelle === 'sperre' || NICHT_BELEGT.has(b.status) || !anzahl.has(b.ressourceId)) continue
if (jetztMs !== null && b.status === 'gehalten' && !istBelegend(b, jetztMs)) continue
anzahl.set(b.ressourceId, anzahl.get(b.ressourceId) + 1)
}
const rang = new Map((einstellung?.ressourcen || []).map((r, i) => [r?.id, i]))
const rangVon = (id) => (rang.has(id) ? rang.get(id) : Infinity) // unbekannte zuletzt, dort gilt die Reihenfolge von ids
return kandidaten.reduce((beste, id) => {
if (anzahl.get(id) !== anzahl.get(beste)) return anzahl.get(id) < anzahl.get(beste) ? id : beste
return rangVon(id) < rangVon(beste) ? id : beste
})
}
+88
View File
@@ -0,0 +1,88 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
import { pruefeNow, pruefeText } from './util.js'
/**
* Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8).
*
* - Bestaetigungstoken: 32 Zufallsbytes (base64url), gespeichert wird nur der SHA-256-Hex (verifyHash).
* - Verwaltungstoken: base64url(HMAC-SHA256(Schluessel, bookingId)); jederzeit neu berechenbar, damit spaetere Mails
* (Erinnerung) denselben Link tragen, gespeichert wird nur sein Hash (manageHash). Wie lange er gilt (Terminende
* + 1 Tag), entscheidet der Aufrufer anhand der Buchung.
* - Buchungsnummer: 8 Zeichen A-Z0-9, nur aus der Buchungs-ID berechnet (kein eigenes Feld); steht in der Bestaetigung
* und gehoert beim Widerruf ohne Link zusammen mit der E-Mail-Adresse.
* - Formular-Token: "<Ausstellzeit base36>.<HMAC>" je siteKey, beweist nur Alter und Herkunft (kein Geheimnis fuer
* den Besucher). Zu schnell abgeschickt (config.termine.formMinSekunden) = Bot, aelter als 2 Stunden = neu laden.
*
* Die HMAC-Schluessel werden je Zweck aus config.sessionSecret abgeleitet (Beschriftung 'termine-manage' bzw.
* 'termine-form'), damit ein Token nie fuer einen anderen Zweck gilt. config wird bei jedem Aufruf gelesen.
*/
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
const FORM_TOKEN_MAX_LAENGE = 100
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
const ZEIT_RE = /^[0-9a-z]{1,13}$/
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
/** Schluessel fuer genau einen Zweck; ohne (ausreichend langes) Sitzungsgeheimnis gibt es keine Tokens. */
function schluessel(zweck) {
const geheimnis = config.sessionSecret
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
throw new Error('Terminplaner-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
}
return createHmac('sha256', geheimnis).update(zweck).digest()
}
/** Neues Bestaetigungstoken: token geht in den Link, hash (SHA-256-Hex) in die Buchung. */
export function neuesToken() {
const token = randomBytes(32).toString('base64url')
return { token, hash: hashToken(token) }
}
/** SHA-256-Hex eines Tokens. Fremde Eingaben (kein Text) ergeben den Hash des leeren Textes und treffen nichts. */
export function hashToken(token) {
return sha256Hex(typeof token === 'string' ? token : '')
}
/** Verwaltungstoken einer Buchung (stabil; bei geaendertem Sitzungsgeheimnis ein anderer Wert). */
export function verwaltungsToken(bookingId) {
pruefeText(bookingId, 'bookingId')
return createHmac('sha256', schluessel('termine-manage')).update(bookingId).digest('base64url')
}
/** Buchungsnummer aus der Buchungs-ID: 8 Zeichen A-Z0-9 (die ersten 64 Bit des SHA-256 zur Basis 36). */
export function buchungsNummer(bookingId) {
pruefeText(bookingId, 'bookingId')
const zahl = createHash('sha256').update(`termine-nummer|${bookingId}`).digest().readBigUInt64BE(0)
return (zahl % 36n ** 8n).toString(36).toUpperCase().padStart(8, '0')
}
function formMac(siteKey, zeit) {
return createHmac('sha256', schluessel('termine-form')).update(`${siteKey}|${zeit}`).digest('base64url')
}
/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */
export function formToken(siteKey, now = new Date()) {
pruefeText(siteKey, 'siteKey')
const zeit = pruefeNow(now, 'formToken').toString(36)
return `${zeit}.${formMac(siteKey, zeit)}`
}
/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */
export function pruefeFormToken(siteKey, token, now = new Date()) {
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
const teile = token.split('.')
if (teile.length !== 2) return 'ungueltig'
const [zeit, mac] = teile
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
const erwartet = Buffer.from(formMac(siteKey, zeit))
const gegeben = Buffer.from(mac)
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
const alter = pruefeNow(now, 'pruefeFormToken') - parseInt(zeit, 36)
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
if (alter < config.termine.formMinSekunden * 1000) return 'zu_schnell'
return 'ok'
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Kleine Hilfen, die mehrere Terminplaner-Module teilen (Zeitpunkte lesen, Pflichtparameter pruefen).
* Reine Funktionen ohne Appwrite und ohne config.
*/
/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */
export function zeitMs(wert) {
if (wert instanceof Date) return wert.getTime()
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
if (typeof wert === 'number') return wert
return Number.NaN
}
/** `now` ist immer ein gueltiges Date (feste Uhr per Parameter, nie die Wanduhr), sonst TypeError. => Millisekunden. */
export function pruefeNow(now, name) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
return now.getTime()
}
/** Pflichttext: nichtleerer String, sonst TypeError. */
export function pruefeText(wert, name) {
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
}
+221
View File
@@ -0,0 +1,221 @@
import { neueId, normalisiereEinstellung } from './einstellungen.js'
/**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
* Eine Vorlage liefert Leistungen, Wochenplan, Regeln und Stornotext als Vorschlag; Betriebsangaben kommen aus dem
* Kundenkonto (ausVorlage). Alle Preise sind Platzhalter zum Anpassen. Die Fristen im Stornotext stammen aus den
* Regeln (absageBisStd), damit Text und Selbstbedienung der Gaeste uebereinstimmen. TP2 nutzt dieselben Daten fuer den Fragebogen.
*/
const ZAHLARTEN = ['bar', 'ec', 'kreditkarte']
const STORNO_PROZENT = 50
/** Stornobedingungen Variante 1 (Zahlung vor Ort), recherche-recht-zahlung.md 7.3 F, mit X = Frist und Y = Prozent. */
const stornoText = (stunden, prozent) =>
`Sie können Ihren Termin bis ${stunden} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ `Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin ${prozent} % des Preises. `
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
/** Wochenplan aus { '1-5': [[von, bis], …], '6': … } (ISO-Wochentage, Pausen = Luecken). */
function wochenplan(spezifikation) {
const plan = {}
for (const [bereich, fenster] of Object.entries(spezifikation)) {
const [von, bis = von] = bereich.split('-').map(Number)
for (let tag = von; tag <= bis; tag += 1) plan[tag] = fenster
}
return plan
}
const person = (name, plan) => ({ name, typ: 'person', plan })
const platz = (name, plan) => ({ name, typ: 'platz', plan })
const leistung = (name, dauer, pufferNach, preisArt, preisCent, beschreibung = '') => ({ name, beschreibung, dauer, pufferNach, preisArt, preisCent })
const MITTAG = [['09:00', '12:00'], ['13:00', '17:00']]
const BUERO = wochenplan({ '1-5': MITTAG })
/**
* Eine Vorlage. ressourcen: Wochenplan je Person/Platz; leistungen gelten fuer alle Ressourcen der Vorlage;
* buchungsart gilt fuer alle Leistungen; gastFelder/recht/regeln ueberschreiben nur die Abweichung von den Vorgaben.
*/
function vorlage(key, name, beschreibung, daten) {
const regeln = { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, ...daten.regeln }
return {
key,
name,
beschreibung,
ressourcen: daten.ressourcen,
leistungen: daten.leistungen,
buchungsart: daten.buchungsart || 'sofort',
regeln,
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [], ...daten.gastFelder },
recht: { gaesteSind: 'verbraucher', freizeit: false, ...daten.recht },
storno: stornoText(regeln.absageBisStd, STORNO_PROZENT),
}
}
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
Object.values(wert).forEach(einfrieren)
}
return wert
}
export const VORLAGEN = einfrieren({
salon: vorlage('salon', 'Friseur und Salon', 'Haarschnitt, Färben und Bartpflege mit Terminen je Person; Preise als „ab“-Preise zum Anpassen.', {
ressourcen: [person('Stylist/in', wochenplan({ '2-5': [['09:00', '12:30'], ['13:30', '18:00']], '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Herrenschnitt', 30, 5, 'fest', 2800),
leistung('Damenschnitt', 45, 5, 'ab', 4500),
leistung('Kinderschnitt', 30, 5, 'fest', 1800),
leistung('Färben', 120, 10, 'ab', 7500),
],
}),
kosmetik: vorlage('kosmetik', 'Kosmetik und Nagelstudio', 'Gesichtsbehandlung, Maniküre und Pediküre mit Pause zum Aufräumen.', {
ressourcen: [person('Kosmetiker/in', wochenplan({ '1-5': MITTAG }))],
leistungen: [
leistung('Gesichtsbehandlung', 60, 10, 'ab', 6500),
leistung('Maniküre', 45, 10, 'fest', 3500),
leistung('Pediküre', 60, 10, 'fest', 4500),
leistung('Augenbrauen zupfen und färben', 20, 5, 'fest', 1800),
],
}),
beratung: vorlage('beratung', 'Beratung und Coaching', 'Kennenlerngespräch kostenlos, Beratungstermine mit Preis; für Berater, Coaches und Kanzleien.', {
ressourcen: [person('Berater/in', BUERO)],
leistungen: [
leistung('Kennenlerngespräch', 30, 5, 'keiner', 0, 'Kostenlos und unverbindlich.'),
leistung('Beratung', 60, 10, 'fest', 9000),
leistung('Folgetermin', 30, 5, 'fest', 4500),
],
gastFelder: { nachricht: 'optional' },
recht: { gaesteSind: 'gemischt' },
}),
werkstatt: vorlage('werkstatt', 'Werkstatt und Reifenservice', 'Zwei Arbeitsplätze, Termine nach Platz; Kennzeichen wird mit abgefragt.', {
ressourcen: [
platz('Platz 1', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
platz('Platz 2', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
],
leistungen: [
leistung('Reifenwechsel', 45, 15, 'ab', 4900),
leistung('Ölwechsel', 45, 15, 'ab', 7900),
leistung('Inspektion', 120, 15, 'ab', 15000),
leistung('Bremsencheck', 30, 5, 'fest', 2900),
],
regeln: { raster: 30, vorlaufMin: 240, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }] },
recht: { gaesteSind: 'gemischt' },
}),
handwerk: vorlage('handwerk', 'Handwerk (Besichtigung vor Ort)', 'Besichtigung und Aufmaß als Anfrage; du bestätigst den Termin und planst die Anfahrt.', {
ressourcen: [person('Team', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '16:00']] }))],
leistungen: [
leistung('Besichtigung vor Ort', 60, 30, 'keiner', 0, 'Wir schauen uns die Aufgabe vor Ort an.'),
leistung('Aufmaß', 90, 30, 'keiner', 0),
leistung('Telefonische Beratung', 30, 0, 'keiner', 0),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
recht: { gaesteSind: 'gemischt' },
}),
tattoo: vorlage('tattoo', 'Tattoo und Piercing', 'Beratung und Sessions als Anfrage; Motiv, Größe und Stelle schreibt der Gast dazu.', {
ressourcen: [person('Tätowierer/in', wochenplan({ '2-6': [['11:00', '18:00']] }))],
leistungen: [
leistung('Beratungsgespräch', 30, 10, 'keiner', 0),
leistung('Kleines Tattoo', 120, 30, 'ab', 8000),
leistung('Tattoo-Session', 240, 30, 'ab', 25000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 120, absageBisStd: 48 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
}),
fotograf: vorlage('fotograf', 'Fotograf', 'Kennenlerngespräch und Shootings als Anfrage, mit längerem Vorlauf.', {
ressourcen: [person('Fotograf/in', wochenplan({ '1-5': [['10:00', '18:00']], '6': [['10:00', '14:00']] }))],
leistungen: [
leistung('Kennenlerngespräch', 30, 10, 'keiner', 0),
leistung('Porträt-Shooting', 90, 30, 'ab', 15000),
leistung('Familien-Shooting', 120, 30, 'ab', 25000),
leistung('Business-Porträt', 60, 15, 'ab', 12000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 2880, horizontTage: 120, absageBisStd: 48 },
gastFelder: { nachricht: 'optional' },
}),
hundesalon: vorlage('hundesalon', 'Hundesalon', 'Pflege nach Größe des Hundes; Name und Rasse werden mit abgefragt.', {
ressourcen: [person('Hundefriseur/in', wochenplan({ '2-5': MITTAG, '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Kleiner Hund', 60, 10, 'ab', 3500),
leistung('Mittelgroßer Hund', 90, 10, 'ab', 5500),
leistung('Großer Hund', 120, 15, 'ab', 7500),
leistung('Krallen schneiden', 15, 5, 'fest', 1000),
],
regeln: { vorlaufMin: 240 },
gastFelder: { zusatz: [{ key: 'hund', label: 'Name und Rasse des Hundes', typ: 'text', pflicht: true }] },
}),
sonstiges: vorlage('sonstiges', 'Sonstiges', 'Neutrale Vorlage mit zwei Terminlängen, die du frei umbenennst.', {
ressourcen: [person('Team', BUERO)],
leistungen: [
leistung('Termin (30 Minuten)', 30, 0, 'keiner', 0),
leistung('Termin (60 Minuten)', 60, 0, 'keiner', 0),
],
}),
})
/** [{ key, name, beschreibung }] in fester Reihenfolge, fuer die Auswahl beim ersten Oeffnen. */
export function vorlagenListe() {
return Object.values(VORLAGEN).map(({ key, name, beschreibung }) => ({ key, name, beschreibung }))
}
/**
* Einstellung (Entwurf) aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer,
* impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf.
* Telefon und Anschrift duerfen fehlen (normalisiereEinstellung mit entwurf); fehlendePflichtangaben(einstellung) nennt,
* was vor dem oeffentlichen Betrieb noch gebraucht wird. Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein
* Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler.
*/
export function ausVorlage(key, betrieb, now) {
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`)
const v = VORLAGEN[key]
const b = betrieb || {}
const ressourcen = v.ressourcen.map((r) => ({ id: neueId('r'), name: r.name, typ: r.typ, aktiv: true, plan: structuredClone(r.plan) }))
const eingabe = {
vorlage: key,
betrieb: {
name: b.name,
email: b.email,
telefon: b.telefon,
adresse: b.adresse,
kleinunternehmer: b.kleinunternehmer === true,
impressumUrl: b.impressumUrl,
datenschutzUrl: b.datenschutzUrl,
agbUrl: b.agbUrl,
websiteUrl: b.websiteUrl,
},
anrede: 'sie',
bundesland: b.bundesland,
ressourcen,
leistungen: v.leistungen.map((l) => ({ ...l, id: neueId('s'), buchungsart: v.buchungsart, aktiv: true, ressourcen: ressourcen.map((r) => r.id) })),
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: v.storno },
recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
}
// Entwurf: ein Kunde ohne Telefon/Anschrift im Konto kann trotzdem starten; die Angaben holt das Portal nach (fehlendePflichtangaben)
const ergebnis = normalisiereEinstellung(eingabe, { now, entwurf: true })
if (ergebnis.fehler) {
const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.')
err.fehler = ergebnis.fehler
throw err
}
return ergebnis.einstellung
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Terminplaner-Zeit: Wanduhr Europe/Berlin <-> UTC, nur ueber Intl (kein Browser-Datum, keine Zusatzbibliothek).
* Tage sind 'YYYY-MM-DD'-Strings, Uhrzeiten 'HH:MM'; Kalenderrechnung (plusTage, wochentag) fasst nie einen Zeitpunkt an.
* Nicht existierende Wanduhrzeiten (Maerz-Umstellung) liefert zuUtc als null, doppelte (Oktober) als erstes Vorkommen.
* Vorlage fuer offsetMs: /root/reports/terminplaner-2026-10-02/spike-code/tz.mjs
*/
export const TZ = 'Europe/Berlin'
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const ZEIT_RE = /^\d{2}:\d{2}$/
const MONAT_RE = /^\d{4}-\d{2}$/
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const MONATE = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
const pad = (n) => String(n).padStart(2, '0')
const formatierer = new Intl.DateTimeFormat('en-US', {
timeZone: TZ,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
})
/** Versatz (Wanduhr minus UTC) in ms zum UTC-Zeitpunkt utcMs. */
function offsetMs(utcMs) {
const p = {}
for (const { type, value } of formatierer.formatToParts(utcMs)) p[type] = Number(value)
const alsUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
return alsUtc - Math.floor(utcMs / 1000) * 1000
}
/** Ist tag ein echter Kalendertag (kein 30.02.)? */
function gueltigerTag(tag) {
if (typeof tag !== 'string' || !TAG_RE.test(tag)) return false
const [y, m, d] = tag.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10) === tag
}
/**
* Berliner Wanduhr -> UTC-Zeitpunkt (Date). '24:00' = Folgetag 00:00.
* null bei ungueltiger Eingabe und bei nicht existierenden Zeiten (Luecke der Maerz-Umstellung);
* doppelte Zeiten (Oktober) ergeben das erste Vorkommen (Sommerzeit).
*/
export function zuUtc(tag, hhmm) {
if (!gueltigerTag(tag) || typeof hhmm !== 'string' || !ZEIT_RE.test(hhmm)) return null
const [h, mi] = hhmm.split(':').map(Number)
if (h > 24 || mi > 59 || (h === 24 && mi !== 0)) return null
const [y, mo, d] = tag.split('-').map(Number)
const wand = Date.UTC(y, mo - 1, d, h, mi) // die Wanduhr als ob UTC; 24:00 rollt auf den Folgetag
const treffer = [...new Set([wand - offsetMs(wand - 86400000), wand - offsetMs(wand + 86400000)])]
.filter((t) => t + offsetMs(t) === wand)
.sort((a, b) => a - b)
return treffer.length ? new Date(treffer[0]) : null
}
/** UTC-Zeitpunkt -> Berliner Wanduhr { tag: 'YYYY-MM-DD', uhrzeit: 'HH:MM' } (Sekunden werden abgeschnitten). */
export function ausUtc(date) {
const t = new Date(date.getTime() + offsetMs(date.getTime()))
return {
tag: `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`,
uhrzeit: `${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`,
}
}
/** ISO-Wochentag 1..7 (Montag = 1) eines 'YYYY-MM-DD'. */
export function wochentag(tag) {
const [y, m, d] = tag.split('-').map(Number)
const w = new Date(Date.UTC(y, m - 1, d)).getUTCDay()
return w === 0 ? 7 : w
}
/** Kalendertag + n Tage (n darf negativ sein), unabhaengig von der Zeitumstellung. */
export function plusTage(tag, n) {
const [y, m, d] = tag.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10)
}
/** Berliner Kalendertag des Zeitpunkts now. */
export function heute(now = new Date()) {
return ausUtc(now).tag
}
/**
* Kalenderdaten eines Monats 'YYYY-MM': { label: 'Oktober 2026', ersterWochentag (ISO 1..7), anzahlTage,
* vorher, nachher ('YYYY-MM') }. Ungueltiger Monat => null.
*/
export function monatInfo(monat) {
if (typeof monat !== 'string' || !MONAT_RE.test(monat)) return null
const [y, m] = monat.split('-').map(Number)
if (m < 1 || m > 12) return null
const monatString = (jahr, nr) => {
const t = new Date(Date.UTC(jahr, nr - 1, 1))
return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}`
}
return {
label: `${MONATE[m - 1]} ${y}`,
ersterWochentag: wochentag(`${monat}-01`),
anzahlTage: new Date(Date.UTC(y, m, 0)).getUTCDate(),
vorher: monatString(y, m - 1),
nachher: monatString(y, m + 1),
}
}
/** 'Montag, 12.10.2026' (Berliner Wanduhr). */
export function datumLang(date) {
const { tag } = ausUtc(date)
return `${WOCHENTAGE[wochentag(tag) - 1]}, ${datumKurzVonTag(tag)}`
}
/** '12.10.2026' (Berliner Wanduhr). */
export function datumKurz(date) {
return datumKurzVonTag(ausUtc(date).tag)
}
/** '10:30' (Berliner Wanduhr). */
export function uhrzeit(date) {
return ausUtc(date).uhrzeit
}
function datumKurzVonTag(tag) {
const [y, m, d] = tag.split('-')
return `${d}.${m}.${y}`
}
+270
View File
@@ -0,0 +1,270 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { istBelegend } from './slots.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5).
*
* Jede 5-Minuten-Zelle (config.termine.zelleMin) von Beginn bis Blockende ist ein termineClaims-Dokument mit
* deterministischer ID (c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)). Alle neuen Zellen einer Belegung gehen
* in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID
* ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses.
*
* Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe
* istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst,
* wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest
* (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur).
*
* Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine
* neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer
* frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen
* gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur,
* was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist.
*
* Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in
* der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen
* Zeit zwischen Zellen und Buchung wegraeumen. Scheitert belegeZellen (SLOT_VERGEBEN), raeumt der Aufrufer die
* Buchung selbst wieder auf. Beim Verschieben ist die Buchung ohnehin da.
*/
/** Hoechstens so viele Zellen in einem Bulk-Create (Appwrite: 100 Dokumente je Anfrage). */
const MAX_ZELLEN = 100
const TAG_MS = 24 * 60 * 60 * 1000
const SPERRE_WARTE_MS = 5000
/** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */
const AUFRAEUM_WARTE_MS = 15000
/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */
const RAEUM_BLOCK = 100
const RAEUM_RUNDEN = 3
/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */
const ID_BLOCK = 50
const LOESCH_PARALLEL = 10
/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */
const ALLE_ZELLEN_MAX = 100000
const claims = () => config.collections.termineClaims
const buchungen = () => config.collections.termineBookings
const sperrSchluessel = (featureId, ressourceId) => `termine-res:${featureId}:${ressourceId}`
const slotVergeben = () =>
Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), {
status: 409,
code: 'SLOT_VERGEBEN',
})
/**
* Alle Zellen von start bis blockEnde (ausgeschlossen) als Date, ausgerichtet am Raster seit 1970 (die Berliner
* Zeitzone verschiebt um ganze Stunden, daher stimmt das Raster mit der Wanduhr ueberein). Krumme Zeiten werden zu
* ganzen Zellen erweitert. Wirft bei ungueltigen Zeiten, Ende <= Beginn und mehr als 100 Zellen.
*/
export function zellenFuer(start, blockEnde) {
const von = zeitMs(start)
const bis = zeitMs(blockEnde)
if (!Number.isFinite(von) || !Number.isFinite(bis) || bis <= von) throw new RangeError('zellenFuer: Beginn und Ende ungültig')
const zelle = config.termine.zelleMin * 60000
const erste = Math.floor(von / zelle)
const letzte = Math.ceil(bis / zelle)
if (letzte - erste > MAX_ZELLEN) throw new RangeError(`zellenFuer: mehr als ${MAX_ZELLEN} Zellen`)
const zellen = []
for (let i = erste; i < letzte; i += 1) zellen.push(new Date(i * zelle))
return zellen
}
/** Dokument-ID einer Zelle: c_ + 34 Hex (36 Zeichen, das Maximum fuer Appwrite-IDs). */
export function zellenId(featureId, ressourceId, zelle) {
const iso = new Date(zeitMs(zelle)).toISOString()
return `c_${createHash('sha1').update(`${featureId}|${ressourceId}|${iso}`).digest('hex').slice(0, 34)}`
}
/** Dokumente einer Collection nach ihren IDs (in Bloecken, damit die Abfrage kurz bleibt; fehlende fehlen im Ergebnis). */
async function leseNachIds(collection, ids) {
const gefunden = []
for (let i = 0; i < ids.length; i += ID_BLOCK) {
gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))])))
}
return gefunden
}
/** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */
async function ungueltigeZellen(zellen, nowMs) {
const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))]
const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung]))
return zellen.filter((zelle) => {
const buchung = vorhanden.get(zelle.bookingId)
return !buchung || !istBelegend(buchung, nowMs)
})
}
/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */
async function loesche(collection, ids) {
let geloescht = 0
let fehler = null
for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) {
const ergebnisse = await Promise.allSettled(ids.slice(i, i + LOESCH_PARALLEL).map((id) => deleteDocument(collection, id)))
for (const ergebnis of ergebnisse) {
if (ergebnis.status === 'fulfilled') geloescht += 1
else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason
}
}
if (fehler) throw fehler
return geloescht
}
/** Ein Bulk-Create; false bei 409 (eine Zelle ist vergeben, nichts geschrieben), andere Fehler gehen durch. */
async function legeZellenAn(dokumente) {
try {
await createDocuments(claims(), dokumente)
return true
} catch (err) {
if (err?.status === 409) return false
throw err
}
}
/**
* Nach einem 409: die blockierenden Zellen lesen und die ungueltigen loeschen. true = ein zweiter Versuch lohnt
* (ungueltige geloescht oder die Blockierer sind inzwischen weg), false = alle Blockierer gelten.
*/
async function raeumeBlockierer(ids, nowMs) {
const blockierer = await leseNachIds(claims(), ids)
if (blockierer.length === 0) return true
const ungueltig = await ungueltigeZellen(blockierer, nowMs)
if (ungueltig.length === 0) return false
await loesche(claims(), ungueltig.map((zelle) => zelle.$id))
return true
}
/**
* Belegt die Zellen von start bis blockEnde fuer bookingId auf einer Ressource oder wirft { status: 409, code:
* 'SLOT_VERGEBEN' } (dann ist nichts geschrieben). Unter der Sperre termine-res:<featureId>:<ressourceId> (hoechstens
* 5 s Wartezeit; die Datenbank bleibt die eigentliche Sicherung).
*
* ausser = ID einer Buchung, deren Zellen als eigene zaehlen (Verschieben): angelegt wird nur, was von ihr noch fehlt;
* ihre alten Zellen bleiben, bis der Aufrufer gibZellenFrei(id, { behalte }) aufruft. Bei einem Fehler aendert sich an
* ihnen nichts.
*/
export async function belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null } = {}) {
pruefeText(featureId, 'featureId')
pruefeText(ressourceId, 'ressourceId')
pruefeText(bookingId, 'bookingId')
if (ausser !== null) pruefeText(ausser, 'ausser')
const nowMs = pruefeNow(now, 'belegeZellen')
const zellen = zellenFuer(start, blockEnde)
const bis = new Date(zeitMs(blockEnde)).toISOString()
const dokumente = zellen.map((zelle) => ({
$id: zellenId(featureId, ressourceId, zelle),
featureId,
ressourceId,
bookingId,
zelle: zelle.toISOString(),
bis,
}))
await withLocks(
[sperrSchluessel(featureId, ressourceId)],
async () => {
let neu = dokumente
if (ausser !== null) {
const eigene = new Set((await listAllDocuments(claims(), [Query.equal('bookingId', ausser)])).map((zelle) => zelle.$id))
neu = dokumente.filter((dokument) => !eigene.has(dokument.$id))
}
if (neu.length === 0) return
if (await legeZellenAn(neu)) return
if (!(await raeumeBlockierer(neu.map((dokument) => dokument.$id), nowMs))) throw slotVergeben()
if (!(await legeZellenAn(neu))) throw slotVergeben()
},
{ waitMs: SPERRE_WARTE_MS }
)
}
/**
* Loescht die Zellen einer Buchung, ausser denen mit den Zeiten aus behalte (Date oder ISO-Text, z. B. das Ergebnis
* von zellenFuer). Mit ressourceId bleiben nur Zellen dieser Ressource stehen (Verschieben auf eine andere Person zur
* selben Zeit). Liest seitenweise (mehr als 25 Zellen). Geloescht wird unter der Sperre jeder betroffenen Ressource
* nach erneutem Lesen: nur Zellen, die dort jetzt noch zu bookingId gehoeren (siehe Kopfkommentar). => Zahl geloeschter.
*/
export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) {
pruefeText(bookingId, 'bookingId')
const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite))
const bleibt = (zelle) => behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId)
const suche = [Query.equal('bookingId', bookingId)]
let geloescht = 0
for (let runde = 0; runde < RAEUM_RUNDEN; runde += 1) {
const gelesen = await listAllDocuments(claims(), suche) // nur um die Ressourcen zu kennen, geloescht wird nach neuem Lesen
const schluessel = [...new Set(gelesen.map((zelle) => sperrSchluessel(zelle.featureId, zelle.ressourceId)))].sort()
if (schluessel.length === 0) break
const gesperrt = new Set(schluessel)
const vollstaendig = await withLocks(
schluessel,
async () => {
const aktuell = await listAllDocuments(claims(), suche)
const hier = aktuell.filter((zelle) => gesperrt.has(sperrSchluessel(zelle.featureId, zelle.ressourceId)))
geloescht += await loesche(claims(), hier.filter((zelle) => !bleibt(zelle)).map((zelle) => zelle.$id))
return hier.length === aktuell.length
},
{ waitMs: AUFRAEUM_WARTE_MS }
)
if (vollstaendig) break
}
return geloescht
}
/** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */
async function verwaisteAus(zellen, nowMs) {
const grenze = nowMs - TAG_MS
const alt = []
const pruefen = []
for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle)
return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))]
}
const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX })
/**
* IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw.
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht:
* zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen.
*/
export async function verwaisteZellen(now) {
const nowMs = pruefeNow(now, 'verwaisteZellen')
return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id)
}
/**
* Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren
* Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert
* oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen.
* => Zahl geloeschter Zellen.
*/
export async function raeumeVerwaisteZellen(now) {
const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen')
const nachSperre = new Map()
for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) {
const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId)
nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle])
}
let geloescht = 0
let fehler = null
for (const [schluessel, liste] of nachSperre) {
for (let i = 0; i < liste.length; i += RAEUM_BLOCK) {
const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId]))
try {
geloescht += await withLocks(
[schluessel],
async () => {
const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId)
return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id))
},
{ waitMs: AUFRAEUM_WARTE_MS }
)
} catch (err) {
fehler ??= err
}
}
}
if (fehler) throw fehler
return geloescht
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Schreib-Begrenzung je Schluessel (meist Kunde) im Speicher: hoechstens `max` Aufrufe je Fenster (Standard eine Stunde),
* gezaehlt ab dem Aufruf. Jeder Aufruf zaehlt, auch ein abgelehnter. Wie beim Chat und bei den News nur im Prozess, ein
* Neustart setzt die Zaehler zurueck; Schluessel ohne Treffer im Fenster werden bei der naechsten Abfrage vergessen.
* Genutzt von routes/news.js (30 je Stunde) und routes/termine.js (120 je Stunde).
*
* @param {{ max: number, fensterMs?: number, now?: () => number }} optionen now liefert Millisekunden (Tests: feste Uhr)
* @returns {{
* istUeberschritten: (key: string) => boolean,
* middleware: (keyOf: (req: object) => string, message: string) => (req: object, res: object, next: () => void) => void,
* reset: () => void,
* }} istUeberschritten zaehlt diesen Aufruf mit und sagt, ob damit mehr als `max` im Fenster liegen;
* middleware antwortet dann 429 { error: message }, sonst next(); reset leert alles (Tests)
*/
export function createWriteLimiter({ max, fensterMs = 60 * 60 * 1000, now = () => Date.now() } = {}) {
if (!Number.isInteger(max) || max < 1) throw new TypeError('createWriteLimiter: max muss eine ganze Zahl ab 1 sein')
if (!Number.isFinite(fensterMs) || fensterMs <= 0) throw new TypeError('createWriteLimiter: fensterMs muss groesser als 0 sein')
if (typeof now !== 'function') throw new TypeError('createWriteLimiter: now muss eine Funktion sein')
const treffer = new Map() // Schluessel -> Zeitpunkte (ms) im Fenster
function istUeberschritten(key) {
const jetzt = now()
for (const [schluessel, zeiten] of treffer) {
const frisch = zeiten.filter((t) => jetzt - t < fensterMs)
if (frisch.length === 0) treffer.delete(schluessel)
else if (frisch.length !== zeiten.length) treffer.set(schluessel, frisch)
}
const liste = treffer.get(key) || []
liste.push(jetzt)
treffer.set(key, liste)
return liste.length > max
}
const middleware = (keyOf, message) => (req, res, next) => {
if (istUeberschritten(keyOf(req))) return res.status(429).json({ error: message })
return next()
}
return { istUeberschritten, middleware, reset: () => treffer.clear() }
}
+226
View File
@@ -0,0 +1,226 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Nachbau wie in den Termine-Tests (Schema, Datumsfelder, Standard-Limit 25) und einer ohne Optionen
const aw = await startAppwriteMock({
schema: { termineClaims: schemaKeys('termineClaims'), termineBookings: schemaKeys('termineBookings'), termineOutbox: schemaKeys('termineOutbox') },
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
const plain = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), plain.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { Query, createDocument, createDocuments, getDocument, updateDocument, listDocuments, listAllDocuments } = await import(
'../server/services/appwriteAdmin.js'
)
const idsOf = (collection, prefix) => [...aw.docs(collection).keys()].filter((id) => id.startsWith(prefix)).sort()
const cell = (id, over = {}) => ({
$id: id,
featureId: 'f1',
ressourceId: 'r1',
bookingId: 'b1',
zelle: '2026-10-05T10:00:00.000Z',
bis: '2026-10-05T10:30:00.000Z',
...over,
})
const rawDocs = (mock, collection) => `${mock.url}/databases/woms-database/collections/${collection}/documents`
const postJson = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
test('Query-Helfer bauen die Appwrite-Abfragen', () => {
assert.deepEqual(Query.between('startsAt', 'a', 'b'), { method: 'between', attribute: 'startsAt', values: ['a', 'b'] })
assert.deepEqual(Query.greaterThanEqual('startsAt', 'a'), { method: 'greaterThanEqual', attribute: 'startsAt', values: ['a'] })
assert.deepEqual(Query.lessThanEqual('startsAt', 'b'), { method: 'lessThanEqual', attribute: 'startsAt', values: ['b'] })
assert.deepEqual(Query.cursorAfter('doc1'), { method: 'cursorAfter', values: ['doc1'] })
})
test('Bulk-Create legt 3 Dokumente in einer Anfrage an', async () => {
const created = await createDocuments('termineClaims', [cell('t2_a1'), cell('t2_a2', { bookingId: 'b2' }), cell('t2_a3')])
assert.deepEqual(created.map((d) => d.$id), ['t2_a1', 't2_a2', 't2_a3'])
assert.deepEqual(idsOf('termineClaims', 't2_a'), ['t2_a1', 't2_a2', 't2_a3'])
const second = await getDocument('termineClaims', 't2_a2')
assert.equal(second.bookingId, 'b2')
assert.equal(second.featureId, 'f1')
})
test('Bulk-Create ohne Dokumente sendet nichts', async () => {
assert.deepEqual(await createDocuments('termineClaims', []), [])
})
test('Bulk mit vorhandener ID: 409 und nichts geschrieben', async () => {
await createDocument('termineClaims', cell('t2_b2', { bookingId: 'alt' }), 't2_b2')
const before = [...aw.docs('termineClaims').entries()].length
await assert.rejects(
createDocuments('termineClaims', [cell('t2_b1'), cell('t2_b2', { bookingId: 'neu' }), cell('t2_b3')]),
(err) => err.status === 409 && err.type === 'document_already_exists'
)
assert.deepEqual(idsOf('termineClaims', 't2_b'), ['t2_b2'])
assert.equal(aw.docs('termineClaims').size, before)
assert.equal(aw.docs('termineClaims').get('t2_b2').bookingId, 'alt')
})
test('Bulk mit doppelter ID innerhalb der Anfrage: 409 und nichts geschrieben', async () => {
await assert.rejects(createDocuments('termineClaims', [cell('t2_c1'), cell('t2_c1')]), (err) => err.status === 409)
assert.deepEqual(idsOf('termineClaims', 't2_c'), [])
})
test('Bulk mit unbekanntem Attribut oder ungueltigem Datum: 400 und nichts geschrieben', async () => {
await assert.rejects(
createDocuments('termineClaims', [cell('t2_d1'), cell('t2_d2', { bogus: 1 })]),
(err) => err.status === 400 && /Unknown attribute: "bogus"/.test(err.message)
)
await assert.rejects(createDocuments('termineClaims', [cell('t2_d3'), cell('t2_d4', { zelle: '' })]), (err) => err.status === 400)
await assert.rejects(createDocuments('termineClaims', [cell('t2_d5'), { featureId: 'ohne-id' }]), (err) => err.status === 400)
assert.deepEqual(idsOf('termineClaims', 't2_d'), [])
})
test('Bulk ueber 100 Dokumente: 400 wie Appwrite, nichts geschrieben', async () => {
const many = Array.from({ length: 101 }, (_, i) => cell(`t2_e${String(i).padStart(3, '0')}`))
await assert.rejects(createDocuments('termineClaims', many), (err) => err.status === 400)
assert.deepEqual(idsOf('termineClaims', 't2_e'), [])
const exactly = await createDocuments('termineClaims', many.slice(0, 100))
assert.equal(exactly.length, 100)
})
test('between ist inklusiv, greaterThanEqual/lessThanEqual ebenso', async () => {
const starts = ['10:00', '10:30', '11:00', '11:30', '12:00']
for (const [i, t] of starts.entries()) {
await createDocument('termineBookings', { featureId: 'f_between', startsAt: `2026-10-05T${t}:00.000Z`, status: 'bestaetigt' }, `t2_bk${i}`)
}
const inFeature = Query.equal('featureId', 'f_between')
const order = Query.orderAsc('startsAt')
const between = await listDocuments('termineBookings', [inFeature, Query.between('startsAt', '2026-10-05T10:30:00.000Z', '2026-10-05T11:30:00.000Z'), order])
assert.deepEqual(between.map((d) => d.$id), ['t2_bk1', 't2_bk2', 't2_bk3'])
// dieselben Zeitpunkte in anderer Schreibweise (Berlin, Sommerzeit +02:00)
const offset = await listDocuments('termineBookings', [inFeature, Query.between('startsAt', '2026-10-05T12:30:00+02:00', '2026-10-05T13:30:00+02:00'), order])
assert.deepEqual(offset.map((d) => d.$id), ['t2_bk1', 't2_bk2', 't2_bk3'])
const from = await listDocuments('termineBookings', [inFeature, Query.greaterThanEqual('startsAt', '2026-10-05T11:30:00.000Z'), order])
assert.deepEqual(from.map((d) => d.$id), ['t2_bk3', 't2_bk4'])
const until = await listDocuments('termineBookings', [inFeature, Query.lessThanEqual('startsAt', '2026-10-05T10:30:00.000Z'), order])
assert.deepEqual(until.map((d) => d.$id), ['t2_bk0', 't2_bk1'])
// greaterThan/lessThan bleiben exklusiv
const later = await listDocuments('termineBookings', [inFeature, Query.greaterThan('startsAt', '2026-10-05T11:30:00Z'), order])
assert.deepEqual(later.map((d) => d.$id), ['t2_bk4'])
const equal = await listDocuments('termineBookings', [inFeature, Query.equal('startsAt', '2026-10-05T13:00:00+02:00')])
assert.deepEqual(equal.map((d) => d.$id), ['t2_bk2'])
})
test('cursorAfter liefert die Dokumente nach dem genannten', async () => {
for (let i = 0; i < 6; i += 1) aw.seed('termineClaims', cell(`t2_f${i}`, { featureId: 'f_cursor' }))
const page = await listDocuments('termineClaims', [Query.equal('featureId', 'f_cursor'), Query.orderAsc('$id'), Query.cursorAfter('t2_f1'), Query.limit(3)])
assert.deepEqual(page.map((d) => d.$id), ['t2_f2', 't2_f3', 't2_f4'])
await assert.rejects(listDocuments('termineClaims', [Query.cursorAfter('gibt-es-nicht'), Query.limit(3)]), (err) => err.status === 400)
})
test('listAllDocuments laedt ueber 3 Seiten, in $id-Reihenfolge und nur die passenden', async () => {
for (let i = 0; i < 25; i += 1) aw.seed('termineClaims', cell(`t2_g${String(i).padStart(2, '0')}`, { featureId: 'f_pages' }))
for (let i = 0; i < 7; i += 1) aw.seed('termineClaims', cell(`t2_g_fremd${i}`, { featureId: 'f_andere' }))
const realFetch = globalThis.fetch
let requests = 0
globalThis.fetch = (...args) => {
requests += 1
return realFetch(...args)
}
let all
try {
all = await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_pages')], { pageSize: 10 })
} finally {
globalThis.fetch = realFetch
}
assert.equal(requests, 3)
assert.equal(all.length, 25)
assert.deepEqual(all.map((d) => d.$id), Array.from({ length: 25 }, (_, i) => `t2_g${String(i).padStart(2, '0')}`))
})
test('listAllDocuments: Vielfaches der Seitengroesse, leere Treffermenge und Standard-Seitengroesse', async () => {
for (let i = 0; i < 20; i += 1) aw.seed('termineClaims', cell(`t2_h${String(i).padStart(2, '0')}`, { featureId: 'f_vielfach' }))
assert.equal((await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_vielfach')], { pageSize: 10 })).length, 20)
assert.deepEqual(await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_leer')]), [])
// 30 Treffer > defaultLimit 25: nur mit eigenem limit-Aufruf vollstaendig
for (let i = 0; i < 30; i += 1) aw.seed('termineClaims', cell(`t2_h_x${String(i).padStart(2, '0')}`, { featureId: 'f_dreissig' }))
assert.equal((await listAllDocuments('termineClaims', [Query.equal('featureId', 'f_dreissig')])).length, 30)
})
test('listAllDocuments: mehr Treffer als max ist ein Fehler, genau max nicht', async () => {
for (let i = 0; i < 21; i += 1) aw.seed('termineClaims', cell(`t2_i${String(i).padStart(2, '0')}`, { featureId: 'f_max' }))
const filter = [Query.equal('featureId', 'f_max')]
await assert.rejects(listAllDocuments('termineClaims', filter, { pageSize: 10, max: 20 }), /mehr als 20 /)
assert.equal((await listAllDocuments('termineClaims', filter, { pageSize: 10, max: 21 })).length, 21)
await assert.rejects(listAllDocuments('termineClaims', filter, { pageSize: 0 }), /pageSize/)
})
test('defaultLimit 25 liefert ohne limit-Abfrage 25 von 30', async () => {
for (let i = 0; i < 30; i += 1) aw.seed('termineOutbox', { $id: `t2_j${String(i).padStart(2, '0')}`, featureId: 'f_limit', status: 'wartet' })
const filter = [Query.equal('featureId', 'f_limit')]
assert.equal((await listDocuments('termineOutbox', filter)).length, 25)
assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(30)])).length, 30)
assert.equal((await listDocuments('termineOutbox', [...filter, Query.limit(5)])).length, 5)
})
test('Datum mit Z kommt als +00:00 zurueck (Anlegen, Aendern, Bulk, Seed)', async () => {
const made = await createDocument('termineBookings', { featureId: 'f_dt', startsAt: '2026-10-05T10:00:00Z', endsAt: '2026-10-05T12:30:00+02:00', status: 'x' }, 't2_dt1')
assert.equal(made.startsAt, '2026-10-05T10:00:00.000+00:00')
assert.equal(made.endsAt, '2026-10-05T10:30:00.000+00:00')
assert.equal((await getDocument('termineBookings', 't2_dt1')).startsAt, '2026-10-05T10:00:00.000+00:00')
const patched = await updateDocument('termineBookings', 't2_dt1', { heldUntil: '2026-10-05T09:59:00.5Z' })
assert.equal(patched.heldUntil, '2026-10-05T09:59:00.500+00:00')
assert.equal(patched.startsAt, '2026-10-05T10:00:00.000+00:00')
const [bulk] = await createDocuments('termineClaims', [cell('t2_dt2', { zelle: '2026-10-05T10:05:00Z' })])
assert.equal(bulk.zelle, '2026-10-05T10:05:00.000+00:00')
assert.equal(bulk.bis, '2026-10-05T10:30:00.000+00:00')
aw.seed('termineBookings', { $id: 't2_dt3', startsAt: '2026-10-05T10:00:00Z' })
assert.equal(aw.docs('termineBookings').get('t2_dt3').startsAt, '2026-10-05T10:00:00.000+00:00')
// Textfelder, die zufaellig wie ein Datum aussehen, bleiben unveraendert
const text = await createDocument('termineBookings', { gastNachricht: '2026-10-05T10:00:00Z' }, 't2_dt4')
assert.equal(text.gastNachricht, '2026-10-05T10:00:00Z')
// null bleibt erlaubt (optionales Datumsfeld leeren)
const cleared = await updateDocument('termineBookings', 't2_dt1', { heldUntil: null })
assert.equal(cleared.heldUntil, null)
})
test('leeres oder unlesbares Datum: 400 beim Anlegen und Aendern', async () => {
await assert.rejects(createDocument('termineBookings', { startsAt: '' }, 't2_dt5'), (err) => err.status === 400)
await assert.rejects(createDocument('termineBookings', { startsAt: 'morgen' }, 't2_dt6'), (err) => err.status === 400)
await assert.rejects(updateDocument('termineBookings', 't2_dt1', { endsAt: '' }), (err) => err.status === 400)
assert.equal(aw.docs('termineBookings').has('t2_dt5'), false)
assert.equal((await getDocument('termineBookings', 't2_dt1')).endsAt, '2026-10-05T10:30:00.000+00:00')
})
test('Nachbau ohne Optionen verhaelt sich wie bisher', async () => {
const col = rawDocs(plain, 'irgendwas')
for (let i = 0; i < 30; i += 1) {
const res = await postJson(col, { documentId: `p${String(i).padStart(2, '0')}`, data: { n: i, am: '2026-10-05T10:00:00Z' } })
assert.equal(res.status, 201)
}
const listed = await (await fetch(col)).json()
assert.equal(listed.documents.length, 30)
assert.equal(listed.documents[0].am, '2026-10-05T10:00:00Z')
const queries = [{ method: 'between', attribute: 'n', values: [5, 7] }, { method: 'orderAsc', attribute: 'n' }]
const between = await (await fetch(`${col}?${queries.map((q) => `queries[]=${encodeURIComponent(JSON.stringify(q))}`).join('&')}`)).json()
assert.deepEqual(between.documents.map((d) => d.n), [5, 6, 7])
const bulk = await postJson(col, { documents: [{ $id: 'q1', n: 100 }, { $id: 'q2', n: 101 }] })
assert.equal(bulk.status, 201)
assert.equal((await postJson(col, { documents: [{ $id: 'q3', n: 102 }, { $id: 'q1', n: 103 }] })).status, 409)
assert.equal(plain.docs('irgendwas').has('q3'), false)
})
test('testApp.send ruft beliebige Methoden auf', async () => {
const { startTestApp, send } = await import('./support/testApp.js')
const router = express.Router()
router.put('/echo', (req, res) => res.json({ method: req.method, body: req.body, via: req.get('x-test') }))
const app = await startTestApp({ '/t': router })
try {
const res = await send('PUT', '/t/echo', { body: { a: 1 }, headers: { 'x-test': 'ja' } })
assert.equal(res.status, 200)
assert.deepEqual(res.body, { method: 'PUT', body: { a: 1 }, via: 'ja' })
} finally {
await app.close()
}
})
+5 -2
View File
@@ -17,8 +17,11 @@ 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 sieben Aktionen', () => {
assert.deepEqual(VIEW_ACTIONS, ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'])
test('VIEW_ACTIONS: genau die zehn Aktionen', () => {
assert.deepEqual(VIEW_ACTIONS, [
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
])
})
test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => {
+3
View File
@@ -64,6 +64,9 @@ test('liste_zustaende', async () => {
assert.equal(body.consentText, config.funktionen.consentText)
assert.equal(body.projects.length, 4)
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
// Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js
for (const p of body.projects) assert.equal(p.features.termine.state, 'off')
assert.deepEqual(body.termine, { verfuegbar: false })
})
test('checkout_ohne_zustimmung_400', async () => {
+207
View File
@@ -0,0 +1,207 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter,
// Kunde loeschen schaltet den Terminplaner ab. News verhaelt sich wie bisher (test/funktionenRoutes.test.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
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' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const cookie = customerCookie('c1')
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const workorders = () => aw.docs('workorders').size
const docOf = (projectId, key) => sf.getFeatureDoc('c1', projectId, key)
test('Team aktiviert termine fuer ein Projekt, News bleibt unveraendert', async () => {
const tickets = workorders()
const res = await team('c1', 'termine', { projectId: 'pt1', action: 'activate', note: 'Pilot' })
assert.equal(res.status, 200)
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['pt1'])
const view = featuresOf(res, 'pt1')
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.ticketWoid, null)
assert.match(view.termine.siteKey, /^[a-z2-7]{16}$/)
assert.match(view.termine.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(view.termine.ticketMissing, false)
assert.equal(view.news.state, 'off')
assert.equal(view.news.adminNote, '')
const doc = await docOf('pt1', 'termine')
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedByPurchase], ['termine', 'active', true, false])
assert.equal(await docOf('pt1', 'news'), null, 'kein News-Dokument entstanden')
assert.equal(workorders(), tickets, 'kein Ticket')
// das Projekt ohne Aktion bleibt aus
const list = await get('/api/team/funktionen/c1', STAFF)
assert.equal(list.projects.find((p) => p.projectId === 'pt2').features.termine.state, 'off')
})
test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => {
const res = await team('c1', 'news', { projectId: 'pt1', action: 'activate', note: 'Kulanz' })
assert.equal(res.status, 200)
const view = featuresOf(res, 'pt1')
assert.equal(view.news.state, 'active')
assert.match(view.news.adminNote, /Andrej: activate – Kulanz$/)
assert.equal(view.termine.state, 'active')
assert.doesNotMatch(view.termine.adminNote, /Kulanz/)
assert.notEqual((await docOf('pt1', 'news')).siteKey, (await docOf('pt1', 'termine')).siteKey)
assert.equal((await team('c1', 'news', { projectId: 'pt1', action: 'disable' })).status, 200)
const after = await docOf('pt1', 'termine')
assert.equal(after.state, 'active')
})
test('Abschalten setzt disabledAt, erneutes Aktivieren leert es (R36)', async () => {
const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' })
assert.equal(off.status, 200)
assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled')
const doc = await docOf('pt1', 'termine')
assert.equal(doc.enabled, false)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000)
const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' })
assert.equal(featuresOf(on, 'pt1').termine.state, 'active')
assert.equal((await docOf('pt1', 'termine')).disabledAt, null)
})
test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => {
const docs = aw.docs('portalFeatures').size
for (const key of ['kalender', 'analyse', 'Termine', 'termine2']) {
const res = await team('c1', key, { projectId: 'pt2', action: 'activate' })
assert.equal(res.status, 404, key)
assert.equal(typeof res.body.error, 'string', key)
}
// der Schluessel wird vor dem Rumpf geprueft
assert.equal((await team('c1', 'kalender', {})).status, 404)
assert.equal(aw.docs('portalFeatures').size, docs)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Meldungen sind je Funktion grammatisch', async () => {
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Ohne Dokument' })
const termine = await team('c1', 'termine', { projectId: 'pt4', action: 'disable' })
assert.equal(termine.status, 409)
assert.equal(termine.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
const news = await team('c1', 'news', { projectId: 'pt4', action: 'disable' })
assert.equal(news.status, 409)
assert.equal(news.body.error, 'Für dieses Projekt gibt es noch keine News-Pinnwand')
assert.equal(await docOf('pt4', 'termine'), null)
})
test('Pruefungen wie bei News: Aktion, Projekt, fremdes Projekt, Kunde', async () => {
assert.equal((await team('c1', 'termine', { projectId: 'pt1', action: 'loeschen' })).status, 400)
assert.equal((await team('c1', 'termine', { action: 'activate' })).status, 400)
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'activate' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'activate' })).status, 403)
assert.equal(await sf.getFeatureDoc('c2', 'pt3', 'termine'), null)
const asCustomer = await team('c1', 'termine', { projectId: 'pt2', action: 'activate' }, { cookie })
assert.ok(asCustomer.status === 401 || asCustomer.status === 403)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Die Ansicht des Teams liest die Funktionsdokumente des Kunden einmal', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
const list = await get('/api/team/funktionen/c1', STAFF)
assert.ok(list.projects.length >= 3)
assert.equal(abfragen.length, 1, abfragen.join(' | '))
assert.match(abfragen[0], /^GET .*\/collections\/portalFeatures\/documents$/)
})
// ------------------------------------------------------------- Kundenliste ---
const MODUS = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
const termineMit = (mode, pilotEmails = []) => {
config.termine.mode = mode
config.termine.pilotEmails = pilotEmails
}
after(() => termineMit(MODUS.mode, MODUS.pilotEmails))
const verfuegbar = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail', async () => {
termineMit('off')
assert.deepEqual(await verfuegbar(), { verfuegbar: false })
termineMit('pilot', ['k@example.invalid'])
assert.deepEqual(await verfuegbar(), { verfuegbar: true })
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
termineMit('pilot', [])
assert.deepEqual(await verfuegbar(), { verfuegbar: false })
termineMit('pilot', ['*'])
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
termineMit('on')
assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
})
test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => {
termineMit('pilot', ['*'])
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
const pt1 = body.projects.find((p) => p.projectId === 'pt1')
assert.equal(pt1.features.termine.state, 'active')
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'])
// ohne Sitzung wie bisher gesperrt
assert.equal((await rawGet('/api/funktionen')).status, 401)
})
// --------------------------------------------------------- Kunde loeschen ---
test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur seine', async () => {
aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' })
aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' })
aw.seed('websiteProjects', { $id: 'ktp3', customerId: 'kt1', projectName: 'Weg Drei' })
await sf.adminActivate('kt1', 'ktp1', 'termine', { by: 'test' })
await sf.adminActivate('kt1', 'ktp1', 'news', { by: 'test' })
await sf.adminActivate('kt1', 'ktp2', 'termine', { by: 'test' })
const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' })
const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt
const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine')
await deleteCustomerWithPortalAccess('kt1')
assert.equal(aw.docs('customers').has('kt1'), false)
for (const projekt of ['ktp1', 'ktp2']) {
const doc = await sf.getFeatureDoc('kt1', projekt, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.adminNote], ['disabled', false, 'Kunde gelöscht'], projekt)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt)
}
assert.equal((await sf.getFeatureDoc('kt1', 'ktp3', 'termine')).disabledAt, frueher, 'Beginn der 30 Tage bleibt')
assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde')
})
test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp4', customerId: 'kt2', projectName: 'Weg Vier' })
await sf.adminActivate('kt2', 'ktp4', 'termine', { by: 'test' })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt2')
assert.equal(aw.docs('customers').has('kt2'), false)
assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0]))))
})
+97
View File
@@ -49,6 +49,103 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt',
assert.doesNotMatch(disabled, /Dev-Modus/)
})
// ---- Terminplaner-Kachel: nur mit Schalter (termine.verfuegbar), Zustand je Projekt aus features.termine
const withTermine = (id, state) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state } } })
const noAnalyse = { comingSoon: true }
async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) {
const calls = []
globalThis.fetch = async (url) => {
calls.push(String(url))
if (String(url).startsWith('/api/termine/einstellungen')) {
const id = new URL(url, 'http://x').searchParams.get('projectId')
return { ok: true, status: 200, json: async () => ({ anfragenOffen: anfragen[id] ?? 0 }) }
}
return { ok: true, status: 200, json: async () => ({ projects, ...(verfuegbar === undefined ? {} : { termine: { verfuegbar } }) }) }
}
const count = { textContent: '', hidden: true }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
await initFunktionen(root)
return { html: root.innerHTML, count, calls }
}
test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt bald“ ohne Knopf', async () => {
for (const verfuegbar of [undefined, false]) {
const { html, calls } = await renderTermine([{ ...withTermine('a', 'active'), analyse: noAnalyse }], { verfuegbar })
assert.doesNotMatch(html, /data-fn-tile="termine"/)
assert.match(html, /fn-tile-soon[\s\S]*Terminplaner/)
assert.match(html, /Kommt bald/)
assert.doesNotMatch(html, /data-view="termine"/)
assert.deepEqual(calls, ['/api/funktionen'], 'ohne Schalter keine Abfrage an /api/termine')
}
})
test('Terminplaner-Kachel: Aus ⇒ „Wir schalten den Terminplaner für dich frei.“ + Chat', async () => {
for (const projects of [[{ ...project('a', noAnalyse), features: { news } }], [withTermine('a', 'off')], [withTermine('a', 'ordered')]]) {
const { html } = await renderTermine(projects, { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /Terminplaner/)
assert.match(kachel, /fn-pill-off">Aus</)
assert.match(kachel, /Wir schalten den Terminplaner für dich frei\./)
assert.match(kachel, /data-view="chat"[^>]*>Chat öffnen</)
assert.doesNotMatch(kachel, /data-view="termine"/)
}
})
test('Terminplaner-Kachel: Aktiv ⇒ „Termine öffnen“ mit Projekt, Anfragen aus anfragenOffen', async () => {
const { html, count, calls } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { b: 3 } })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-on">Aktiv</)
assert.match(kachel, /data-view="termine" data-project="b"[^>]*>Termine öffnen</)
assert.deepEqual(calls, ['/api/funktionen', '/api/termine/einstellungen?projectId=b'], 'nur aktive Projekte werden gefragt')
assert.equal(count.textContent, '3 Anfragen warten')
assert.equal(count.hidden, false)
})
test('Terminplaner-Kachel: Anfragen zählen über alle aktiven Websites, 1 ⇒ Einzahl, 0 ⇒ nichts', async () => {
const zwei = await renderTermine([withTermine('a', 'active'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { a: 1, b: 2 } })
assert.equal(zwei.count.textContent, '3 Anfragen warten')
const eine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: { a: 1 } })
assert.equal(eine.count.textContent, '1 Anfrage wartet')
const keine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: {} })
assert.equal(keine.count.textContent, '')
assert.equal(keine.count.hidden, true)
})
test('Terminplaner-Kachel: Fehler bei der Anfragen-Zahl lässt die Kachel stehen', async () => {
globalThis.fetch = async (url) => {
if (String(url).startsWith('/api/termine/')) throw new Error('offline')
return { ok: true, status: 200, json: async () => ({ projects: [withTermine('a', 'active')], termine: { verfuegbar: true } }) }
}
const count = { textContent: '', hidden: true }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
await initFunktionen(root)
assert.match(tile(root.innerHTML, 'termine'), /Termine öffnen/)
assert.equal(count.textContent, '')
assert.equal(count.hidden, true)
})
test('Terminplaner-Kachel: Abgeschaltet ⇒ Pille, Hinweis, Termine nur ansehen', async () => {
const { html, calls } = await renderTermine([withTermine('a', 'disabled')], { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /fn-pill-off">Abgeschaltet</)
assert.match(kachel, /abgeschaltet/)
assert.match(kachel, /data-view="termine" data-project="a"[^>]*>Termine ansehen</)
assert.deepEqual(calls, ['/api/funktionen'], 'abgeschaltet: keine Anfragen-Zahl')
})
test('Terminplaner-Kachel: mehrere Websites, Hinweis „aktiv für n von m“, Aktive zuerst', async () => {
const { html } = await renderTermine([withTermine('a', 'disabled'), withTermine('b', 'off'), withTermine('c', 'active')], { verfuegbar: true })
const kachel = tile(html, 'termine')
assert.match(kachel, /aktiv für 1 von 3 Websites/)
assert.match(kachel, /data-project="c"/)
})
test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => {
const { html } = await renderTermine([{ ...withTermine('a<b>', 'active'), projectName: '<img src=x onerror=1>' }], { verfuegbar: true })
assert.doesNotMatch(html, /<img/)
assert.match(tile(html, 'termine'), /data-project="a&lt;b&gt;"/)
})
// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten)
function fakeEl() {
const handlers = {}
+43 -3
View File
@@ -36,8 +36,11 @@ const MOUNTS = [
['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default],
['/api/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
['/api/termine', (await import('../server/routes/termine.js')).default],
]
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) })
// 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
const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS), '/api/public/termine/v1': oeffentlich })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
@@ -100,14 +103,25 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
})
test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => {
test('Erlaubnisliste: genau News, Terminplaner, 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'],
['POST', '/api/auth/logout'], ['POST', '/api/auth/login'],
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
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']]) {
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 3, 'genau drei Termine-Eintraege dazu')
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
['POST', '/api/termineX'], ['POST', '/api/termineX/buchungen'], ['POST', '/api/termine'], ['POST', '/api/termine/'],
['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'],
['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
]) {
assert.ok(!allowed(m, p), `${m} ${p}`)
}
})
@@ -210,3 +224,29 @@ test('Tab mit Kennung, Cookie ist keine Kundenansicht mehr -> 409 statt fremder
// ohne Cookie: kein fremder Kunde, die Route antwortet selbst mit 401
assert.equal((await call('GET', '/api/projects', { headers: header })).status, 401)
})
// Terminplaner (Aufgabe 16): Gaeste buchen ohne Sitzung; ein Kundenansicht-Cookie im selben Browser (Mitarbeiter testet die
// Website des Kunden) darf ihre POSTs weder sperren noch beenden
test('/api/public/ ist ausgenommen: Kundenansicht-Cookie sperrt oeffentliche POSTs nicht und beendet keine Ansicht', async (t) => {
const vorher = config.termine.minAntwortMs
config.termine.minAntwortMs = 0 // sonst wartet jede Buchungsantwort 300 ms
t.after(() => (config.termine.minAntwortMs = vorher))
const url = '/api/public/termine/v1/abcdefghijklmnop/buchungen'
const faelle = [
['Kundenansicht mit Kennung', kv()],
['Kundenansicht ohne Kennung (sonst 409)', { cookie: kundenansichtCookie() }],
['fremde Kennung (sonst 409)', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } }],
['abgelaufene Ansicht (sonst 401)', kv({ viewExpiresAt: Date.now() - 1000 })],
['alte Ansicht ohne viewId (sonst 401)', { cookie: viewAsCookie('c1') }],
]
for (const [was, opts] of faelle) {
for (const pfad of [url, url.replace('/api/public/', '/API/Public/')]) {
const res = await call('POST', pfad, { ...opts, body: {} })
assert.equal(res.status, 200, `${was} ${pfad}`)
assert.deepEqual(res.body, { aktiv: false }, `${was} ${pfad}`) // Modus off: die Schnittstelle antwortet selbst
assert.equal(clears(res, config.cookieName), false, `${was}: das Portal-Cookie bleibt`)
}
}
// ausserhalb von /api/public/ bleibt die Sperre
assert.equal((await call('POST', '/api/chat/read', kv())).status, 403)
})
+302 -23
View File
@@ -5,27 +5,11 @@ import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '.
import { contractAttachmentFor, termsDocFor } from '../server/services/products.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// 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); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
'K15',
'K16',
'K17',
'K18',
'K19',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
'T15',
'T16',
'T17',
'T18',
]
// 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); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; T18 Bankzugang erneuern (Finanzen)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 21), ...ids('T', 18)]
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
@@ -60,7 +44,9 @@ for (const id of EXPECTED) {
assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/)
assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt')
assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt')
assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
// K und T tragen das WEBklar-Logo; G (Gästemails im Auftrag eines Betriebs) haben ein neutrales Layout ohne
if (/^[KT]/.test(id)) assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
else assert.doesNotMatch(mail.html, /webklar-logo\.png/, 'G-Vorlagen ohne WEBklar-Logo')
assert.doesNotMatch(mail.subject, /\{\{|\n/)
assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities')
}
@@ -148,7 +134,7 @@ test('htmlToText: Links, Buttons und Leerzeilen', () => {
})
test('Vorlagen-Ordner enthält nur Vorlagen', () => {
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), [])
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[GKT]\d{2} .*\.html$/.test(f)), [])
})
test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () => {
@@ -523,3 +509,296 @@ test('T01: Knopf antwortet im Ticketsystem beim Kunden (team_link)', () => {
assert.deepEqual(mail.problems, [])
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/chat\?kunde=c1"[^>]*>Im Ticketsystem antworten &rarr;<\/a>/)
})
// ---------------------------------------------------------------------------
// Terminplaner (Task 9): G01–G08 an Gäste, K20 und K21 an den Betrieb
// ---------------------------------------------------------------------------
const sourceOf = (id) => readFileSync(new URL(loadTemplate(id).file, DIR), 'utf8')
const FOOTER_SENTENCE = 'Diese E-Mail wurde im Auftrag von Wert-betrieb_name über den Terminplaner von WEBklar versendet.'
// Sie-Formen und Du-Formen (der Rechtstext der Widerrufsbelehrung ist ein Platzhalterwert und kommt hier nicht vor)
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
const anrede = (tpl, sie, extra = {}) => ({ ...allBlocks(tpl, true), ...extra, sie, du: !sie })
test('G-Vorlagen: Betreff ist {{betreff}}, Blöcke sie und du, Platzhalter für Farbe und Betriebsfuß', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const block of ['sie', 'du']) assert.ok(tpl.blocks.includes(block), `${id}: Block ${block}`)
for (const p of ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']) {
assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
}
}
})
test('G-Vorlagen: neutrales Layout, Kopf in der Betriebsfarbe, Fuß mit Betrieb, Links und Satz über den Terminplaner', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const source = sourceOf(id)
assert.match(source, /<td[^>]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`)
assert.doesNotMatch(source, /<img|webklar-logo|project\.webklar\.com/i, `${id}: kein Bild, kein WEBklar-Logo, keine Portal-Adresse`)
assert.doesNotMatch(source, /https?:\/\/(?!www\.w3\.org)|google|tracking|pixel/i, `${id}: keine festen Adressen, keine Werbung, kein Tracking`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, on), sie: true, du: false })
assert.deepEqual(mail.problems, [], id)
assert.ok(mail.text.includes(FOOTER_SENTENCE), `${id}: Fußsatz\n${mail.text}`)
for (const wert of ['Wert-betrieb_adresse', 'Wert-betrieb_telefon', 'Wert-betrieb_email']) assert.ok(mail.text.includes(wert), `${id}: ${wert}`)
assert.match(mail.html, /href="Wert-impressum_url"[^>]*>Impressum</, id)
assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz</, id)
assert.match(mail.html, /background:Wert-farbe/, id)
}
}
})
test('G-Vorlagen: jeder Anredetext steht in sie und du, Du-Mail ohne Sie-Formen und Sie-Mail ohne Du-Formen', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const du = renderMail(id, sampleValues(tpl), anrede(tpl, false))
const sie = renderMail(id, sampleValues(tpl), anrede(tpl, true))
assert.deepEqual([du.problems, sie.problems], [[], []], id)
assert.doesNotMatch(du.text, SIE_FORM, `${id}: Du-Fassung enthält Sie-Form\n${du.text}`)
assert.doesNotMatch(du.text, / Sie | Ihr /, id)
assert.doesNotMatch(sie.text, DU_FORM, `${id}: Sie-Fassung enthält Du-Form\n${sie.text}`)
assert.match(sie.text, SIE_FORM, `${id}: Sie-Fassung spricht den Gast an`)
assert.match(du.text, DU_FORM, `${id}: Du-Fassung spricht den Gast an`)
assert.notEqual(du.text, sie.text, id)
// ohne gesetzte Anrede erscheint kein Anredetext (der Server setzt genau einen der beiden Blöcke)
const neither = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: false })
assert.doesNotMatch(neither.text, SIE_FORM, id)
assert.doesNotMatch(neither.text, DU_FORM, id)
}
})
test('G02: Du-Mail enthält im Text kein „ Sie “ und kein „Ihr “', () => {
const tpl = loadTemplate('G02')
const mail = renderMail('G02', sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: true })
assert.deepEqual(mail.problems, [])
assert.doesNotMatch(mail.text, / Sie |Ihr /)
assert.doesNotMatch(mail.html, / Sie | Ihr /)
})
test('G01 und G08 enthalten keinen vom Gast eingegebenen Text (kein Platzhalter gast_name)', () => {
for (const id of ['G01', 'G08']) {
assert.ok(!loadTemplate(id).placeholders.includes('gast_name'), `${id}: Platzhalter`)
assert.ok(!sourceOf(id).includes('gast_name'), `${id}: Quelltext`)
}
// ... die anderen Gästemails nennen den Gast beim Namen
for (const id of ['G02', 'G03', 'G04', 'G05', 'G06', 'G07']) assert.ok(loadTemplate(id).placeholders.includes('gast_name'), id)
})
test('G-Vorlagen: Platzhalter und Blöcke wie im Plan', () => {
const common = ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']
const expected = {
G01: { p: ['leistung', 'datum', 'zeit', 'link'], b: ['bestaetigen', 'zu_viele'] },
G02: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'dauer', 'preis', 'zahlung', 'nummer', 'link', 'ort', 'storno_text', 'widerrufsbelehrung', 'widerruf_link'], b: ['preis', 'person', 'storno', 'widerruf', 'kein_widerruf', 'anfrage_angenommen'] },
G03: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'link'], b: [] },
G04: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'nachricht'], b: ['nachricht'] },
G05: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'link'], b: ['person'] },
G06: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'absage_von', 'grund', 'nachricht'], b: ['grund', 'nachricht'] },
G07: { p: ['gast_name', 'leistung', 'alt_datum', 'alt_zeit', 'datum', 'zeit', 'link'], b: [] },
G08: { p: ['eingang', 'nummer'], b: ['zugeordnet', 'nach_frist'] },
}
for (const [id, { p, b }] of Object.entries(expected)) {
const tpl = loadTemplate(id)
assert.deepEqual([...tpl.placeholders].sort(), [...common, ...p].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), ['du', 'sie', ...b].sort(), `${id}: Blöcke`)
}
})
test('G01: Block bestaetigen mit Angaben und Knopf, Block zu_viele nur mit eigenem Text und ohne Knopf', () => {
const tpl = loadTemplate('G01')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', betrieb_telefon: '06301 1234', leistung: 'Herrenschnitt', datum: 'Montag, 12.10.2026', zeit: '10:00', link: 'https://project.webklar.com/termin.html#v=abc' }
const sie = renderMail('G01', values, { sie: true, bestaetigen: true })
assert.deepEqual(sie.problems, [])
assert.match(sie.text, /Leistung: Herrenschnitt/)
assert.match(sie.text, /Datum: Montag, 12\.10\.2026/)
assert.match(sie.text, /Uhrzeit: 10:00 Uhr/)
assert.match(sie.text, /Termin bestätigen: https:\/\/project\.webklar\.com\/termin\.html#v=abc/)
assert.match(sie.text, /Das waren Sie nicht\? Dann ignorieren Sie diese E-Mail\./)
assert.doesNotMatch(sie.text, /höchstmögliche Zahl|rufen Sie uns an/)
assert.match(renderMail('G01', values, { du: true, bestaetigen: true }).text, /Das warst du nicht\? Dann ignoriere diese E-Mail\./)
// zu_viele: nichts reserviert, also weder Terminangaben noch Knopf noch Link
const viele = renderMail('G01', values, { sie: true, zu_viele: true })
assert.deepEqual(viele.problems, [])
assert.match(viele.text, /bereits die höchstmögliche Zahl offener Termine/)
assert.match(viele.text, /sagen Sie einen Ihrer offenen Termine ab/)
assert.match(viele.text, /rufen Sie uns an: 06301 1234\./)
assert.doesNotMatch(viele.text, /Herrenschnitt|12\.10\.2026|Uhrzeit|Termin bestätigen|termin\.html|ignorieren Sie den Knopf|Knopf/)
assert.doesNotMatch(viele.html, /termin\.html|Herrenschnitt/)
assert.match(renderMail('G01', values, { du: true, zu_viele: true }).text, /ruf uns an: 06301 1234\./)
// ohne gesetzten Block bleibt nur der gemeinsame Rahmen
const leer = renderMail('G01', values, { sie: true })
assert.doesNotMatch(leer.text, /Herrenschnitt|höchstmögliche Zahl|Termin bestätigen/)
})
test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf nur mit ihren Blöcken', () => {
const tpl = loadTemplate('G02')
const belehrung = 'Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'
const values = {
...sampleValues(tpl),
gast_name: 'Erika Muster', leistung: 'Herrenschnitt', person: 'Anna', datum: 'Montag, 12.10.2026', zeit: '10:00', dauer: '30 Minuten',
preis: '28,00 € inkl. USt', zahlung: 'Bezahlung vor Ort', nummer: 'AB12CD34', link: 'https://project.webklar.com/termin.html#m=tok', ort: 'Hauptstr. 1, 67722 Winnweiler',
storno_text: 'Absage bis 24 Stunden vorher kostenlos.', widerrufsbelehrung: belehrung, widerruf_link: 'https://project.webklar.com/widerrufen.html#m=tok',
}
const basis = renderMail('G02', values, { sie: true })
assert.deepEqual(basis.problems, [])
for (const zeile of ['Guten Tag Erika Muster,', 'Leistung: Herrenschnitt', 'Datum: Montag, 12.10.2026', 'Uhrzeit: 10:00 Uhr', 'Dauer: 30 Minuten', 'Ort: Hauptstr. 1, 67722 Winnweiler', 'Buchungsnummer: AB12CD34']) {
assert.ok(basis.text.split('\n').includes(zeile), `Zeile fehlt: ${zeile}\n${basis.text}`)
}
assert.match(basis.text, /Termin verwalten: https:\/\/project\.webklar\.com\/termin\.html#m=tok/)
assert.match(basis.text, /Ihr Termin ist bestätigt/)
assert.doesNotMatch(basis.text, /28,00|Bezahlung vor Ort|Anna|Absage bis 24 Stunden|Widerruf|angenommen/)
const voll = renderMail('G02', values, { sie: true, preis: true, person: true, storno: true, widerruf: true, anfrage_angenommen: true })
assert.deepEqual(voll.problems, [])
assert.match(voll.text, /Preis: 28,00 € inkl\. USt/)
assert.match(voll.text, /Zahlung: Bezahlung vor Ort/)
assert.match(voll.text, /Anna/)
assert.match(voll.text, /Absage bis 24 Stunden vorher kostenlos\./)
assert.match(voll.text, /Wert-betrieb_name hat Ihre Terminanfrage angenommen/)
assert.match(voll.text, /Vertrag widerrufen: https:\/\/project\.webklar\.com\/widerrufen\.html#m=tok/)
// Ruling R55/Abschlussrunde: der Knopf geht nur bis zum Beginn des Termins, danach Formular auf der Website oder E-Mail
const WEG = 'Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von Wert-betrieb_name oder per E-Mail an Wert-betrieb_email.'
assert.ok(voll.text.includes(WEG), voll.text)
assert.doesNotMatch(voll.text, /jederzeit den Knopf/)
// Belehrung wörtlich, Zeilenumbrüche bleiben, Sonderzeichen sind maskiert
assert.match(voll.html, /Widerrufsrecht: 14 Tage &lt;b&gt;ohne&lt;\/b&gt; Gründe\.<br>Zweite Zeile &amp; mehr/)
assert.ok(voll.text.includes('Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'), voll.text)
assert.doesNotMatch(voll.text, /kein Widerrufsrecht/, 'widerruf und kein_widerruf schließen sich aus')
// der Wortlaut der Belehrung steht in einem abgesetzten Kasten
assert.match(voll.html, /class="box"[^>]*><tr><td[^>]*>Widerrufsbelehrung<br><br>Widerrufsrecht/)
// Freizeitausnahme: Hinweis statt Widerrufsrecht
const kein = renderMail('G02', values, { sie: true, kein_widerruf: true })
assert.deepEqual(kein.problems, [])
assert.ok(kein.text.includes('Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.'), kein.text)
assert.doesNotMatch(kein.text, /Vertrag widerrufen|Widerrufsbelehrung|Muster-Widerrufsformular/)
const keinDu = renderMail('G02', values, { du: true, kein_widerruf: true })
assert.ok(keinDu.text.includes('Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.'), keinDu.text)
assert.doesNotMatch(keinDu.text, SIE_FORM)
assert.doesNotMatch(basis.text, /kein Widerrufsrecht/)
const du = renderMail('G02', values, { du: true, anfrage_angenommen: true, widerruf: true })
assert.match(du.text, /Dein Termin ist bestätigt/)
assert.match(du.text, /hat deine Terminanfrage angenommen/)
assert.ok(du.text.includes(WEG), du.text)
assert.doesNotMatch(du.text, /jederzeit/)
assert.doesNotMatch(du.text, SIE_FORM)
})
test('G03, G04, G05, G06, G07: Inhalt je Mail', () => {
const g03 = renderMail('G03', { ...sampleValues(loadTemplate('G03')), gast_name: 'Erika', betrieb_name: 'Salon Muster', link: 'https://x.example/m' }, { sie: true })
assert.match(g03.text, /Salon Muster prüft sie und meldet sich bei Ihnen/)
assert.match(g03.text, /Anfrage absagen: https:\/\/x\.example\/m/)
const t04 = loadTemplate('G04')
const g04 = renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true })
assert.doesNotMatch(g04.text, /Urlaub/)
assert.match(g04.text, /leider kann Wert-betrieb_name Ihre Terminanfrage nicht annehmen/)
assert.match(renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true, nachricht: true }).text, /Am Montag sind wir leider im Urlaub\./)
const t05 = loadTemplate('G05')
const g05 = renderMail('G05', { ...sampleValues(t05), person: 'Anna', link: 'https://x.example/m' }, { du: true, person: true })
assert.match(g05.text, /Anna/)
assert.match(g05.text, /Termin verwalten: https:\/\/x\.example\/m/)
assert.match(g05.text, /Erinnerung/)
assert.doesNotMatch(renderMail('G05', { ...sampleValues(t05), person: 'Anna' }, { du: true }).text, /Anna/)
const t06 = loadTemplate('G06')
const werte06 = { ...sampleValues(t06), absage_von: 'dem Betrieb', grund: 'Krankheit im Team', nachricht: 'Wir melden uns wegen eines neuen Termins.' }
const g06 = renderMail('G06', werte06, { sie: true })
assert.match(g06.text, /Abgesagt von: dem Betrieb/)
assert.doesNotMatch(g06.text, /Krankheit|Grund:|neuen Termins/)
const g06grund = renderMail('G06', werte06, { sie: true, grund: true })
assert.match(g06grund.text, /Grund: Krankheit im Team/)
assert.doesNotMatch(g06grund.text, /neuen Termins/)
const g06nachricht = renderMail('G06', werte06, { sie: true, nachricht: true })
assert.match(g06nachricht.text, /Nachricht von Wert-betrieb_name\s+Wir melden uns wegen eines neuen Termins\./)
assert.doesNotMatch(g06nachricht.text, /Krankheit/)
const beide = renderMail('G06', werte06, { sie: true, grund: true, nachricht: true })
assert.deepEqual(beide.problems, [])
assert.match(beide.text, /Krankheit im Team/)
assert.match(beide.text, /neuen Termins/)
const t07 = loadTemplate('G07')
const g07 = renderMail('G07', { ...sampleValues(t07), alt_datum: '12.10.2026', alt_zeit: '10:00', datum: '13.10.2026', zeit: '11:30' }, { du: true })
assert.match(g07.text, /Bisher: 12\.10\.2026, 10:00 Uhr/)
assert.match(g07.text, /Neu: 13\.10\.2026, 11:30 Uhr/)
})
test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und nach_frist, nichts vom Gast', () => {
const tpl = loadTemplate('G08')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', eingang: '02.10.2026, 10:07 Uhr', nummer: 'AB12CD34' }
const ohne = renderMail('G08', values, { sie: true })
assert.deepEqual(ohne.problems, [])
assert.match(ohne.text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(ohne.text, /Widerruf/)
assert.doesNotMatch(ohne.text, /AB12CD34|bereits abgelaufen/)
const zugeordnet = renderMail('G08', values, { sie: true, zugeordnet: true })
assert.match(zugeordnet.text, /Buchung AB12CD34/)
assert.match(zugeordnet.text, /Der Termin ist abgesagt\./)
assert.doesNotMatch(zugeordnet.text, /bereits abgelaufen/)
assert.doesNotMatch(zugeordnet.text, /keiner Buchung zuordnen/, 'zugeordnet widerspricht sich nicht selbst')
const spaet = renderMail('G08', values, { sie: true, nach_frist: true })
assert.match(spaet.text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen\./)
assert.doesNotMatch(spaet.text, /Der Termin ist abgesagt|AB12CD34|keiner Buchung zuordnen/)
// der Satz für alle Fälle ist in jedem Zustand wahr, in beiden Anreden
for (const blocks of [{}, { zugeordnet: true }, { nach_frist: true }]) {
assert.match(renderMail('G08', values, { sie: true, ...blocks }).text, /Salon Muster prüft Ihre Erklärung und meldet sich bei Ihnen, falls noch etwas zu klären ist\./)
assert.match(renderMail('G08', values, { du: true, ...blocks }).text, /Salon Muster prüft deine Erklärung und meldet sich bei dir, falls noch etwas zu klären ist\./)
}
assert.match(renderMail('G08', values, { du: true, nach_frist: true }).text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang deiner Erklärung bereits abgelaufen\./)
// Hinweis für Dritte: wer nichts erklärt hat, kann die Mail ignorieren
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
})
test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fall', () => {
for (const id of ['K20', 'K21']) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const p of ['betreff', 'kunde_name', 'leistung', 'person', 'datum', 'zeit', 'gast_name', 'gast_email', 'gast_telefon', 'gast_nachricht', 'portal_link']) assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
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))
assert.doesNotMatch(mail.text, SIE_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, /Hallo Wert-kunde_name,/, id)
assert.match(mail.html, /<a href="Wert-portal_link"/, id)
assert.doesNotMatch(sourceOf(id), /google|tracking|pixel/i, id)
}
assert.deepEqual([...loadTemplate('K20').blocks], ['anfrage'])
assert.deepEqual([...loadTemplate('K21').blocks].sort(), ['abgesagt', 'begonnen', 'nach_frist', 'nicht_zugeordnet', 'verschoben', 'widerrufen'])
for (const p of ['alt_datum', 'alt_zeit', 'eingang']) assert.ok(loadTemplate('K21').placeholders.includes(p), `K21: ${p}`)
const t20 = loadTemplate('K20')
const werte20 = { ...sampleValues(t20), kunde_name: 'Berta', leistung: 'Herrenschnitt', datum: '12.10.2026', zeit: '10:00', gast_name: 'Max <b>Muster</b>', gast_email: 'max@example.com', gast_telefon: '0170 1', gast_nachricht: 'Bitte\nFenster', portal_link: 'https://project.webklar.com/dashboard.html' }
const buchung = renderMail('K20', werte20, {})
assert.deepEqual(buchung.problems, [])
assert.match(buchung.html, /Max &lt;b&gt;Muster&lt;\/b&gt;/)
assert.match(buchung.html, /Bitte<br>Fenster/)
assert.match(buchung.text, /E-Mail: max@example\.com/)
assert.match(buchung.text, /Im Portal ansehen: https:\/\/project\.webklar\.com\/dashboard\.html/)
assert.doesNotMatch(buchung.text, /noch nicht bestätigt/)
assert.match(renderMail('K20', werte20, { anfrage: true }).text, /Diese Anfrage ist noch nicht bestätigt/)
const t21 = loadTemplate('K21')
const werte21 = { ...sampleValues(t21), alt_datum: '11.10.2026', alt_zeit: '09:00', datum: '12.10.2026', zeit: '10:00', eingang: '02.10.2026, 10:07 Uhr' }
const fall = (block) => renderMail('K21', werte21, { [block]: true })
assert.match(fall('abgesagt').text, /hat den Termin abgesagt/)
assert.match(fall('verschoben').text, /Bisher: 11\.10\.2026, 09:00 Uhr/)
assert.match(fall('verschoben').text, /Neu: 12\.10\.2026, 10:00 Uhr/)
assert.match(fall('widerrufen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(fall('widerrufen').text, /widerrufen/)
assert.match(fall('nach_frist').text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen/)
assert.match(fall('begonnen').text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\. Bitte prüfe, ob das Widerrufsrecht schon erloschen ist \(vollständige Leistung nach Zustimmung zum vorzeitigen Beginn\) oder ob Wertersatz in Frage kommt\./)
assert.match(fall('begonnen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(fall('nicht_zugeordnet').text, /keiner Buchung zuordnen/)
// je Fall erscheint nur der eigene Abschnitt
const SATZ = { abgesagt: 'hat den Termin abgesagt', verschoben: 'hat den Termin verschoben', widerrufen: 'hat den Vertrag widerrufen', nach_frist: 'Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen', begonnen: 'nachdem der Termin bereits begonnen hatte', nicht_zugeordnet: 'keiner Buchung zuordnen' }
for (const block of Object.keys(SATZ)) {
const mail = fall(block)
assert.deepEqual(mail.problems, [], block)
for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`)
}
})
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import { readFileSync } from 'node:fs'
import express from 'express'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlermiddleware mit: hier haengt er an einer nackten
// express()-App ohne globalen Parser und ohne eigene Fehlerbehandlung. Was beim Gast ankommt, kommt nur vom Router.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { default: router, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
const app = express()
app.use('/api/public/termine/v1', router)
const server = http.createServer(app)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const PORT = server.address().port
after(() => Promise.all([new Promise((resolve) => server.close(resolve)), aw.close()]))
const SITE_KEY = 'abcdefghijklmnop'
const PFAD = `/api/public/termine/v1/${SITE_KEY}/buchungen`
const MODUS_VORHER = config.termine.mode
const MIN_ANTWORT_VORHER = config.termine.minAntwortMs
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
beforeEach(() => {
resetTermineLimitsForTests()
resetKontextForTests()
resetPublicTermineForTests()
config.termine.mode = 'pilot'
config.termine.minAntwortMs = 0
mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
after(() => {
config.termine.mode = MODUS_VORHER
config.termine.minAntwortMs = MIN_ANTWORT_VORHER
})
/** Roher Aufruf mit Chunks (ohne content-length) oder festem Body; => { status, text, headers }. */
function roh({ pfad = PFAD, method = 'POST', headers = {}, chunks = [] } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port: PORT, path: pfad, method, headers }, (res) => {
let text = ''
res.setEncoding('utf8')
res.on('data', (d) => (text += d))
res.on('end', () => resolve({ status: res.statusCode, text, headers: res.headers }))
})
req.on('error', reject)
for (const chunk of chunks) req.write(chunk)
req.end()
})
}
test('20 KB ohne content-length (chunked): 413 vom Router-Parser, ohne Rohtext', async () => {
const json = `{"name":"${'x'.repeat(20 * 1024)}"}`
const teile = json.match(/[\s\S]{1,2000}/g)
const res = await roh({ headers: { 'content-type': 'application/json', 'transfer-encoding': 'chunked' }, chunks: teile })
assert.equal(res.status, 413)
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
assert.match(res.headers['content-type'], /^application\/json/)
assert.equal(res.headers['cache-control'], 'no-store')
assert.ok(!res.text.includes('xxxx'))
})
test('content-length ueber 16 384: 413 vorab, auch fuer einen Body, der gar nicht mehr geschickt wird', async () => {
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': '16385' }, chunks: [] }).catch((err) => err)
// der Server antwortet, bevor der Rumpf kommt; der Client darf dabei zurueckgesetzt werden
if (res instanceof Error) assert.match(res.code, /ECONNRESET|EPIPE|ECONNABORTED/)
else {
assert.equal(res.status, 413)
assert.deepEqual(JSON.parse(res.text), ZU_GROSS)
}
const gross = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ a: 'y'.repeat(16400) }) })
assert.equal(gross.status, 413)
assert.deepEqual(await gross.json(), ZU_GROSS)
})
test('genau 16 384 Bytes sind noch erlaubt', async () => {
const kopf = '{"kontakt_url":"'
const ende = '"}'
const fuell = 'z'.repeat(16384 - kopf.length - ende.length)
const body = kopf + fuell + ende
assert.equal(Buffer.byteLength(body), 16384)
const res = await fetch(`http://127.0.0.1:${PORT}${PFAD}`, { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://salon.de' }, body })
// nicht 413: der Router liest den Rumpf; der siteKey ist hier unbekannt (nichts gespeichert), also { aktiv: false }
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { aktiv: false })
})
test('kaputtes JSON: 400 mit fester Meldung, nie der Text des Parsers', async () => {
for (const body of ['{"name": ', '{"a":}', 'nicht json', '{"a":1}}', '[1,2', "{'a':1}"]) {
const res = await roh({ headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body)) }, chunks: [body] })
assert.equal(res.status, 400, body)
assert.deepEqual(JSON.parse(res.text), UNGUELTIG, body)
assert.doesNotMatch(res.text, /Unexpected|JSON|position|token|SyntaxError|at /i, body)
}
// dasselbe auf den Token-Seiten
const token = await roh({ pfad: '/api/public/termine/v1/termin/vorschau', headers: { 'content-type': 'application/json' }, chunks: ['{"token":'] })
assert.equal(token.status, 400)
assert.deepEqual(JSON.parse(token.text), UNGUELTIG)
})
test('unbekannte Kodierung, falscher Zeichensatz und abgebrochene Anfragen: 4xx als { fehler }, kein Stacktrace', async () => {
const gzip = await roh({ headers: { 'content-type': 'application/json', 'content-encoding': 'br', 'content-length': '2' }, chunks: ['{}'] })
assert.equal(gzip.status, 400)
assert.deepEqual(JSON.parse(gzip.text), UNGUELTIG)
const zeichensatz = await roh({ headers: { 'content-type': 'application/json; charset=iso-8859-1', 'content-length': '2' }, chunks: ['{}'] })
assert.equal(zeichensatz.status, 400)
assert.deepEqual(JSON.parse(zeichensatz.text), UNGUELTIG)
})
test('interner Fehler: 503 { fehler } ohne err.message, auch ohne Fehlerbehandlung der App', async () => {
const RAW = 'Nachbau kaputt: geheimer Rohtext'
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: RAW })
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/${SITE_KEY}/config`)
assert.equal(res.status, 503)
const text = await res.text()
assert.deepEqual(JSON.parse(text), { fehler: { _: 'Gerade nicht erreichbar' } })
assert.ok(!text.includes('Nachbau') && !text.includes('geheimer'))
})
test('Router und App: der Router beantwortet alles unter seinem Pfad selbst (404 als { fehler }), die App darunter sieht nichts', async () => {
const res = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v1/irgendwas/unbekannt`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"a":1}' })
assert.equal(res.status, 404)
assert.deepEqual(await res.json(), { fehler: { _: 'Nicht gefunden' } })
// ausserhalb des Pfades antwortet Express selbst
const aussen = await fetch(`http://127.0.0.1:${PORT}/api/public/termine/v2/x`)
assert.equal(aussen.status, 404)
})
test('Reihenfolge in server/index.js: kundenansichtGuard, dann der Router der oeffentlichen Schnittstelle, dann der globale express.json', () => {
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
const guard = quelle.indexOf('app.use(kundenansichtGuard)')
const oeffentlich = quelle.indexOf("app.use('/api/public/termine/v1'")
const global = quelle.indexOf('app.use(express.json(')
assert.ok(guard >= 0 && oeffentlich >= 0 && global >= 0, 'alle drei Aufrufe gefunden')
assert.equal(quelle.indexOf("app.use('/api/public/termine/v1'", oeffentlich + 1), -1, 'nur einmal eingehaengt')
assert.ok(guard < oeffentlich, 'erst der Waechter der Kundenansicht')
assert.ok(oeffentlich < global, 'vor dem globalen Parser (2 MB)')
assert.match(quelle, /import publicTermineRoutes from '\.\/routes\/publicTermine\.js'/)
})
+102
View File
@@ -162,3 +162,105 @@ test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
assert.equal(tickets().length, ticketsBefore)
}
})
// ---------------------------------------------------------------- Terminplaner als zweite Funktion ---
test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
// erst hier angelegt: die aelteren Tests oben zaehlen die Projekte von c1
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'])
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
})
})
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'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
})
test('termine_wird_aktiviert_und_bekommt_eigenen_sitekey_und_eigenes_dokument', async () => {
const doc = await sf.adminActivate('c1', 'pt1', 'termine', { by: 'kenso' })
assert.equal(doc.featureKey, 'termine')
assert.equal(doc.state, 'active')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(await sf.getFeatureDoc('c1', 'pt1', 'news'), null, 'News bleibt unberuehrt')
const view = (await sf.listCustomerFeatures('c1')).find((x) => x.projectId === 'pt1').features
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.siteKey, doc.siteKey)
assert.equal(view.news.state, 'off')
await assert.rejects(() => sf.adminActivate('c1', 'pt1', 'kalender', {}), { status: 400 })
})
test('findActiveBySiteKey: Vorgabe ist news, termine nur mit zweitem Argument, nie der andere Schluessel', async () => {
const termine = await sf.getFeatureDoc('c1', 'pt1', 'termine')
assert.equal((await sf.findActiveBySiteKey(termine.siteKey, 'termine')).$id, termine.$id)
assert.equal(await sf.findActiveBySiteKey(termine.siteKey), null, 'ohne zweites Argument = news')
assert.equal(await sf.findActiveBySiteKey(termine.siteKey, 'news'), null)
const news = await sf.adminActivate('c1', 'pt2', 'news', { by: 'kenso' })
assert.equal((await sf.findActiveBySiteKey(news.siteKey)).$id, news.$id)
assert.equal((await sf.findActiveBySiteKey(news.siteKey, 'news')).$id, news.$id)
assert.equal(await sf.findActiveBySiteKey(news.siteKey, 'termine'), null)
assert.equal(await sf.findActiveBySiteKey('zzzzzzzzzzzzzzzz', 'termine'), null)
assert.equal(await sf.findActiveBySiteKey('KURZ', 'termine'), null)
})
test('abgeschaltet_ist_nicht_auffindbar_und_wieder_aktivieren_leert_disabledAt (R36)', async () => {
const doc = await sf.getFeatureDoc('c1', 'pt1', 'termine')
const off = await sf.disable(doc.$id, { by: 'kenso' })
assert.equal(off.state, 'disabled')
assert.ok(Math.abs(Date.parse(off.disabledAt) - Date.now()) < 5000)
assert.equal(await sf.findActiveBySiteKey(doc.siteKey, 'termine'), null)
const on = await sf.activate(doc.$id, { by: 'kenso' })
assert.equal(on.state, 'active')
assert.equal(on.disabledAt, null, 'sonst koennte der 30-Tage-Loeschlauf eine wieder eingeschaltete Funktion treffen')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, null)
assert.equal((await sf.findActiveBySiteKey(doc.siteKey, 'termine')).$id, doc.$id)
})
test('activate gilt fuer jede Funktion gleich: News verliert disabledAt ebenfalls', async () => {
const news = await sf.getFeatureDoc('c1', 'pt2', 'news')
assert.equal((await sf.disable(news.$id, {})).state, 'disabled')
assert.equal((await sf.activate(news.$id, {})).disabledAt, null)
})
test('activateClosedTickets bleibt bei News (TP2): ein termine-Dokument im Zustand setup wird nicht angefasst', async () => {
aw.seed('portalFeatures', { $id: 'f_setup_termine', customerId: 'c1', projectId: 'pt2', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'bbbbbbbbbbbbbbbb', ticketId: 't_closed' })
aw.seed('workorders', { $id: 't_closed', woid: 'WO-9', status: 'Closed' })
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), [])
assert.equal(aw.docs('portalFeatures').get('f_setup_termine').state, 'setup')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { config } = await import('../server/config.js')
await deleteDocument(config.collections.portalFeatures, 'f_setup_termine')
})
test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab, mit Notiz', async () => {
const mine = await sf.getFeatureDoc('c1', 'pt1', 'termine')
const other = await sf.adminActivate('c2', 'pt3', 'termine', { by: 'kenso' })
const news = await sf.getFeatureDoc('c1', 'pt2', 'news')
await sf.activate(news.$id, {})
const done = await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' })
assert.deepEqual(done.map((d) => d.$id), [mine.$id])
const after = await sf.getFeatureDoc('c1', 'pt1', 'termine')
assert.equal(after.state, 'disabled')
assert.equal(after.enabled, false)
assert.equal(after.adminNote, 'Kunde gelöscht')
assert.ok(after.disabledAt)
assert.equal((await sf.getFeatureDoc('c2', 'pt3', 'termine')).state, 'active', 'anderer Kunde')
assert.equal(other.state, 'active')
assert.equal((await sf.getFeatureDoc('c1', 'pt2', 'news')).state, 'active', 'andere Funktion')
// schon abgeschaltet: disabledAt (Beginn der 30 Tage) bleibt
const marker = after.disabledAt
assert.deepEqual(await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' }), [])
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, marker)
await assert.rejects(() => sf.disableCustomerFeatures('c1', 'kalender', {}), { status: 400 })
})
+128 -17
View File
@@ -8,8 +8,19 @@ import http from 'node:http'
* Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt");
* after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert).
*
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
* nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400,
* dann ist nichts geschrieben. Abfragen: equal, greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/
* orderDesc, limit, cursorAfter. Zwei Werte, die beide wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte
* (Schreibweise Z, +00:00, +02:00 ist egal), alle anderen wie bisher.
*
* Optionen (ohne sie verhaelt sich der Nachbau wie zuvor):
* datetimes { collection: ['feld', ...] } Datumsfelder; beim Schreiben (Anlegen, Aendern, Bulk, seed) wie
* Appwrite auf "...000+00:00" (UTC) normalisiert, '' oder Unlesbares => 400, null bleibt erlaubt
* defaultLimit Zahl: gilt ohne limit-Abfrage wie Appwrites 25 (Termine-Tests: 25), null = unbegrenzt
*/
export async function startAppwriteMock({ schema = {} } = {}) {
export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLimit = null } = {}) {
const collections = new Map()
let sequence = 0
let pendingFailure = null
@@ -34,6 +45,8 @@ export async function startAppwriteMock({ schema = {} } = {}) {
},
]
const documentExists = [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
const docs = (collection) => {
if (!collections.has(collection)) collections.set(collection, new Map())
return collections.get(collection)
@@ -46,9 +59,62 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const seed = (collection, doc) => {
if (!doc?.$id) throw new Error('seed: $id fehlt')
docs(collection).set(doc.$id, stamp(doc, collection))
const [normalized, badDate] = normalizeDates(collection, doc)
if (badDate) throw new Error(`seed: ${collection}.${badDate} ist kein gueltiges Datum`)
docs(collection).set(doc.$id, stamp(normalized, collection))
}
// Zeitpunkt eines Textes wie "2026-10-05T10:00:00Z" (ohne Zonenangabe gilt UTC); NaN, wenn es keiner ist
const isoStart = /^\d{4}-\d\d-\d\dT/
const hasZone = /(?:Z|[+-]\d\d(?::?\d\d)?)$/i
const instant = (value) => (typeof value === 'string' && isoStart.test(value) ? Date.parse(hasZone.test(value) ? value : `${value}Z`) : Number.NaN)
// Beide Seiten Zeitpunkte => deren Millisekunden, sonst die Werte selbst (Vergleich wie bisher)
const comparable = (a, b) => {
const x = instant(a)
const y = instant(b)
return Number.isNaN(x) || Number.isNaN(y) ? [a, b] : [x, y]
}
const same = (a, b) => {
const [x, y] = comparable(a, b)
return x === y
}
const greater = (a, b) => {
const [x, y] = comparable(a, b)
return x > y
}
const less = (a, b) => {
const [x, y] = comparable(a, b)
return x < y
}
const atLeast = (a, b) => {
const [x, y] = comparable(a, b)
return x >= y
}
const atMost = (a, b) => {
const [x, y] = comparable(a, b)
return x <= y
}
// Datumsfelder der Collection wie Appwrite speichern: UTC mit Millisekunden und "+00:00". Liefert
// [Daten, Schluessel des ersten ungueltigen Datums | null]
const normalizeDates = (collection, data) => {
const keys = datetimes[collection]
if (!keys?.length) return [data, null]
const out = { ...data }
for (const key of keys) {
const value = out[key]
if (value === undefined || value === null) continue
let ms = Number.NaN
if (value instanceof Date) ms = value.getTime()
else if (typeof value === 'string') ms = isoStart.test(value) ? instant(value) : /^\d{4}-\d\d-\d\d$/.test(value) ? Date.parse(value) : Number.NaN
if (Number.isNaN(ms)) return [data, key]
out[key] = new Date(ms).toISOString().replace('Z', '+00:00')
}
return [out, null]
}
const invalidDate = (key) => [400, { message: `Invalid document structure: Attribute "${key}" has invalid format. Value must be a valid date`, code: 400, type: 'document_invalid_structure' }]
const unknownAttribute = (collection, data) => {
const allowed = schema[collection]
if (!allowed) return null
@@ -57,34 +123,48 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const matches = (doc, query) => {
const value = doc[query.attribute]
const [first, last] = query.values || []
switch (query.method) {
case 'equal':
return query.values.includes(value)
return query.values.some((candidate) => same(value, candidate))
case 'greaterThan':
return value > query.values[0]
return greater(value, first)
case 'greaterThanEqual':
return atLeast(value, first)
case 'lessThan':
return value < query.values[0]
return less(value, first)
case 'lessThanEqual':
return atMost(value, first)
case 'between':
return atLeast(value, first) && atMost(value, last)
default:
return true
}
}
const supportedQueries = ['equal', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', 'between', 'orderAsc', 'orderDesc', 'limit', 'cursorAfter']
const list = (collection, rawQueries) => {
const queries = rawQueries.map((q) => JSON.parse(q))
const unsupported = queries.find((q) => !['equal', 'greaterThan', 'lessThan', 'orderAsc', 'orderDesc', 'limit'].includes(q.method))
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
let result = [...docs(collection).values()].filter((doc) => queries.every((q) => matches(doc, q)))
// cursorAfter: das Dokument mit dieser ID muss es geben (es muss aber nicht zu den Filtern passen)
const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0]
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
return [400, { message: 'The cursor is invalid. This can happen if the item represented by the cursor has been deleted.', code: 400, type: 'general_cursor_not_found' }]
}
let result = [...docs(collection).values()].filter((doc) => doc.$id === cursorId || queries.every((q) => matches(doc, q)))
for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) {
const dir = q.method === 'orderAsc' ? 1 : -1
result.sort((a, b) => {
const x = a[q.attribute]
const y = b[q.attribute]
const [x, y] = comparable(a[q.attribute], b[q.attribute])
if (x === y) return (a.$seq - b.$seq) * dir
return (x > y ? 1 : -1) * dir
})
}
const limit = queries.find((q) => q.method === 'limit')
if (limit) result = result.slice(0, limit.values[0])
if (cursorId !== undefined) result = result.slice(result.findIndex((doc) => doc.$id === cursorId) + 1)
const limit = queries.find((q) => q.method === 'limit')?.values[0] ?? defaultLimit
if (limit !== null) result = result.slice(0, limit)
return [200, { total: result.length, documents: result.map(publicDoc) }]
}
@@ -162,6 +242,33 @@ export async function startAppwriteMock({ schema = {} } = {}) {
return null
}
// Bulk-Create (Appwrite 1.8.1, API-Schluessel): alles oder nichts. Erst jedes Dokument pruefen (400), dann auf
// vorhandene oder doppelte IDs (409); geschrieben wird erst, wenn alles durch ist. Anders als Appwrite (das eine
// fehlende $id selbst vergibt) verlangt der Nachbau die $id, die Belegungszellen brauchen feste IDs.
const createMany = (collection, documents) => {
if (documents.length < 1 || documents.length > 100) {
return [400, { message: 'Invalid `documents` param: Value must be a valid array and contain between 1 and 100 items', code: 400, type: 'general_argument_invalid' }]
}
const prepared = []
for (const document of documents) {
if (!document || typeof document !== 'object' || Array.isArray(document) || !validDocumentId(document.$id)) return invalidDocumentId
const bad = unknownAttribute(collection, document)
if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }]
const [dated, badDate] = normalizeDates(collection, document)
if (badDate) return invalidDate(badDate)
prepared.push(dated)
}
const store = docs(collection)
const ids = new Set()
for (const { $id } of prepared) {
if (store.has($id) || ids.has($id)) return documentExists
ids.add($id)
}
const created = prepared.map((document) => stamp(document, collection))
for (const document of created) store.set(document.$id, document)
return [201, { total: created.length, documents: created.map(publicDoc) }]
}
const handle = async (req) => {
const url = new URL(req.url, 'http://127.0.0.1')
const auth = await handleAuth(req, url)
@@ -211,14 +318,16 @@ export async function startAppwriteMock({ schema = {} } = {}) {
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
if (!id && req.method === 'POST') {
const { documentId, data = {} } = await readBody(req)
const body = await readBody(req)
if (Array.isArray(body.documents)) return createMany(collection, body.documents)
const { documentId, data = {} } = body
if (!validDocumentId(documentId)) return invalidDocumentId
if (store.has(documentId)) {
return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
}
if (store.has(documentId)) return documentExists
const bad = unknownAttribute(collection, data)
if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }]
const doc = stamp({ ...data, $id: documentId }, collection)
const [dated, badDate] = normalizeDates(collection, data)
if (badDate) return invalidDate(badDate)
const doc = stamp({ ...dated, $id: documentId }, collection)
store.set(documentId, doc)
return [201, publicDoc(doc)]
}
@@ -228,7 +337,9 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const { data = {} } = await readBody(req)
const bad = unknownAttribute(collection, data)
if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }]
const doc = { ...store.get(id), ...data, $updatedAt: new Date().toISOString() }
const [dated, badDate] = normalizeDates(collection, data)
if (badDate) return invalidDate(badDate)
const doc = { ...store.get(id), ...dated, $updatedAt: new Date().toISOString() }
store.set(id, doc)
return [200, publicDoc(doc)]
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs'
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
@@ -7,9 +8,10 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw.
* 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.
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs).
*/
export function schemaKeys(collection) {
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
for (const script of SETUP_SCRIPTS) {
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
const start = src.indexOf(`'${collection}',`)
+7 -6
View File
@@ -46,7 +46,7 @@ export function sessionCookie(session) {
let baseUrl = null
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, server, close } (server: der http.Server, z. B. fuer closeAllConnections). */
export async function startTestApp(routers = {}) {
const app = express()
app.set('trust proxy', 1)
@@ -66,10 +66,11 @@ export async function startTestApp(routers = {}) {
const server = http.createServer(app)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
baseUrl = `http://127.0.0.1:${server.address().port}`
return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) }
return { url: baseUrl, server, close: () => new Promise((resolve) => server.close(resolve)) }
}
async function request(method, path, { body, cookie, headers = {} } = {}) {
/** Anfrage an die zuletzt gestartete App (beliebige Methode) -> { status, body, headers }; body ist geparstes JSON, sonst Text. */
export async function send(method, path, { body, cookie, headers = {} } = {}) {
if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen')
const res = await fetch(baseUrl + path, {
method,
@@ -92,7 +93,7 @@ async function request(method, path, { body, cookie, headers = {} } = {}) {
}
/** GET -> { status, body } */
export const rawGet = (path, opts = {}) => request('GET', path, opts)
export const rawGet = (path, opts = {}) => send('GET', path, opts)
/** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */
export async function get(path, opts = {}) {
@@ -107,7 +108,7 @@ export async function get(path, opts = {}) {
}
/** POST mit JSON-Body -> { status, body } */
export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body })
export const post = (path, body, opts = {}) => send('POST', path, { ...opts, body })
// Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt
const webhookSigner = new Stripe('sk_test_fake')
@@ -116,5 +117,5 @@ const webhookSigner = new Stripe('sk_test_fake')
export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) {
const payload = JSON.stringify(event)
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
return send('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+997
View File
@@ -0,0 +1,997 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
neueId,
normalisiereEinstellung,
oeffentlicheEinstellung,
knopfText,
istVerbraucher,
brauchtZustimmungVorzeitig,
preisText,
preisHinweis,
zahlungText,
fehlendePflichtangaben,
zeigtWiderruf,
} from '../server/services/termine/einstellungen.js'
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
const NOW = new Date('2026-10-02T10:00:00.000Z') // Berlin: 2026-10-02 12:00
const TAG = 86400000
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S1 = 's_bbbbbbb1'
const S2 = 's_bbbbbbb2'
/** Pflichtangaben fuer Gaeste (R13/R13a): Telefon immer, Anschrift bei Widerrufsbelehrung. */
const PFLICHT = { telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler' }
/** Kleinste gueltige Eingabe; alles andere kommt aus den Vorgaben. */
const basis = () => ({
betrieb: { name: 'Salon Müller', email: 'info@salon.de', ...PFLICHT },
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']] } }],
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }],
})
const pruefe = (eingabe, optionen = {}) => normalisiereEinstellung(eingabe, { now: NOW, ...optionen })
const gueltig = (eingabe, optionen) => {
const erg = pruefe(eingabe, optionen)
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const fehlerVon = (eingabe, optionen) => {
const erg = pruefe(eingabe, optionen)
assert.equal(erg.einstellung, undefined)
assert.ok(erg.fehler && typeof erg.fehler === 'object')
return erg.fehler
}
const BETRIEB = {
name: 'Salon Müller',
email: 'Info@Salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
kleinunternehmer: false,
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
}
// ---------------------------------------------------------------- IDs
test('neueId: Praefix, 8 Zeichen aus a-z0-9, unterschiedlich; unbekannter Praefix wirft', () => {
const ids = new Set()
for (const p of ['r', 's', 'a']) {
for (let i = 0; i < 50; i += 1) {
const id = neueId(p)
assert.match(id, new RegExp(`^${p}_[a-z0-9]{8}$`))
ids.add(id)
}
}
assert.equal(ids.size, 150)
assert.throws(() => neueId('x'))
assert.throws(() => neueId())
})
// ---------------------------------------------------------------- Vorlagen
test('Vorlagen: neun Schluessel in fester Reihenfolge, Liste mit Name und Beschreibung', () => {
const keys = ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges']
assert.deepEqual(Object.keys(VORLAGEN), keys)
const liste = vorlagenListe()
assert.deepEqual(liste.map((v) => v.key), keys)
for (const eintrag of liste) {
assert.deepEqual(Object.keys(eintrag).sort(), ['beschreibung', 'key', 'name'])
assert.ok(eintrag.name.length > 2 && eintrag.beschreibung.length > 10)
}
})
test('Vorlagen: jede ergibt eine gueltige, stabile Einstellung', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
assert.equal(e.vorlage, key)
assert.equal(e.betrieb.name, 'Salon Müller')
assert.equal(e.betrieb.email, 'info@salon.de')
assert.equal(e.betrieb.websiteUrl, 'https://salon.de/')
assert.ok(e.leistungen.length >= 2 && e.leistungen.length <= 5, `${key}: ${e.leistungen.length} Leistungen`)
assert.ok(e.ressourcen.length >= 1)
assert.ok(e.leistungen.every((l) => l.aktiv && l.ressourcen.length >= 1))
assert.ok(e.ressourcen.every((r) => Object.keys(r.plan).length >= 1 && r.feedToken.length === 43))
assert.equal(e.zahlung.modus, 'vorort')
// erneutes Normalisieren aendert nichts (Token und Storno-Version bleiben)
const nochmal = gueltig(e, { bisher: e })
assert.deepEqual(nochmal, e, key)
}
})
test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mit zwei Plaetzen', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
const erwartet = ['tattoo', 'handwerk', 'fotograf'].includes(key) ? 'anfrage' : 'sofort'
assert.ok(e.leistungen.every((l) => l.buchungsart === erwartet), key)
}
const werkstatt = ausVorlage('werkstatt', BETRIEB, NOW)
assert.equal(werkstatt.ressourcen.length, 2)
assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz'))
})
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => {
for (const key of Object.keys(VORLAGEN)) {
const e = ausVorlage(key, BETRIEB, NOW)
const text = e.storno.text
assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key)
assert.ok(text.includes('berechnen wir für den freigehaltenen Termin 50 % des Preises.'), key)
assert.ok(text.includes('Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist.'), key)
assert.ok(text.endsWith('Ihr gesetzliches Widerrufsrecht bleibt unberührt.'), key)
assert.ok(!text.includes('{{'), key)
assert.equal(e.storno.version, '2026-10-02-1')
}
})
test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => {
const a = ausVorlage('salon', BETRIEB, NOW)
const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW)
assert.notEqual(a.ressourcen[0].id, b.ressourcen[0].id)
assert.notEqual(a.ressourcen[0].feedToken, b.ressourcen[0].feedToken)
assert.equal(a.betrieb.kleinunternehmer, false)
assert.equal(b.betrieb.kleinunternehmer, true)
})
test('Vorlagen: unbekannter Schluessel wirft, fehlende Betriebsangaben werfen mit Feldfehlern', () => {
assert.throws(() => ausVorlage('gibtsnicht', BETRIEB, NOW), /Unbekannte Vorlage/)
assert.throws(() => ausVorlage('__proto__', BETRIEB, NOW), /Unbekannte Vorlage/)
assert.throws(() => ausVorlage('salon', {}, NOW), (err) => {
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
return true
})
})
test('Vorlagen sind Entwuerfe: ohne Telefon und Anschrift gueltig, fehlendePflichtangaben nennt, was noch fehlt (R41)', () => {
const ohne = { name: 'Salon Müller', email: 'info@salon.de' }
const salon = ausVorlage('salon', ohne, NOW)
assert.equal(salon.betrieb.telefon, '')
assert.equal(salon.betrieb.adresse, '')
assert.deepEqual(fehlendePflichtangaben(salon), ['betrieb.telefon', 'betrieb.adresse'], 'Verbraucher + sofort: Telefon und Anschrift')
// Anfrage-Betriebe zeigen die Widerrufsbelehrung ebenfalls (R55: der Vertrag kommt mit der Annahme zustande, auch ein Fernabsatzvertrag)
assert.deepEqual(fehlendePflichtangaben(ausVorlage('handwerk', ohne, NOW)), ['betrieb.telefon', 'betrieb.adresse'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', { ...ohne, telefon: '06301 1234' }, NOW)), ['betrieb.adresse'])
assert.deepEqual(fehlendePflichtangaben(ausVorlage('salon', BETRIEB, NOW)), [])
// der Entwurf selbst besteht die strenge Pruefung (wie PATCH sie macht) erst mit den Angaben
const streng = pruefe(salon, { bisher: salon })
assert.deepEqual(Object.keys(streng.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
assert.ok(pruefe({ ...salon, betrieb: { ...salon.betrieb, ...PFLICHT } }, { bisher: salon }).einstellung, 'mit den Angaben gueltig')
// Name und E-Mail bleiben Pflicht, auch im Entwurf
assert.throws(() => ausVorlage('salon', { telefon: '06301 1234' }, NOW), (err) => {
assert.ok(err.fehler['betrieb.name'] && err.fehler['betrieb.email'])
return true
})
})
test('Vorlagen: VORLAGEN ist eingefroren', () => {
assert.throws(() => { VORLAGEN.salon.leistungen.push({}) })
assert.throws(() => { VORLAGEN.neu = {} })
})
// ---------------------------------------------------------------- Normalisieren: Grundlagen
test('Vorgaben werden aufgefuellt', () => {
const e = gueltig(basis())
assert.equal(e.schema, 1)
assert.equal(e.vorlage, null)
assert.deepEqual(e.betrieb, {
name: 'Salon Müller', email: 'info@salon.de', telefon: '06301 1234', kleinunternehmer: false, adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: null, datenschutzUrl: null, agbUrl: null, websiteUrl: null,
})
assert.equal(e.anrede, 'sie')
assert.match(e.farbe, /^#[0-9a-f]{6}$/)
assert.ok(BUNDESLAENDER.includes(e.bundesland))
assert.equal(e.feiertageGeschlossen, true)
assert.deepEqual(e.sonderoeffnungen, {})
assert.deepEqual(e.abwesenheiten, [])
assert.deepEqual(e.regeln, { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true })
assert.deepEqual(e.gastFelder, { telefon: 'optional', nachricht: 'aus', zusatz: [] })
assert.deepEqual(e.erinnerung, { anbieten: true, stundenVorher: 24 })
assert.deepEqual(e.storno, { text: '', version: '' })
assert.deepEqual(e.recht, { gaesteSind: 'verbraucher', freizeit: false })
assert.deepEqual(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] })
assert.equal(e.aufbewahrungTage, 90)
assert.deepEqual(e.leistungen[0], {
id: S1, name: 'Damenschnitt', beschreibung: '', dauer: 45, pufferNach: 0, preisArt: 'keiner', preisCent: 0,
buchungsart: 'sofort', ressourcen: [R1], aktiv: true, reihenfolge: 1,
})
assert.deepEqual(e.ressourcen[0].plan, { 1: [['09:00', '12:30'], ['13:30', '18:00']] })
assert.equal(e.ressourcen[0].typ, 'person')
assert.equal(e.ressourcen[0].aktiv, true)
})
test('Eingabe wird nicht veraendert; Nicht-Objekte werden abgelehnt', () => {
const eingabe = basis()
const kopie = structuredClone(eingabe)
gueltig(eingabe)
assert.deepEqual(eingabe, kopie)
for (const unsinn of [null, undefined, 'x', 5, [], true]) {
assert.deepEqual(Object.keys(fehlerVon(unsinn)), ['_'])
}
})
test('now ist Pflicht', () => {
assert.throws(() => normalisiereEinstellung(basis(), {}), /now/)
assert.throws(() => normalisiereEinstellung(basis()), /now/)
assert.throws(() => normalisiereEinstellung(basis(), { now: 'heute' }), /now/)
})
test('Pflichtfelder: Betriebsname und Betriebs-E-Mail', () => {
const f = fehlerVon({})
assert.ok(f['betrieb.name'])
assert.ok(f['betrieb.email'])
assert.ok(fehlerVon({ betrieb: { ...PFLICHT, name: ' ', email: 'a@b.de' } })['betrieb.name'])
assert.ok(fehlerVon({ betrieb: { name: 'X' } })['betrieb.email'])
})
test('Betriebsname wird auf 80 Zeichen gekuerzt, Steuerzeichen fliegen raus', () => {
const e = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: ` Sa\u0000lon\n\tMüller\u202E ${'x'.repeat(100)}`, email: 'a@b.de' } })
assert.ok(e.betrieb.name.startsWith('Salon Müller xxx'))
assert.equal([...e.betrieb.name].length, 80)
assert.ok(!/[\u0000-\u001f\u202E]/.test(e.betrieb.name))
})
test('E-Mail: genau ein Postfach, klein und getrimmt', () => {
assert.equal(gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: ' Info@Salon.DE ' } }).betrieb.email, 'info@salon.de')
for (const schlecht of ['a@b.de, c@d.de', 'a@b.de;c@d.de', '<a@b.de>', '"a"@b.de', 'a b@c.de', 'ohne-at.de', 'a@b', 'a@@b.de', '@b.de', 'a@.de', `${'x'.repeat(250)}@b.de`]) {
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: schlecht } })['betrieb.email'], schlecht)
}
})
test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeichen', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
const f = fehlerVon(mit('impressumUrl', 'javascript:alert(1)'))
assert.ok(f['betrieb.impressumUrl'])
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
for (const schlecht of ['http://salon.de', 'javascript:alert(1)', 'java\nscript:alert(1)', 'data:text/html,x', 'https://user:pw@salon.de', 'https://user@salon.de', '//salon.de', 'salon.de', `https://salon.de/${'a'.repeat(300)}`, 'https://sa lon.de', 5]) {
assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`)
}
assert.equal(gueltig(mit(feld, 'https://salon.de/impressum')).betrieb[feld], 'https://salon.de/impressum')
assert.equal(gueltig(mit(feld, '')).betrieb[feld], null)
assert.equal(gueltig(mit(feld, null)).betrieb[feld], null)
}
})
test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => {
const mit = (feld, wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', [feld]: wert } })
for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) {
for (const schlecht of [
'https://exa"mple.com/', "https://a'b.de/", 'https://a`b.de/', "https://a'b.de/x'y", 'https://a<b.de/', 'https://a>b.de/',
'https://a|b.de/', 'https://a^b.de/', 'https://-a.de/', 'https://a..de/', 'https://a.de./', 'https://[zz]/x',
]) {
assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`)
}
}
})
test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => {
const url = (wert) => gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl
assert.equal(url("https://ab.de/x'y"), 'https://ab.de/x%27y')
assert.equal(url("https://ab.de/?q='a'#f'g"), 'https://ab.de/?q=%27a%27#f%27g')
assert.equal(url('https://Salon.DE/Impressum'), 'https://salon.de/Impressum')
assert.equal(url('https://Bücher.de/Über'), 'https://xn--bcher-kva.de/%C3%9Cber')
assert.equal(url('https://sub.salon-müller.de/impressum'), 'https://sub.xn--salon-mller-zhb.de/impressum')
assert.equal(url('https://[::1]/x'), 'https://[::1]/x')
for (const gespeichert of [url("https://ab.de/x'y\"z`w"), url('https://a.de/?q="x"<y>')]) {
assert.ok(!/['"`<>]/.test(gespeichert), gespeichert)
}
// die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ')
const apostrophe = "'".repeat(110)
assert.ok(fehlerVon({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl'])
assert.equal(url(`https://ab.de/${"'".repeat(80)}`).length, 'https://ab.de/'.length + 80 * 3)
})
test('Unsichtbare Formatzeichen: Name nur daraus ist leer (Pflichtfeld), sonst werden sie entfernt', () => {
const name = (wert) => ({ ...basis(), betrieb: { ...PFLICHT, name: wert, email: 'a@b.de' } })
for (const leer of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u200B\u200E \uFEFF', ' \u200B\u200D\u200C ', '\u200D', '\u200C\u200D']) {
assert.ok(fehlerVon(name(leer))['betrieb.name'], JSON.stringify(leer))
}
assert.equal(gueltig(name('A\u200EB\u061CC\uFEFFD')).betrieb.name, 'ABCD')
for (const zeichen of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u00AD', '\u2061', '\u206A']) {
assert.equal(gueltig(name(`Sa${zeichen}lon`)).betrieb.name, 'Salon', JSON.stringify(zeichen))
}
// ZWJ (Emoji-Sequenzen) und ZWNJ (persische Schrift) innerhalb des Textes bleiben
assert.equal(gueltig(name('\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon')).betrieb.name, '\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon')
assert.equal(gueltig(name('می\u200Cخواهم')).betrieb.name, 'می\u200Cخواهم')
// am Rand sind sie wertlos und fallen weg
assert.equal(gueltig(name('\u200DSalon\u200C ')).betrieb.name, 'Salon')
})
test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail und URL', () => {
const e = basis()
e.ressourcen[0].name = '\u200B'
e.leistungen[0].name = '\uFEFF\u200F'
const f = fehlerVon(e)
assert.ok(f['ressourcen.0.name'])
assert.ok(f['leistungen.0.name'])
const ok = basis()
ok.ressourcen[0].name = 'An\u200Bna'
ok.leistungen[0].beschreibung = 'Mit\u200E\u2060 Waschen'
ok.storno = { text: 'Zeile\u200B 1\n\u200EZeile 2' }
ok.betrieb = { ...PFLICHT, name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' }
const e2 = gueltig(ok)
assert.equal(e2.ressourcen[0].name, 'Anna')
assert.equal(e2.leistungen[0].beschreibung, 'Mit Waschen')
assert.equal(e2.storno.text, 'Zeile 1\nZeile 2')
assert.equal(e2.betrieb.email, 'info@salon.de')
assert.equal(e2.betrieb.impressumUrl, 'https://salon.de/impressum')
})
test('Farbe, Anrede, Bundesland werden geprueft', () => {
assert.equal(gueltig({ ...basis(), farbe: '#AABBCC' }).farbe, '#aabbcc')
for (const schlecht of ['red', '#abc', '#12345g', 'ffffff', 5]) assert.ok(fehlerVon({ ...basis(), farbe: schlecht }).farbe, String(schlecht))
assert.equal(gueltig({ ...basis(), anrede: 'du' }).anrede, 'du')
assert.ok(fehlerVon({ ...basis(), anrede: 'ihr' }).anrede)
for (const land of BUNDESLAENDER) assert.equal(gueltig({ ...basis(), bundesland: land }).bundesland, land)
for (const schlecht of ['XX', 'rp', 'Bayern', 'BUND', 'ALL', 5]) assert.ok(fehlerVon({ ...basis(), bundesland: schlecht }).bundesland, String(schlecht))
assert.ok(fehlerVon({ ...basis(), feiertageGeschlossen: 'ja' }).feiertageGeschlossen)
})
// ---------------------------------------------------------------- Wochenplan
test('Fenster: Minuten muessen Vielfache von 5 sein (09:07 => Fehler am Plan-Pfad)', () => {
const eingabe = basis()
eingabe.ressourcen[0].plan = { 1: [['09:07', '12:00']] }
assert.ok(fehlerVon(eingabe)['ressourcen.0.plan.1'])
})
test('Fenster: Format, Bereich, Ende nach Beginn, 24:00 nur als Ende', () => {
const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 3: fenster }; return e }
for (const schlecht of [
[['9:00', '12:00']], [['09:00', '12:00:00']], [['25:00', '26:00']], [['09:60', '12:00']], [['12:00', '12:00']], [['12:00', '09:00']],
[['24:00', '24:00']], [['09:00']], [['09:00', '12:00', '13:00']], ['09:00-12:00'], [[9, 12]], 'montags', [['09:00', '24:05']],
]) {
assert.ok(fehlerVon(mit(schlecht))['ressourcen.0.plan.3'], JSON.stringify(schlecht))
}
assert.deepEqual(gueltig(mit([['18:00', '24:00']])).ressourcen[0].plan, { 3: [['18:00', '24:00']] })
assert.deepEqual(gueltig(mit([['00:00', '00:05']])).ressourcen[0].plan, { 3: [['00:00', '00:05']] })
})
test('Fenster: Ueberlappung und falsche Reihenfolge => Fehler am Pfad ressourcen.0.plan.1', () => {
const mit = (fenster) => { const e = basis(); e.ressourcen[0].plan = { 1: [['08:00', '08:30']], 2: fenster }; return e }
const ueberlappt = basis()
ueberlappt.ressourcen[0].plan = { 1: [['09:00', '12:00'], ['11:00', '15:00']] }
assert.match(fehlerVon(ueberlappt)['ressourcen.0.plan.1'], /überlappen/)
const unsortiert = basis()
unsortiert.ressourcen[0].plan = { 1: [['13:00', '15:00'], ['09:00', '12:00']] }
assert.match(fehlerVon(unsortiert)['ressourcen.0.plan.1'], /aufsteigend/)
// direkt aneinander ist keine Ueberlappung
assert.deepEqual(gueltig(mit([['09:00', '12:00'], ['12:00', '15:00']])).ressourcen[0].plan[2], [['09:00', '12:00'], ['12:00', '15:00']])
})
test('Wochenplan: nur Wochentage 1 bis 7; leere Tage entfallen', () => {
const e = basis()
e.ressourcen[0].plan = { 0: [['09:00', '10:00']] }
assert.ok(fehlerVon(e)['ressourcen.0.plan.0'])
e.ressourcen[0].plan = { 8: [['09:00', '10:00']] }
assert.ok(fehlerVon(e)['ressourcen.0.plan.8'])
e.ressourcen[0].plan = { 1: [], 2: [['09:00', '10:00']] }
assert.deepEqual(gueltig(e).ressourcen[0].plan, { 2: [['09:00', '10:00']] })
e.ressourcen[0].plan = 'immer'
assert.ok(fehlerVon(e)['ressourcen.0.plan'])
})
// ---------------------------------------------------------------- Ressourcen und Leistungen
test('IDs: Format, Praefix, Eindeutigkeit; Eintraege ohne ID bekommen eine', () => {
const e = basis()
e.ressourcen.push({ name: 'Ben' })
e.leistungen.push({ name: 'Herrenschnitt', dauer: 30, ressourcen: [R1] })
const ok = gueltig(e)
assert.match(ok.ressourcen[1].id, /^r_[a-z0-9]{8}$/)
assert.match(ok.leistungen[1].id, /^s_[a-z0-9]{8}$/)
const falsch = basis()
falsch.ressourcen[0].id = 'x_aaaaaaa1'
assert.ok(fehlerVon(falsch)['ressourcen.0.id'])
const zuKurz = basis()
zuKurz.leistungen[0].id = 's_abc'
assert.ok(fehlerVon(zuKurz)['leistungen.0.id'])
const grossbuchstaben = basis()
grossbuchstaben.leistungen[0].id = 's_BBBBBBB1'
assert.ok(fehlerVon(grossbuchstaben)['leistungen.0.id'])
const doppelt = basis()
doppelt.ressourcen.push({ id: R1, name: 'Doppelt' })
assert.ok(fehlerVon(doppelt)['ressourcen.1.id'])
const doppelteLeistung = basis()
doppelteLeistung.leistungen.push({ id: S1, name: 'Nochmal', dauer: 30, ressourcen: [R1] })
assert.ok(fehlerVon(doppelteLeistung)['leistungen.1.id'])
})
test('Ressource: Name Pflicht, Typ person|platz, Reihenfolge der Liste bleibt', () => {
const e = basis()
e.ressourcen = [{ id: R1, name: '' }]
e.leistungen = []
assert.ok(fehlerVon(e)['ressourcen.0.name'])
e.ressourcen = [{ id: R1, name: 'A', typ: 'zimmer' }]
assert.ok(fehlerVon(e)['ressourcen.0.typ'])
e.ressourcen = [{ id: R2, name: 'Platz 2', typ: 'platz' }, { id: R1, name: 'Platz 1', typ: 'platz' }]
assert.deepEqual(gueltig(e).ressourcen.map((r) => r.id), [R2, R1])
})
test('Leistung: Pflichtfelder, Dauer und Pause in 5-Minuten-Schritten, Summe hoechstens 500', () => {
const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e }
assert.ok(fehlerVon(mit({ name: '' }))['leistungen.0.name'])
assert.ok(fehlerVon(mit({ dauer: undefined }))['leistungen.0.dauer'])
for (const dauer of [0, 4, 7, 485, 481, -5, 30.5, '30']) assert.ok(fehlerVon(mit({ dauer }))['leistungen.0.dauer'], String(dauer))
for (const dauer of [5, 480, 45]) assert.equal(gueltig(mit({ dauer })).leistungen[0].dauer, dauer)
for (const pufferNach of [-5, 3, 125, 121, '5']) assert.ok(fehlerVon(mit({ pufferNach }))['leistungen.0.pufferNach'], String(pufferNach))
for (const pufferNach of [0, 5, 120]) assert.equal(gueltig(mit({ pufferNach })).leistungen[0].pufferNach, pufferNach)
assert.ok(fehlerVon(mit({ dauer: 480, pufferNach: 25 }))['leistungen.0.dauer'])
assert.equal(gueltig(mit({ dauer: 480, pufferNach: 20 })).leistungen[0].pufferNach, 20)
})
test('Leistung: Preis, Preisart und Buchungsart', () => {
const mit = (aenderung) => { const e = basis(); Object.assign(e.leistungen[0], aenderung); return e }
assert.ok(fehlerVon(mit({ preisArt: 'gratis' }))['leistungen.0.preisArt'])
assert.ok(fehlerVon(mit({ buchungsart: 'online' }))['leistungen.0.buchungsart'])
for (const preisCent of [-1, 1000001, 4.5, '45']) assert.ok(fehlerVon(mit({ preisArt: 'fest', preisCent }))['leistungen.0.preisCent'], String(preisCent))
const fest = gueltig(mit({ preisArt: 'fest', preisCent: 4500 })).leistungen[0]
assert.equal(fest.preisCent, 4500)
assert.equal(gueltig(mit({ preisArt: 'ab', preisCent: 1000000 })).leistungen[0].preisCent, 1000000)
// ohne Preis wird die Zahl nicht mitgeschleppt
assert.equal(gueltig(mit({ preisArt: 'keiner', preisCent: 999 })).leistungen[0].preisCent, 0)
assert.equal(gueltig(mit({ buchungsart: 'anfrage' })).leistungen[0].buchungsart, 'anfrage')
})
test('Leistung: Beschreibung gekuerzt, Reihenfolge folgt der Liste', () => {
const e = basis()
e.leistungen[0].beschreibung = 'y'.repeat(500)
e.leistungen.push({ id: S2, name: 'Zweite', dauer: 15, ressourcen: [R1] })
const ok = gueltig(e)
assert.equal([...ok.leistungen[0].beschreibung].length, 300)
assert.deepEqual(ok.leistungen.map((l) => l.reihenfolge), [1, 2])
})
test('Leistung ohne aktive Ressource ist abgelehnt (aktiv, inaktiv, leer, unbekannt)', () => {
const ohne = basis()
ohne.leistungen[0].ressourcen = []
assert.ok(fehlerVon(ohne)['leistungen.0.ressourcen'])
const inaktiv = basis()
inaktiv.ressourcen[0].aktiv = false
assert.ok(fehlerVon(inaktiv)['leistungen.0.ressourcen'])
const unbekannt = basis()
unbekannt.leistungen[0].ressourcen = ['r_zzzzzzz9']
assert.ok(fehlerVon(unbekannt)['leistungen.0.ressourcen'])
const gemischt = basis()
gemischt.ressourcen.push({ id: R2, name: 'Ben', aktiv: false })
gemischt.leistungen[0].ressourcen = [R2, R1, R1]
assert.deepEqual(gueltig(gemischt).leistungen[0].ressourcen, [R2, R1])
// eine inaktive Leistung darf ohne aktive Ressource bleiben
const ruhend = basis()
ruhend.leistungen[0].aktiv = false
ruhend.leistungen[0].ressourcen = []
assert.equal(gueltig(ruhend).leistungen[0].aktiv, false)
})
// ---------------------------------------------------------------- Entfernen mit kuenftigen Terminen
test('Entfernen einer Leistung mit kuenftigen Terminen => Fehler mit Anzahl', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.leistungen = []
const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 3 }, ressourcen: {} } })
assert.equal(f[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 3 künftige Termine. Deaktiviere sie stattdessen.')
const einzahl = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 1 }, ressourcen: {} } })
assert.equal(einzahl[`leistungen.${S1}`], 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.')
// ohne kuenftige Termine darf sie weg, ebenso ohne Angabe
assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 0 }, ressourcen: {} } }).leistungen, [])
assert.deepEqual(gueltig(neu, { bisher }).leistungen, [])
assert.deepEqual(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S2]: 9 } } }).leistungen, [])
})
test('Entfernen einer Ressource mit kuenftigen Terminen => Fehler mit Anzahl (Person und Platz)', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.ressourcen = []
neu.leistungen[0].aktiv = false
neu.leistungen[0].ressourcen = []
const f = fehlerVon(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 4 } } })
assert.equal(f[`ressourcen.${R1}`], 'Für diese Person gibt es noch 4 künftige Termine. Deaktiviere sie stattdessen.')
const platzBasis = basis()
platzBasis.ressourcen[0].typ = 'platz'
const platzBisher = gueltig(platzBasis)
const f2 = fehlerVon(neu, { bisher: platzBisher, kuenftigeTermine: { ressourcen: { [R1]: 2 } } })
assert.equal(f2[`ressourcen.${R1}`], 'Für diesen Platz gibt es noch 2 künftige Termine. Deaktiviere ihn stattdessen.')
assert.equal(gueltig(neu, { bisher, kuenftigeTermine: { leistungen: {}, ressourcen: { [R1]: 0 } } }).ressourcen.length, 0)
})
test('Deaktivieren ist trotz kuenftiger Termine erlaubt', () => {
const bisher = gueltig(basis())
const neu = basis()
neu.leistungen[0].aktiv = false
neu.ressourcen.push({ id: R2, name: 'Ben' })
neu.ressourcen[0].aktiv = false
neu.leistungen[0].ressourcen = [R2]
const e = gueltig(neu, { bisher, kuenftigeTermine: { leistungen: { [S1]: 5 }, ressourcen: { [R1]: 5 } } })
assert.equal(e.leistungen[0].aktiv, false)
assert.equal(e.ressourcen[0].aktiv, false)
})
// ---------------------------------------------------------------- Feed-Token
test('feedToken: Client-Wert wird ignoriert, bisheriger bleibt, neue Ressourcen bekommen einen', () => {
const eins = gueltig({ ...basis(), ressourcen: [{ ...basis().ressourcen[0], feedToken: 'vom-client' }] })
assert.notEqual(eins.ressourcen[0].feedToken, 'vom-client')
assert.match(eins.ressourcen[0].feedToken, /^[A-Za-z0-9_-]{43}$/)
const zwei = basis()
zwei.ressourcen[0].feedToken = 'vom-client'
zwei.ressourcen.push({ id: R2, name: 'Ben', feedToken: eins.ressourcen[0].feedToken })
const nachher = gueltig(zwei, { bisher: eins })
assert.equal(nachher.ressourcen[0].feedToken, eins.ressourcen[0].feedToken)
assert.match(nachher.ressourcen[1].feedToken, /^[A-Za-z0-9_-]{43}$/)
assert.notEqual(nachher.ressourcen[1].feedToken, eins.ressourcen[0].feedToken)
})
// ---------------------------------------------------------------- Abwesenheiten, Sonderoeffnungen
test('Abwesenheiten: von <= bis, gueltige Tage, bekannte Ressource oder null', () => {
const mit = (a) => ({ ...basis(), abwesenheiten: [a] })
const ok = gueltig(mit({ ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' })).abwesenheiten
assert.equal(ok.length, 1)
assert.match(ok[0].id, /^a_[a-z0-9]{8}$/)
assert.deepEqual({ ...ok[0], id: 'x' }, { id: 'x', ressourceId: null, von: '2026-12-27', bis: '2026-12-31', titel: 'Betriebsurlaub' })
assert.equal(gueltig(mit({ ressourceId: R1, von: '2026-12-27', bis: '2026-12-27' })).abwesenheiten[0].ressourceId, R1)
assert.ok(fehlerVon(mit({ von: '2026-12-31', bis: '2026-12-27' }))['abwesenheiten.0.bis'])
assert.ok(fehlerVon(mit({ von: '2026-02-30', bis: '2026-03-01' }))['abwesenheiten.0.von'])
assert.ok(fehlerVon(mit({ von: '27.12.2026', bis: '2026-12-31' }))['abwesenheiten.0.von'])
assert.ok(fehlerVon(mit({ von: '2026-12-01' }))['abwesenheiten.0.bis'])
assert.ok(fehlerVon(mit({ ressourceId: 'r_zzzzzzz9', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.ressourceId'])
assert.ok(fehlerVon(mit({ id: 'r_aaaaaaa1', von: '2026-12-27', bis: '2026-12-31' }))['abwesenheiten.0.id'])
})
test('Sonderoeffnungen: gueltiger Tag, Fenster wie im Plan, leere Liste = geschlossen', () => {
const mit = (s) => ({ ...basis(), sonderoeffnungen: s })
assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '13:00']], '2026-12-31': [] })).sonderoeffnungen, {
'2026-12-24': [['09:00', '13:00']], '2026-12-31': [],
})
assert.deepEqual(gueltig(mit({ '2026-12-24': [['09:00', '24:00']] })).sonderoeffnungen['2026-12-24'], [['09:00', '24:00']])
assert.ok(fehlerVon(mit({ '2026-13-01': [['09:00', '13:00']] }))['sonderoeffnungen.2026-13-01'])
assert.ok(fehlerVon(mit({ 'morgen': [['09:00', '13:00']] }))['sonderoeffnungen.morgen'])
assert.ok(fehlerVon(mit({ '2026-12-24': [['09:03', '13:00']] }))['sonderoeffnungen.2026-12-24'])
assert.ok(fehlerVon(mit({ '2026-12-24': [['09:00', '13:00'], ['12:00', '14:00']] }))['sonderoeffnungen.2026-12-24'])
assert.ok(fehlerVon(mit([['09:00', '13:00']]))['sonderoeffnungen'])
})
// ---------------------------------------------------------------- Regeln, Gastfelder, Erinnerung, Zahlung
test('Regeln: Wertebereiche', () => {
const mit = (r) => ({ ...basis(), regeln: r })
assert.deepEqual(gueltig(mit({ raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false })).regeln,
{ raster: 30, vorlaufMin: 0, horizontTage: 365, absageBisStd: 336, maxOffeneJeEmail: 10, personWaehlbar: false })
for (const raster of [5, 10, 15, 20, 30, 60]) assert.equal(gueltig(mit({ raster })).regeln.raster, raster)
for (const raster of [0, 1, 25, 45, 90, '15']) assert.ok(fehlerVon(mit({ raster }))['regeln.raster'], String(raster))
for (const vorlaufMin of [-1, 20161, 1.5]) assert.ok(fehlerVon(mit({ vorlaufMin }))['regeln.vorlaufMin'], String(vorlaufMin))
assert.equal(gueltig(mit({ vorlaufMin: 20160 })).regeln.vorlaufMin, 20160)
for (const horizontTage of [0, 366, -1]) assert.ok(fehlerVon(mit({ horizontTage }))['regeln.horizontTage'], String(horizontTage))
assert.equal(gueltig(mit({ horizontTage: 1 })).regeln.horizontTage, 1)
for (const absageBisStd of [-1, 337]) assert.ok(fehlerVon(mit({ absageBisStd }))['regeln.absageBisStd'], String(absageBisStd))
assert.equal(gueltig(mit({ absageBisStd: 0 })).regeln.absageBisStd, 0)
for (const maxOffeneJeEmail of [0, 11]) assert.ok(fehlerVon(mit({ maxOffeneJeEmail }))['regeln.maxOffeneJeEmail'], String(maxOffeneJeEmail))
assert.ok(fehlerVon(mit({ personWaehlbar: 'ja' }))['regeln.personWaehlbar'])
})
test('Gastfelder: aus|optional|pflicht, bis zu 5 Zusatzfelder', () => {
const mit = (g) => ({ ...basis(), gastFelder: g })
assert.deepEqual(gueltig(mit({ telefon: 'pflicht', nachricht: 'optional' })).gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [] })
assert.ok(fehlerVon(mit({ telefon: 'vielleicht' }))['gastFelder.telefon'])
assert.ok(fehlerVon(mit({ nachricht: 'an' }))['gastFelder.nachricht'])
const zusatz = [
{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true },
{ key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter', 'Sommer'], pflicht: false },
]
const ok = gueltig(mit({ zusatz })).gastFelder.zusatz
assert.deepEqual(ok, [
{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true },
{ key: 'reifen', label: 'Reifen', typ: 'auswahl', optionen: ['Sommer', 'Winter'], pflicht: false },
])
const sechs = Array.from({ length: 6 }, (_, i) => ({ key: `feld${i}`, label: `Feld ${i}`, typ: 'text' }))
assert.ok(fehlerVon(mit({ zusatz: sechs }))['gastFelder.zusatz'])
assert.equal(gueltig(mit({ zusatz: sechs.slice(0, 5) })).gastFelder.zusatz.length, 5)
for (const key of ['Kennzeichen', 'a-b', '', 'x'.repeat(21), 'ä', '__proto__', 'constructor', 'prototype']) {
assert.ok(fehlerVon(mit({ zusatz: [{ key, label: 'X', typ: 'text' }] }))['gastFelder.zusatz.0.key'], key)
}
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X' }, { key: 'a', label: 'Y' }] }))['gastFelder.zusatz.1.key'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: '' }] }))['gastFelder.zusatz.0.label'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'datei' }] }))['gastFelder.zusatz.0.typ'])
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: [] }] }))['gastFelder.zusatz.0.optionen'])
const elf = Array.from({ length: 11 }, (_, i) => `Option ${i}`)
assert.ok(fehlerVon(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf }] }))['gastFelder.zusatz.0.optionen'])
assert.equal(gueltig(mit({ zusatz: [{ key: 'a', label: 'X', typ: 'auswahl', optionen: elf.slice(0, 10) }] })).gastFelder.zusatz[0].optionen.length, 10)
})
test('Erinnerung, Recht, Aufbewahrung', () => {
for (const stundenVorher of [2, 12, 24, 48]) assert.equal(gueltig({ ...basis(), erinnerung: { stundenVorher } }).erinnerung.stundenVorher, stundenVorher)
for (const stundenVorher of [1, 6, 36, 72, '24']) assert.ok(fehlerVon({ ...basis(), erinnerung: { stundenVorher } })['erinnerung.stundenVorher'], String(stundenVorher))
assert.equal(gueltig({ ...basis(), erinnerung: { anbieten: false } }).erinnerung.anbieten, false)
assert.ok(fehlerVon({ ...basis(), erinnerung: { anbieten: 'nein' } })['erinnerung.anbieten'])
assert.deepEqual(gueltig({ ...basis(), recht: { gaesteSind: 'gemischt', freizeit: true } }).recht, { gaesteSind: 'gemischt', freizeit: true })
assert.ok(fehlerVon({ ...basis(), recht: { gaesteSind: 'firmen' } })['recht.gaesteSind'])
assert.ok(fehlerVon({ ...basis(), recht: { freizeit: 1 } })['recht.freizeit'])
for (const tage of [14, 365, 90]) assert.equal(gueltig({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, tage)
for (const tage of [13, 366, 0, 90.5]) assert.ok(fehlerVon({ ...basis(), aufbewahrungTage: tage }).aufbewahrungTage, String(tage))
})
test('Zahlung: nur vorort; online/beides => "Online-Zahlung kommt bald"; Zahlarten geprueft', () => {
const mit = (z) => ({ ...basis(), zahlung: z })
assert.equal(fehlerVon(mit({ modus: 'online' }))['zahlung.modus'], 'Online-Zahlung kommt bald')
assert.equal(fehlerVon(mit({ modus: 'beides' }))['zahlung.modus'], 'Online-Zahlung kommt bald')
assert.ok(fehlerVon(mit({ modus: 'bitcoin' }))['zahlung.modus'])
assert.equal(gueltig(mit({ modus: 'vorort' })).zahlung.modus, 'vorort')
assert.deepEqual(gueltig(mit({ vorOrtArten: ['paypal', 'bar', 'bar', 'ueberweisung', 'ec', 'kreditkarte'] })).zahlung.vorOrtArten,
['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
assert.deepEqual(gueltig(mit({ vorOrtArten: [] })).zahlung.vorOrtArten, [])
assert.ok(fehlerVon(mit({ vorOrtArten: ['bar', 'bitcoin'] }))['zahlung.vorOrtArten'])
assert.ok(fehlerVon(mit({ vorOrtArten: 'bar' }))['zahlung.vorOrtArten'])
})
// ---------------------------------------------------------------- Storno
test('Storno: Version YYYY-MM-DD-n nur bei geaendertem Text (Tag aus now, Berlin)', () => {
const a = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: 'vom-client' } })
assert.deepEqual(a.storno, { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' })
// unveraenderter Text: Version bleibt, auch wenn der Client eine andere schickt
const b = gueltig({ ...basis(), storno: { text: 'Absage bis 24 Stunden vorher.', version: '1999-01-01-9' } }, { bisher: a })
assert.equal(b.storno.version, '2026-10-02-1')
// geaenderter Text am selben Tag: n + 1
const c = gueltig({ ...basis(), storno: { text: 'Absage bis 48 Stunden vorher.' } }, { bisher: b })
assert.equal(c.storno.version, '2026-10-02-2')
// naechster Tag: wieder 1; kurz nach Mitternacht Berlin zaehlt als neuer Tag
const d = gueltig({ ...basis(), storno: { text: 'Absage bis 12 Stunden vorher.' } }, { bisher: c, now: new Date('2026-10-02T22:30:00.000Z') })
assert.equal(d.storno.version, '2026-10-03-1')
const e = gueltig({ ...basis(), storno: { text: 'Absage bis 6 Stunden vorher.' } }, { bisher: d, now: new Date('2026-10-03T10:00:00.000Z') })
assert.equal(e.storno.version, '2026-10-03-2')
// Text entfernt: Text leer, Version bleibt; spaeter neuer Text zaehlt weiter
const f = gueltig(basis(), { bisher: e, now: new Date('2026-10-03T11:00:00.000Z') })
assert.deepEqual(f.storno, { text: '', version: '2026-10-03-2' })
const g = gueltig({ ...basis(), storno: { text: 'Wieder da.' } }, { bisher: f, now: new Date('2026-10-03T12:00:00.000Z') })
assert.equal(g.storno.version, '2026-10-03-3')
})
test('Storno: Zeilenumbrueche bleiben, andere Steuerzeichen nicht; auf 2000 Zeichen gekuerzt', () => {
const e = gueltig({ ...basis(), storno: { text: `Zeile 1\r\nZeile\u0007 2\n\n\n\nZeile 3 ${'x'.repeat(3000)}` } })
assert.ok(e.storno.text.startsWith('Zeile 1\nZeile 2\n\nZeile 3 xxx'))
assert.equal([...e.storno.text].length, 2000)
})
// ---------------------------------------------------------------- Obergrenzen
test('Obergrenzen: 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderoeffnungen', () => {
const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}` }))
assert.equal(gueltig({ ...basis(), ressourcen: ressourcen(20), leistungen: [] }).ressourcen.length, 20)
assert.ok(fehlerVon({ ...basis(), ressourcen: ressourcen(21), leistungen: [] }).ressourcen)
const leistungen = (n) => Array.from({ length: n }, (_, i) => ({ name: `L${i}`, dauer: 30, ressourcen: [R1] }))
assert.equal(gueltig({ ...basis(), leistungen: leistungen(50) }).leistungen.length, 50)
assert.ok(fehlerVon({ ...basis(), leistungen: leistungen(51) }).leistungen)
const abwesenheiten = (n) => Array.from({ length: n }, () => ({ von: '2026-12-01', bis: '2026-12-02' }))
assert.equal(gueltig({ ...basis(), abwesenheiten: abwesenheiten(100) }).abwesenheiten.length, 100)
assert.ok(fehlerVon({ ...basis(), abwesenheiten: abwesenheiten(101) }).abwesenheiten)
const tage = (n) => Object.fromEntries(Array.from({ length: n }, (_, i) => [new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10), [['09:00', '12:00']]]))
assert.equal(Object.keys(gueltig({ ...basis(), sonderoeffnungen: tage(200) }).sonderoeffnungen).length, 200)
assert.ok(fehlerVon({ ...basis(), sonderoeffnungen: tage(201) }).sonderoeffnungen)
})
test('Obergrenze: Gesamtgroesse hoechstens 200 000 Zeichen (Fehler unter "_")', () => {
const uhr = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
// 144 Fenster je Tag (jeweils 5 Minuten, dazwischen 5 Minuten Luecke)
const tag = Array.from({ length: 144 }, (_, i) => [uhr(i * 10), uhr(i * 10 + 5)])
const wochenplan = Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, tag]))
const ressourcen = (n) => Array.from({ length: n }, (_, i) => ({ name: `R${i}`, plan: wochenplan }))
const zuGross = { ...basis(), ressourcen: ressourcen(20), leistungen: [] }
const f = fehlerVon(zuGross)
assert.ok(f._)
assert.deepEqual(Object.keys(f), ['_'])
const noch = { ...basis(), ressourcen: ressourcen(8), leistungen: [] }
const e = gueltig(noch)
assert.ok(JSON.stringify(e).length <= 200000)
assert.ok(JSON.stringify(e).length > 100000)
// Die Grenzen der Einzellisten zusammen bleiben darunter
const gross = basis()
gross.leistungen = Array.from({ length: 50 }, () => ({ name: 'N'.repeat(100), beschreibung: 'b'.repeat(300), dauer: 30, ressourcen: [R1] }))
gross.abwesenheiten = Array.from({ length: 100 }, () => ({ titel: 't'.repeat(80), von: '2026-12-01', bis: '2026-12-02' }))
gross.storno = { text: 's'.repeat(2000) }
assert.ok(JSON.stringify(gueltig(gross)).length < 200000)
})
// ---------------------------------------------------------------- Texte
test('knopfText: verbindlich + Preis, verbindlich ohne Preis, Anfrage (und Bestaetigungsseite)', () => {
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
const abPreis = { buchungsart: 'sofort', preisArt: 'ab', preisCent: 4500 }
const ohnePreis = { buchungsart: 'sofort', preisArt: 'keiner', preisCent: 0 }
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
assert.equal(knopfText(mitPreis), 'Zahlungspflichtig buchen')
assert.equal(knopfText(abPreis), 'Zahlungspflichtig buchen')
assert.equal(knopfText(ohnePreis), 'Termin buchen')
assert.equal(knopfText(anfrage), 'Termin anfragen')
assert.equal(knopfText(mitPreis, { bestaetigen: true }), 'Zahlungspflichtig bestätigen')
assert.equal(knopfText(ohnePreis, { bestaetigen: true }), 'Termin bestätigen')
assert.equal(knopfText(anfrage, { bestaetigen: true }), 'Anfrage absenden')
assert.equal(knopfText(mitPreis, { bestaetigen: false }), 'Zahlungspflichtig buchen')
// Preis 0 gilt als kein Preis
assert.equal(knopfText({ buchungsart: 'sofort', preisArt: 'fest', preisCent: 0 }), 'Termin buchen')
})
test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => {
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: false } }), true)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'gemischt', freizeit: false } }), true)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'unternehmer', freizeit: false } }), false)
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false)
})
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn bis zum Ende der Widerrufsfrist (Berliner Kalender, §§ 187, 188, 193 BGB)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } }
const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } }
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
// NOW = Freitag 02.10.2026, 12:00 Berlin; Vertragsschluss spaetestens nach der Haltezeit (30 Minuten): Fristende
// Freitag 16.10.2026, 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC (MESZ)
const FRISTENDE = Date.parse('2026-10-16T21:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
// Grenze: die letzte Millisekunde der Frist ja, die naechste nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE + 1), NOW), false)
// 14 mal 24 Stunden reichen nicht: 14 Tage nach der Buchung um 12:00 liegen noch in der Frist (bis 24 Uhr des Tages)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true)
// Fristende auf einem Wochenende ⇒ naechster Werktag: Samstag 10.10.2026 + 14 = Samstag 24.10. ⇒ Montag 26.10., 23:59:59,999 Uhr Berlin (MEZ)
const SAMSTAG = new Date('2026-10-10T10:00:00.000Z')
const wochenendeEnde = Date.parse('2026-10-26T22:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde), SAMSTAG), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde + 1), SAMSTAG), false)
// die Haltezeit zaehlt mit: wer um 23:45 Uhr bucht, schliesst den Vertrag erst nach Mitternacht (Fristende einen Tag spaeter)
// (Samstag 03.10. + 14 = Samstag 17.10. ⇒ Montag 19.10., 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC)
const spaet = new Date('2026-10-02T21:45:00.000Z') // 23:45 Uhr Berlin, Freitag; + 30 Minuten = Samstag 00:15 Uhr
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-16T21:59:59.999Z') + 1), spaet), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-19T21:59:59.999Z') + 1), spaet), false)
// Unternehmer, Freizeit, kein Preis: keine Zustimmung noetig
assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
// ungueltiger Beginn: lieber die Zustimmung verlangen
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true)
})
test('brauchtZustimmungVorzeitig: bei einer Anfrage mit Preis fuer Verbraucher immer (der Tag der Annahme ist unbekannt)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(60), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, new Date('kaputt'), NOW), true)
// ohne Preis, Unternehmer, Freizeit: nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...anfrage, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'unternehmer', freizeit: false } }, anfrage, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'verbraucher', freizeit: true } }, anfrage, inTagen(3), NOW), false)
})
test('preisText, preisHinweis, zahlungText', () => {
assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €')
assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 4505 }), '45,05 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 5 }), '0,05 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 123456 }), '1.234,56 €')
assert.equal(preisText({ preisArt: 'fest', preisCent: 1000000 }), '10.000,00 €')
assert.equal(preisText({ preisArt: 'keiner', preisCent: 0 }), '')
assert.equal(preisText({ preisArt: 'keiner', preisCent: 4500 }), '')
assert.equal(preisText({ preisArt: 'fest', preisCent: 0 }), '')
assert.equal(preisHinweis({ betrieb: { kleinunternehmer: false } }), 'inkl. USt')
assert.equal(preisHinweis({ betrieb: {} }), 'inkl. USt')
assert.equal(preisHinweis({ betrieb: { kleinunternehmer: true } }), 'kein Ausweis der USt nach § 19 UStG')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] } }), 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: ['ueberweisung', 'paypal'] } }), 'Bezahlung vor Ort: Überweisung, PayPal')
assert.equal(zahlungText({ zahlung: { modus: 'vorort', vorOrtArten: [] } }), 'Bezahlung vor Ort')
})
test('R13: Telefon ist immer Pflicht (Pfad betrieb.telefon), auch ohne Widerrufsbelehrung; Leerraum und Steuerzeichen zaehlen als leer', () => {
const unternehmer = { ...basis(), recht: { gaesteSind: 'unternehmer' }, betrieb: { name: 'X', email: 'a@b.de', adresse: PFLICHT.adresse } }
assert.deepEqual(Object.keys(fehlerVon(unternehmer)), ['betrieb.telefon'])
for (const leer of [undefined, null, '', ' ', '\t\n', '\u200b\u200b', 42, {}]) {
const f = fehlerVon({ ...basis(), betrieb: { ...basis().betrieb, telefon: leer } })
assert.deepEqual(Object.keys(f), ['betrieb.telefon'], String(leer))
assert.match(f['betrieb.telefon'], /Telefonnummer/)
}
assert.equal(gueltig({ ...unternehmer, betrieb: { ...unternehmer.betrieb, telefon: ' 0171 2345678 ' } }).betrieb.telefon, '0171 2345678')
})
test('R13a/R55: Anschrift ist Pflicht (Pfad betrieb.adresse), sobald die Widerrufsbelehrung erscheint: Verbraucher + sichtbare Leistung (sofort oder Anfrage)', () => {
const ohneAdresse = (zusatz = {}) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', telefon: PFLICHT.telefon }, ...zusatz })
const f = fehlerVon(ohneAdresse())
assert.deepEqual(Object.keys(f), ['betrieb.adresse'])
assert.match(f['betrieb.adresse'], /Anschrift/)
for (const leer of ['', ' ', '\u200b']) {
assert.deepEqual(Object.keys(fehlerVon({ ...ohneAdresse(), betrieb: { ...ohneAdresse().betrieb, adresse: leer } })), ['betrieb.adresse'])
}
// gemischte Gaeste sind auch Verbraucher
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse({ recht: { gaesteSind: 'gemischt' } }))), ['betrieb.adresse'])
// ohne Widerrufsbelehrung keine Anschrift noetig: nur Unternehmer oder Freizeitleistung
assert.equal(gueltig(ohneAdresse({ recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
assert.equal(gueltig(ohneAdresse({ recht: { freizeit: true } })).betrieb.adresse, '')
// R55: auch ein Betrieb mit nur Anfrage-Leistungen braucht die Anschrift (der Vertrag kommt mit der Annahme zustande)
const anfragen = { leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] }
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(anfragen))), ['betrieb.adresse'])
assert.equal(gueltig(ohneAdresse({ ...anfragen, recht: { gaesteSind: 'unternehmer' } })).betrieb.adresse, '')
// eine Leistung, die Gaeste nicht sehen (inaktiv oder nur an inaktiver Ressource), loest nichts aus
const ruht = {
ressourcen: [{ id: R1, name: 'Anna', plan: {} }, { id: R2, name: 'Ben', aktiv: false, plan: {} }],
leistungen: [
{ id: S1, name: 'Anfrage', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage', aktiv: false },
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], buchungsart: 'sofort', aktiv: false },
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], buchungsart: 'sofort', aktiv: false },
],
}
assert.equal(gueltig(ohneAdresse(ruht)).betrieb.adresse, '')
// mit einer sichtbaren Leistung daneben wieder Pflicht
const mitSofort = { ...ruht, leistungen: [...ruht.leistungen, { id: 's_bbbbbbb4', name: 'Sofort', dauer: 30, ressourcen: [R1], buchungsart: 'sofort' }] }
assert.deepEqual(Object.keys(fehlerVon(ohneAdresse(mitSofort))), ['betrieb.adresse'])
})
test('R13/R13a: beide Pflichtangaben fehlen => beide Fehler; Reihenfolge der Pfade Telefon, Anschrift; andere Fehler bleiben daneben', () => {
const f = fehlerVon({ ...basis(), betrieb: { name: '', email: 'a@b.de' } })
assert.deepEqual(Object.keys(f), ['betrieb.name', 'betrieb.telefon', 'betrieb.adresse'])
})
test('R13/R13a: fehlendePflichtangaben stimmt mit oeffentlicheEinstellung.widerruf ueberein (alle Kombinationen)', () => {
for (const gaesteSind of ['verbraucher', 'unternehmer', 'gemischt']) {
for (const freizeit of [false, true]) {
for (const buchungsart of ['sofort', 'anfrage']) {
for (const aktiv of [true, false]) {
const e = gueltig({
...basis(),
recht: { gaesteSind, freizeit },
leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart, aktiv }],
})
const name = JSON.stringify({ gaesteSind, freizeit, buchungsart, aktiv })
const widerruf = oeffentlicheEinstellung(e, NOW).widerruf
assert.equal(widerruf, gaesteSind !== 'unternehmer' && !freizeit && aktiv, `R55: unabhaengig von der Buchungsart ${name}`)
assert.equal(zeigtWiderruf(e), widerruf, name)
const ohne = { ...e, betrieb: { ...e.betrieb, adresse: '', telefon: '' } }
assert.deepEqual(fehlendePflichtangaben(ohne), widerruf ? ['betrieb.telefon', 'betrieb.adresse'] : ['betrieb.telefon'], name)
assert.deepEqual(fehlendePflichtangaben(e), [], name)
}
}
}
}
})
test('R41: entwurf laesst nur die beiden Pflichtangaben aus; alle anderen Pruefungen gelten weiter; ohne entwurf strikt', () => {
const roh = { ...basis(), betrieb: { name: 'Salon Müller', email: 'info@salon.de' } }
assert.deepEqual(Object.keys(fehlerVon(roh)), ['betrieb.telefon', 'betrieb.adresse'], 'Vorgabe: strikt')
assert.deepEqual(Object.keys(fehlerVon(roh, { entwurf: false })), ['betrieb.telefon', 'betrieb.adresse'])
const e = gueltig(roh, { entwurf: true })
assert.equal(e.betrieb.telefon, '')
assert.equal(e.betrieb.adresse, '')
const kaputt = { betrieb: { name: '', email: 'kein-postfach', impressumUrl: 'javascript:alert(1)' }, ressourcen: [], leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1] }] }
const f = fehlerVon(kaputt, { entwurf: true })
assert.deepEqual(Object.keys(f).sort(), ['betrieb.email', 'betrieb.impressumUrl', 'betrieb.name', 'leistungen.0.ressourcen'])
assert.ok(!('betrieb.telefon' in f) && !('betrieb.adresse' in f))
})
// ---------------------------------------------------------------- Oeffentliche Sicht
test('oeffentlicheEinstellung: genau die vereinbarten Felder, ohne feedToken und interne Angaben', () => {
const e = ausVorlage('salon', { ...BETRIEB, agbUrl: 'https://salon.de/agb' }, NOW)
const oe = oeffentlicheEinstellung(e, NOW)
assert.deepEqual(Object.keys(oe).sort(), [
'anrede', 'betrieb', 'erinnerung', 'farbe', 'gastFelder', 'heute', 'leistungen', 'personWaehlbar', 'preisHinweis',
'regeln', 'ressourcen', 'storno', 'widerruf', 'zahlung',
])
assert.equal(oe.heute, '2026-10-02')
assert.deepEqual(oe.betrieb, {
name: 'Salon Müller', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: 'https://salon.de/impressum', datenschutzUrl: 'https://salon.de/datenschutz', agbUrl: 'https://salon.de/agb',
})
assert.equal(oeffentlicheEinstellung(ausVorlage('salon', BETRIEB, NOW), NOW).betrieb.agbUrl, null, 'ohne AGB-Adresse null')
assert.equal(oe.anrede, 'sie')
assert.equal(oe.farbe, e.farbe)
assert.deepEqual(oe.regeln, { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd })
assert.equal(oe.personWaehlbar, e.regeln.personWaehlbar)
assert.deepEqual(oe.zahlung, { modus: 'vorort', text: 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' })
assert.equal(oe.preisHinweis, 'inkl. USt')
assert.deepEqual(oe.storno, { text: e.storno.text, version: e.storno.version })
assert.equal(oe.widerruf, true)
assert.deepEqual(oe.erinnerung, { anbieten: true, stundenVorher: 24 })
assert.deepEqual(oe.gastFelder, e.gastFelder)
assert.deepEqual(oe.ressourcen, e.ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ })))
const leistung = oe.leistungen[0]
assert.deepEqual(Object.keys(leistung).sort(), [
'beschreibung', 'buchungsart', 'dauer', 'id', 'knopf', 'name', 'preis', 'preisArt', 'ressourcen', 'verbraucher',
])
assert.equal(leistung.knopf, knopfText(e.leistungen[0]))
assert.equal(leistung.preis, preisText(e.leistungen[0]))
assert.equal(leistung.verbraucher, true)
const json = JSON.stringify(oe)
for (const geheim of ['feedToken', e.ressourcen[0].feedToken, 'info@salon.de', 'customerId', 'projectId', 'featureId', 'pufferNach', 'preisCent', 'aufbewahrungTage', 'sonderoeffnungen', 'abwesenheiten', 'feiertage', 'vorOrtArten', 'vorlage', 'kleinunternehmer', 'vorlaufMin', 'maxOffeneJeEmail', 'raster']) {
assert.ok(!json.includes(geheim), `oeffentlich enthaelt ${geheim}`)
}
})
test('oeffentlicheEinstellung: nur aktive Leistungen mit aktiver Ressource, nur aktive Ressourcen', () => {
const e = gueltig({
...basis(),
ressourcen: [
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '12:00']] } },
{ id: R2, name: 'Ben', typ: 'platz', aktiv: false, plan: { 1: [['09:00', '12:00']] } },
],
leistungen: [
{ id: S1, name: 'Beide', dauer: 30, ressourcen: [R2, R1] },
{ id: S2, name: 'Ruht', dauer: 30, ressourcen: [R1], aktiv: false },
{ id: 's_bbbbbbb3', name: 'Nur Ben', dauer: 30, ressourcen: [R2], aktiv: false },
],
})
const oe = oeffentlicheEinstellung(e, NOW)
assert.deepEqual(oe.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }])
assert.deepEqual(oe.leistungen.map((l) => l.id), [S1])
assert.deepEqual(oe.leistungen[0].ressourcen, [R1])
// Ressource nachtraeglich deaktiviert (Daten aus dem Speicher): Leistung ohne aktive Ressource verschwindet
const roh = structuredClone(e)
roh.ressourcen[0].aktiv = false
assert.deepEqual(oeffentlicheEinstellung(roh, NOW).leistungen, [])
assert.equal(oeffentlicheEinstellung(roh, NOW).widerruf, false)
})
test('oeffentlicheEinstellung: storno null ohne Text, widerruf bei Verbraucher mit sichtbarer Leistung (sofort und Anfrage, R55), Kleinunternehmer-Hinweis', () => {
const sofort = gueltig(basis())
assert.equal(oeffentlicheEinstellung(sofort, NOW).storno, null)
assert.equal(oeffentlicheEinstellung(sofort, NOW).widerruf, true)
const anfrage = gueltig({ ...basis(), leistungen: [{ id: S1, name: 'A', dauer: 30, ressourcen: [R1], buchungsart: 'anfrage' }] })
assert.equal(oeffentlicheEinstellung(anfrage, NOW).widerruf, true, 'R55: auch eine Anfrage ist ein Fernabsatzvertrag')
const unternehmer = gueltig({ ...basis(), recht: { gaesteSind: 'unternehmer' } })
assert.equal(oeffentlicheEinstellung(unternehmer, NOW).widerruf, false)
assert.equal(oeffentlicheEinstellung(unternehmer, NOW).leistungen[0].verbraucher, false)
const freizeit = gueltig({ ...basis(), recht: { freizeit: true } })
assert.equal(oeffentlicheEinstellung(freizeit, NOW).widerruf, false)
const klein = gueltig({ ...basis(), betrieb: { ...PFLICHT, name: 'X', email: 'a@b.de', kleinunternehmer: true } })
assert.equal(oeffentlicheEinstellung(klein, NOW).preisHinweis, 'kein Ausweis der USt nach § 19 UStG')
})
test('oeffentlicheEinstellung: heute ist der Berliner Tag (kurz nach Mitternacht)', () => {
const e = gueltig(basis())
assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T22:30:00.000Z')).heute, '2026-10-03')
assert.equal(oeffentlicheEinstellung(e, new Date('2026-10-02T21:59:00.000Z')).heute, '2026-10-02')
})
+227
View File
@@ -0,0 +1,227 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4):
// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen
// vom Server: monat, tage, zeiten, datumLang). Darum kein Date, getDay, toLocale*, Intl.
// - Kein Browser-Speicher, keine Cookies, kein Auslesen von Referrer oder Zeitzone, keine Fremdadressen im Code.
// - Kundentext nur ueber textContent/Attribute: innerHTML nur mit festen Zeichenketten.
// Die Pruefung der Seiten drumherum (Demo, Einbauanleitung) steht am Ende.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/embed/termine.js')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'termine.js' }))
})
test('kein Browser-Speicher, keine Cookies, kein Referrer, keine Zeitzone', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions', 'getTimezoneOffset']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine Datums- und Zeitzonenrechnung im Browser (Date, getDay, toLocale, Intl.)', () => {
assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen')
for (const verboten of ['getDay', 'toLocale', 'Intl.', 'getFullYear', 'getMonth', 'getHours', 'getTime(', 'Date.now']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('innerHTML nur mit festen Zeichenketten; kein outerHTML, insertAdjacentHTML, document.write, eval', () => {
const treffer = [...OHNE_KOMMENTARE.matchAll(/innerHTML/g)]
for (const t of treffer) {
const rest = OHNE_KOMMENTARE.slice(t.index + 'innerHTML'.length)
assert.match(rest, /^\s*=\s*(?:'[^'\\+]*'|"[^"\\+]*")\s*(?:;|\n|\))/, 'innerHTML darf nur eine feste Zeichenkette bekommen')
}
for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`)
}
})
test('keine festen Fremdadressen im Code (nur die Adresse aus data-api oder dem Skript)', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('Anfragen ohne Cookies und nur an die Termine-Schnittstelle', () => {
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes('/api/public/termine/v1/'))
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (eine Stelle fuer alle Anfragen)')
})
test('Pflichtbausteine der Spezifikation (Einbau, Stile, Honeypot, Fuss, Texte)', () => {
for (const noetig of [
'data-webklar-termine', 'data-api', 'data-modus', 'data-knopf', 'data-leistung',
"'wkt-style'", '@layer wkt', 'wkt-',
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
'kontakt_url', 'autocomplete', 'tabindex', 'aria-hidden', 'aria-live', 'polite', 'aria-selected', 'aria-disabled', 'role', 'dialog',
'performance.now', '100 * 60 * 1000', 'neu_laden', '/widerruf',
'Vertrag widerrufen', 'Datenschutz', 'Terminbuchung über WEBklar', 'Widerruf bestätigen',
'Gerade nicht erreichbar. Bitte rufen Sie an: ',
'Fast geschafft', 'widerrufsbelehrung', 'details',
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
})
test('Stile stehen einmal in <style id="wkt-style">, alle Klassen beginnen mit wkt-', () => {
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
assert.ok(css, 'CSS-Block fehlt')
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1]).filter((k) => !/^\d/.test(k))
assert.ok(klassen.length > 20)
for (const k of klassen) assert.ok(k.startsWith('wkt-'), `Klasse ${k} ohne Praefix wkt-`)
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
})
test('Ausweichtext wird nie verbraucht (nur Kopien zurueck auf die Seite), Link auf die AGB nur als https-Link', () => {
assert.ok(!/appendChild\(ausweich\)/.test(OHNE_KOMMENTARE), 'der Ausweichtext darf nur als cloneNode(true) zurueckgesetzt werden')
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
assert.ok(QUELLE.includes('agbUrl') && QUELLE.includes('AGB des Betriebs'))
assert.match(OHNE_KOMMENTARE, /httpsUrl\(c\.betrieb\.agbUrl\)/)
})
// ---------------------------------------------------------------- Telefonnummer, Formular-Token, Widerrufsknoepfe (Abschlussrunde)
/** Die reine Funktion telefonLink des Skripts (zwischen den Marken @telefon-anfang und @telefon-ende) einzeln ausfuehren. */
function telefonFunktion() {
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
return vm.runInNewContext(`${m[1]}; telefonLink`, {})
}
test('telefonLink: Nummern werden gewaehlt, freier Text nie (Buchstaben, Listen), "(0)" hinter der Landesvorwahl entfaellt', () => {
const telefonLink = telefonFunktion()
const faelle = [
['06301 1234', 'tel:063011234'],
['+49 6301 1234', 'tel:+4963011234'],
['+49 (0) 6301-1234', 'tel:+4963011234'],
['+49(0)6301 1234', 'tel:+4963011234'],
['+49 (0)6301/12 34 5', 'tel:+49630112345'],
['0049 (0) 6301 1234', 'tel:004963011234'],
['(06301) 1234', 'tel:063011234'],
['06301/1234-0', 'tel:0630112340'],
['06301.1234', 'tel:063011234'],
[' +49 160 1234567 ', 'tel:+491601234567'],
['(0)6301 1234', 'tel:063011234'],
// Text mit Buchstaben oder Mehrfachangaben: kein Link
['06301 1234 (ab 9 Uhr)', ''],
['Tel. 06301 1234', ''],
['06301 1234 oder 0171 555', ''],
['0171 1, 0171 2', ''],
['06301 1234; 0171 2', ''],
['ruf an', ''],
['+49 (0) 6301 1234 ext. 5', ''],
// keine Nummer
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
test('R53: das Widget wartet vor dem Absenden, bis das Formular-Token formMinSekunden plus Puffer alt ist (Einen Moment …), Messung mit performance.now', () => {
for (const noetig of ['formMinSekunden', 'FORM_PUFFER_MS', 'FORM_MIN_VORGABE_S', 'warteAufFormToken', 'Einen Moment …']) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/, 'Puffer 0,5 s')
assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/, 'Vorgabe 3 s wie der Server')
// der Abruf der Buchung steht erst nach dem Warten
const sende = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function sende ('))
assert.ok(sende.indexOf('warteAufFormToken(senden)') > 0 && sende.indexOf("request('POST', '/buchungen'") > sende.indexOf('warteAufFormToken(senden)'), 'POST /buchungen nur nach warteAufFormToken')
assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/buchungen'/g)].length, 1, 'genau eine Stelle sendet Buchungen')
})
test('R54: Widerruf im Fuss ist ein zweiter Knopf (wkt-btn wkt-btn-sek, 44 px), kein Textlink; im Knopfmodus ein Abruf beim Laden und ein dauerhafter Knopf', () => {
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-btn wkt-btn-sek', 'Vertrag widerrufen'"), 'Fuss: sekundaerer Knopf')
assert.doesNotMatch(OHNE_KOMMENTARE, /wkt-link/, 'kein Textlink-Knopf')
assert.match(QUELLE, /\.wkt-btn,\.wkt-knopf\{[^}]*min-height:44px/, 'Knoepfe mindestens 44 px hoch')
// Knopfmodus: neben dem Buchungsknopf, oeffnet den Dialog gleich beim Widerruf
assert.ok(OHNE_KOMMENTARE.includes("knopf('wkt-knopf wkt-btn-sek', 'Vertrag widerrufen'"), 'dauerhafter Knopf im Knopfmodus')
assert.ok(OHNE_KOMMENTARE.includes('oeffne(true)'), 'der Knopf oeffnet den Dialog beim Widerrufsformular')
const start = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function startKnopf'))
assert.ok(start.includes('lade()') && start.indexOf('lade()') < start.indexOf('widerrufKnopf = knopf'), 'startKnopf holt die Konfiguration beim Laden')
})
// ---------------------------------------------------------------- Textfarbe auf der Betriebsfarbe (Kontrast)
/** Die reinen Farbfunktionen des Skripts (zwischen den Marken @farben-anfang und @farben-ende) einzeln ausfuehren. */
function farbFunktionen() {
const m = /\/\/ @farben-anfang[^\n]*\n([\s\S]*?)\/\/ @farben-ende/.exec(QUELLE)
assert.ok(m, 'Marken @farben-anfang/@farben-ende fehlen')
return vm.runInNewContext(`${m[1]}; ({ luminanz, kontrast, textFarbeAuf })`, {})
}
// Unabhaengige Gegenrechnung (WCAG 2.1: relative Leuchtdichte und Kontrastverhaeltnis)
function wcagKontrast(hexA, hexB) {
const l = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [x, y] = [l(hexA), l(hexB)]
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
test('Textfarbe auf der Betriebsfarbe erreicht mindestens 4,5 : 1 (auch fuer mittlere Toene)', () => {
const { textFarbeAuf } = farbFunktionen()
// Mitteltoene, bei denen Weiss allein nur 2,5 bis 3,6 : 1 haette
for (const farbe of ['#ea580c', '#16a34a', '#0ea5e9', '#14b8a6', '#ec4899']) {
const text = textFarbeAuf(farbe)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(wcagKontrast(farbe, text) >= wcagKontrast(farbe, '#ffffff') - 1e-9, `${farbe}: Weiss waere besser gewesen`)
}
// dunkle und helle Farben behalten Weiss bzw. das dunkle Grau
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#1d4ed8'), '#ffffff')
assert.equal(textFarbeAuf('#fde68a'), '#111827')
assert.equal(textFarbeAuf('#ffffff'), '#111827')
assert.equal(textFarbeAuf('#000000'), '#ffffff')
})
test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raster ueber den Farbraum)', () => {
const { textFarbeAuf, luminanz, kontrast } = farbFunktionen()
const hex = (r, g, b) => `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`
const proben = []
for (let g = 0; g < 256; g += 1) proben.push(hex(g, g, g))
for (let r = 0; r < 256; r += 15) for (let g = 0; g < 256; g += 15) for (let b = 0; b < 256; b += 15) proben.push(hex(r, g, b))
for (const farbe of proben) {
const text = textFarbeAuf(farbe)
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: unerwartete Textfarbe ${text}`)
assert.ok(wcagKontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${wcagKontrast(farbe, text).toFixed(2)} : 1`)
assert.ok(Math.abs(kontrast(luminanz(farbe), luminanz(text)) - wcagKontrast(farbe, text)) < 1e-9, `${farbe}: Skript und Gegenrechnung weichen ab`)
}
})
// ---------------------------------------------------------------- Demo und Einbauanleitung
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
const demo = lies('public/embed/termine-demo.html')
assert.match(demo, /<meta name="robots" content="noindex"/)
assert.match(demo, /<script src="\/embed\/termine\.js" defer><\/script>/)
assert.match(demo, /data-webklar-termine="abcdefghijklmnop"/)
assert.match(demo, /data-modus="inline"/)
assert.match(demo, /data-modus="knopf"/)
assert.match(demo, /data-knopf="/)
assert.match(demo, /Termine telefonisch/, 'Ausweichtext fehlt')
})
test('Einbauanleitung: Snippet, Variablen, drei Stile, Datenschutz-Absatz, Abnahme-Checkliste, API-Kurzreferenz', () => {
const doc = lies('docs/funktionen/termine-einbau.md')
for (const noetig of [
'<div data-webklar-termine="SITEKEY"', '<script src="https://project.webklar.com/embed/termine.js" defer></script>',
'data-modus', 'data-api', 'data-knopf', 'data-leistung',
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
'@layer wkt', 'wkt-style',
'docs/rechtliches/termine-datenschutz-absatz.md', 'ENTWURF',
'Abnahme', '/api/public/termine/v1', '/config', '/tage', '/zeiten', '/buchungen', '/widerruf', '/kalender/',
'termine-demo.html', 'termine-widget.ui.mjs',
]) {
assert.ok(doc.includes(noetig), `${noetig} fehlt in der Einbauanleitung`)
}
assert.equal([...doc.matchAll(/^### Stil \d/gm)].length, 3, 'drei Stilbeispiele')
assert.ok([...doc.matchAll(/^- \[ \] /gm)].length >= 8, 'Abnahme-Checkliste mit Punkten')
})
+363
View File
@@ -0,0 +1,363 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
// Kalenderdatei (RFC 5545/5546) und Gaesteversand. Kein Netz: der Mailversand laeuft ueber die Senke, config.js braucht
// nur eine gueltige Umgebung.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { icsTermin, icsKalender } = await import('../server/services/termine/ics.js')
const { sendGuestMail, setMailTransportForTests } = await import('../server/services/mailer.js')
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-02T09:00:00.000Z')
const termin = (extra = {}) => ({
uid: 'b_9f3a1c@termine.webklar.com',
sequenz: 2,
methode: 'REQUEST',
start: '2026-10-05T10:00:00.000+02:00', // 08:00 UTC
ende: new Date('2026-10-05T08:45:00.000Z'),
titel: 'Haarschnitt – Salon Muster',
ort: 'Hauptstraße 1, 67655 Kaiserslautern',
beschreibung: 'Buchungsnummer AB12CD34',
organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' },
teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' },
jetzt: JETZT,
...extra,
})
const enc = new TextEncoder()
const octets = (s) => enc.encode(s).length
/**
* Jede physische Zeile hoechstens 75 Oktette und wohlgeformtes UTF-16 (kein halbes Surrogatpaar). Ein Decode-Test
* (TextDecoder) taugt dafuer nicht: JS-Strings kodieren immer, einzelne Surrogate werden still zu U+FFFD.
*/
const pruefeZeilen = (text) => {
for (const zeile of text.split('\r\n')) {
assert.ok(octets(zeile) <= 75, `Zeile zu lang (${octets(zeile)}): ${zeile}`)
assert.ok(zeile.isWellFormed(), `Zeile mit halbem Surrogatpaar: ${JSON.stringify(zeile)}`)
}
}
/** Zeilenfaltung rueckgaengig machen: CRLF + Leerzeichen faellt weg. */
const entfalte = (text) => text.replace(/\r\n /g, '')
/** Logische Zeilen (entfaltet, ohne das abschliessende CRLF). */
const zeilen = (text) => entfalte(text).split('\r\n').slice(0, -1)
const entescape = (s) => s.replace(/\\([\\;,nN])/g, (_, c) => (c === 'n' || c === 'N' ? '\n' : c))
const wert = (text, name) => zeilen(text).find((l) => l.startsWith(`${name}:`) || l.startsWith(`${name};`))
test('Aufbau: CRLF, Kopf, genau ein Termin, UTC-Zeiten, UID, SEQUENCE, DTSTAMP', () => {
const ics = icsTermin(termin())
assert.ok(ics.endsWith('END:VCALENDAR\r\n'), 'endet mit CRLF')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics), 'nur CRLF, kein einzelnes CR oder LF')
assert.deepEqual(zeilen(ics), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
'UID:b_9f3a1c@termine.webklar.com',
'DTSTAMP:20261002T090000Z',
'SEQUENCE:2',
'DTSTART:20261005T080000Z',
'DTEND:20261005T084500Z',
'SUMMARY:Haarschnitt – Salon Muster',
'DESCRIPTION:Buchungsnummer AB12CD34',
'LOCATION:Hauptstraße 1\\, 67655 Kaiserslautern',
'ORGANIZER;CN="Salon Muster":mailto:info@salon-muster.de',
'ATTENDEE;CN="Anna Müller";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com',
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR',
])
})
test('Zeiten: Date, ISO mit Zone und Millisekunden werden gleich zu UTC; Sekundenbruchteile fallen weg', () => {
const a = icsTermin(termin({ start: new Date('2026-10-05T08:00:00.999Z'), ende: Date.UTC(2026, 9, 5, 8, 45) }))
assert.equal(wert(a, 'DTSTART'), 'DTSTART:20261005T080000Z')
assert.equal(wert(a, 'DTEND'), 'DTEND:20261005T084500Z')
// Sommerzeitende (Berlin 2026-10-25): 02:30 +01:00 ist 01:30 UTC
const b = icsTermin(termin({ start: '2026-10-25T02:30:00+01:00', ende: '2026-10-25T03:00:00+01:00' }))
assert.equal(wert(b, 'DTSTART'), 'DTSTART:20261025T013000Z')
assert.equal(wert(b, 'DTEND'), 'DTEND:20261025T020000Z')
})
test('Zeilenlaenge: jede Zeile hoechstens 75 Oktette, nie mitten in einem UTF-8-Zeichen gefaltet', () => {
for (const baustein of ['a', 'ä', '€', '🙂', 'Ä߀🙂']) {
for (let n = 40; n < 130; n += 1) {
const titel = baustein.repeat(n)
const ics = icsTermin(termin({ titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }))
pruefeZeilen(ics)
for (const folge of ics.split('\r\n').filter((l) => l.startsWith(' '))) assert.ok(folge.length > 1, 'Folgezeile nicht leer')
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel, 'Entfalten ergibt den Titel wieder')
}
}
})
test('Zeilenlaenge: Emoji (Surrogatpaare) werden nie zwischen ihren Haelften gefaltet, in Termin und Abo', () => {
let gefaltet = 0
for (const vorsatz of ['', 'a', 'ab', 'abc']) {
for (let n = 1; n < 60; n += 1) {
const titel = vorsatz + '😀'.repeat(n)
const felder = { titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }
const ics = icsTermin(termin(felder))
pruefeZeilen(ics)
gefaltet += ics.split('\r\n').filter((l) => l.startsWith(' ')).length
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel)
assert.equal(entescape(wert(ics, 'DESCRIPTION').slice('DESCRIPTION:'.length)), titel)
const feed = icsKalender([termin(felder)], { name: titel, jetzt: JETZT })
pruefeZeilen(feed)
assert.equal(entescape(wert(feed, 'X-WR-CALNAME').slice('X-WR-CALNAME:'.length)), titel)
}
}
assert.ok(gefaltet > 500, `der Test faltet wirklich (${gefaltet} Folgezeilen)`)
// Beispiel: DESCRIPTION: ist 12 Oktette lang, danach passen 15 Emoji (60 Oktette) in die erste Zeile, das 16. beginnt die Folgezeile
const beschreibung = '😀'.repeat(16)
const zeilen = icsTermin(termin({ beschreibung })).split('\r\n')
const i = zeilen.findIndex((l) => l.startsWith('DESCRIPTION:'))
assert.equal(zeilen[i], `DESCRIPTION:${'😀'.repeat(15)}`)
assert.equal(zeilen[i + 1], ' 😀')
})
test('Zeilenlaenge: eine Zeile von genau 75 Oktetten bleibt ungefaltet, 76 wird gefaltet', () => {
const zeileMit = (n) => icsTermin(termin({ titel: 'x'.repeat(n - 'SUMMARY:'.length) })).split('\r\n').find((l) => l.startsWith('SUMMARY:'))
assert.equal(octets(zeileMit(75)), 75)
const ics76 = icsTermin(termin({ titel: 'x'.repeat(76 - 'SUMMARY:'.length) }))
assert.match(ics76, /SUMMARY:x{67}\r\n x\r\n/)
})
test('Escaping: Backslash, Semikolon, Komma und Zeilenumbrueche in TEXT-Werten', () => {
const roh = 'a\\b;c,d\ne\r\nf\rg'
const ics = icsTermin(termin({ titel: roh, ort: roh, beschreibung: roh }))
const erwartet = 'a\\\\b\\;c\\,d\\ne\\nf\\ng'
assert.equal(wert(ics, 'SUMMARY'), `SUMMARY:${erwartet}`)
assert.equal(wert(ics, 'LOCATION'), `LOCATION:${erwartet}`)
assert.equal(wert(ics, 'DESCRIPTION'), `DESCRIPTION:${erwartet}`)
assert.equal(entescape(erwartet), 'a\\b;c,d\ne\nf\ng')
})
test('Einschleusen: CR/LF und Steuerzeichen in Eingaben erzeugen keine zusaetzlichen Zeilen', () => {
const boese = 'x\r\nATTENDEE:mailto:boese@example.com\r\nEND:VEVENT'
const ics = icsTermin(
termin({
uid: `uid${boese}`,
titel: boese,
ort: boese,
beschreibung: boese,
organisator: { name: `Org${boese}`, email: `org@example.com${boese}` },
teilnehmer: { name: `Gast${boese}\u0000\u0007`, email: `gast@example.com${boese}` },
})
)
const logisch = zeilen(ics)
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 1, 'genau ein ATTENDEE')
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 1, 'genau ein END:VEVENT')
assert.equal(logisch.filter((l) => l.startsWith('UID:')).length, 1)
assert.ok(!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(ics), 'keine Steuerzeichen')
assert.ok(!/[^\r]\n|\r[^\n]/.test(ics))
// jede Zeile ist eine bekannte Eigenschaft (kein eingeschleuster Name)
for (const l of logisch) assert.match(l, /^(BEGIN|END|VERSION|PRODID|CALSCALE|METHOD|UID|DTSTAMP|SEQUENCE|DTSTART|DTEND|SUMMARY|DESCRIPTION|LOCATION|ORGANIZER|ATTENDEE|STATUS|TRANSP)[:;]/)
})
test('CN="…": ohne Anfuehrungszeichen und Steuerzeichen, leerer Name laesst CN weg', () => {
const ics = icsTermin(termin({ organisator: { name: 'Salon "Chez; Muster"\n', email: 'info@salon-muster.de' }, teilnehmer: { name: '"\u0001"', email: 'anna@example.com' } }))
assert.equal(wert(ics, 'ORGANIZER'), 'ORGANIZER;CN="Salon Chez; Muster":mailto:info@salon-muster.de')
assert.equal(wert(ics, 'ATTENDEE'), 'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:anna@example.com')
})
test('E-Mail in mailto: ohne Leerraum und Trennzeichen', () => {
const ics = icsTermin(termin({ teilnehmer: { name: 'A', email: ' anna@example.com,boese@example.com ' } }))
assert.match(wert(ics, 'ATTENDEE'), /:mailto:anna@example\.comboese@example\.com$/)
})
test('ohne Teilnehmer, Ort und Beschreibung entfallen die Zeilen', () => {
const ics = icsTermin(termin({ teilnehmer: null, ort: '', beschreibung: undefined }))
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'LOCATION'), undefined)
assert.equal(wert(ics, 'DESCRIPTION'), undefined)
assert.ok(wert(ics, 'ORGANIZER'))
})
test('CANCEL: gleiche UID, hoehere Sequenz, STATUS:CANCELLED', () => {
const ics = icsTermin(termin({ methode: 'CANCEL', sequenz: 3 }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:CANCEL')
assert.equal(wert(ics, 'UID'), 'UID:b_9f3a1c@termine.webklar.com')
assert.equal(wert(ics, 'SEQUENCE'), 'SEQUENCE:3')
assert.equal(wert(ics, 'STATUS'), 'STATUS:CANCELLED')
assert.match(wert(ics, 'ATTENDEE'), /;RSVP=FALSE:mailto:anna@example\.com$/)
assert.equal(zeilen(icsTermin(termin())).includes('STATUS:CANCELLED'), false, 'REQUEST ist nicht abgesagt')
})
test('PUBLISH (Betriebsmail, Abo): kein ATTENDEE, Organisator bleibt', () => {
const ics = icsTermin(termin({ methode: 'PUBLISH' }))
assert.equal(wert(ics, 'METHOD'), 'METHOD:PUBLISH')
assert.equal(wert(ics, 'ATTENDEE'), undefined)
assert.equal(wert(ics, 'STATUS'), 'STATUS:CONFIRMED')
assert.ok(wert(ics, 'ORGANIZER'))
})
test('Pflichtangaben: unbekannte Methode, ungueltige Zeiten, fehlende Felder werfen', () => {
assert.throws(() => icsTermin(termin({ methode: 'REPLY' })), TypeError)
assert.throws(() => icsTermin(termin({ methode: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ start: 'morgen' })), TypeError)
assert.throws(() => icsTermin(termin({ ende: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ ende: '2026-10-05T08:00:00Z' })), TypeError, 'Ende nicht nach Beginn')
assert.throws(() => icsTermin(termin({ jetzt: undefined })), TypeError)
assert.throws(() => icsTermin(termin({ jetzt: '2026-10-02' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '' })), TypeError)
assert.throws(() => icsTermin(termin({ uid: '\r\n' })), TypeError)
assert.throws(() => icsTermin(termin({ titel: '' })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: -1 })), TypeError)
assert.throws(() => icsTermin(termin({ sequenz: 1.5 })), TypeError)
assert.throws(() => icsTermin(termin({ organisator: null })), TypeError, 'REQUEST braucht den Organisator')
assert.throws(() => icsTermin(termin({ methode: 'CANCEL', organisator: undefined })), TypeError)
})
test('icsKalender: PUBLISH-Feed mit Name, Aktualisierungshinweis, mehreren Terminen und ohne Gaeste', () => {
const feed = icsKalender(
[
termin({ uid: 'b_1@termine.webklar.com', teilnehmer: { name: 'Geheim', email: 'geheim@example.com' } }),
{ uid: 'x_sperre@termine.webklar.com', sequenz: 0, start: '2026-10-06T12:00:00Z', ende: '2026-10-06T13:00:00Z', titel: 'Gesperrt', organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' } },
],
{ name: 'Salon Muster, Stuhl 1; Süd', jetzt: JETZT }
)
assert.ok(feed.endsWith('END:VCALENDAR\r\n'))
assert.ok(!/[^\r]\n|\r[^\n]/.test(feed))
for (const zeile of feed.split('\r\n')) assert.ok(octets(zeile) <= 75)
const logisch = zeilen(feed)
assert.deepEqual(logisch.slice(0, 7), [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//WEBklar//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Salon Muster\\, Stuhl 1\\; Süd',
'X-PUBLISHED-TTL:PT15M',
])
assert.equal(logisch.filter((l) => l === 'BEGIN:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'END:VEVENT').length, 2)
assert.equal(logisch.filter((l) => l === 'DTSTAMP:20261002T090000Z').length, 2, 'DTSTAMP = jetzt')
assert.equal(logisch.filter((l) => l.startsWith('ATTENDEE')).length, 0, 'keine Gaeste im Abo')
assert.ok(!/geheim@example\.com/.test(feed))
assert.deepEqual(logisch.filter((l) => l.startsWith('UID:')), ['UID:b_1@termine.webklar.com', 'UID:x_sperre@termine.webklar.com'])
assert.deepEqual(logisch.filter((l) => l.startsWith('STATUS:')), ['STATUS:CONFIRMED', 'STATUS:CONFIRMED'])
})
test('icsKalender: leerer Feed ist ein gueltiger Kalender; ohne jetzt oder Name-Typ wirft', () => {
const leer = icsKalender([], { name: 'Leer', jetzt: JETZT })
assert.deepEqual(zeilen(leer).filter((l) => l.startsWith('BEGIN:') || l.startsWith('END:')), ['BEGIN:VCALENDAR', 'END:VCALENDAR'])
assert.throws(() => icsKalender([], { name: 'Leer' }), TypeError)
assert.throws(() => icsKalender([termin({ start: 'x' })], { name: 'Leer', jetzt: JETZT }), TypeError)
})
// ---------------------------------------------------------------------------------------------------------------------
// sendGuestMail
// ---------------------------------------------------------------------------------------------------------------------
const gastMail = (extra = {}) => ({
to: 'anna@example.com',
fromName: 'Salon Muster',
subject: 'Ihr Termin bei Salon Muster am 05.10.2026',
html: '<p>Hallo</p>',
text: 'Hallo',
...extra,
})
async function mitSenke(fn, from = 'noreply@webklar.com') {
const vorher = config.smtp.from
config.smtp.from = from
const senke = mailSink()
setMailTransportForTests(senke)
try {
return await fn(senke)
} finally {
setMailTransportForTests(null)
config.smtp.from = vorher
}
}
test('sendGuestMail: Absendername = Betrieb, Adresse = SMTP_FROM, replyTo = Betrieb, nie BCC, ohne ics kein icalEvent', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ replyTo: 'info@salon-muster.de' }))
assert.equal(senke.sent.length, 1)
const [m] = senke.sent
assert.deepEqual(m.from, { address: 'noreply@webklar.com', name: 'Salon Muster' })
assert.deepEqual(m.to, [{ address: 'anna@example.com', name: '' }])
assert.deepEqual(m.replyTo, [{ address: 'info@salon-muster.de', name: '' }])
assert.equal(m.subject, 'Ihr Termin bei Salon Muster am 05.10.2026')
assert.equal(m.html, '<p>Hallo</p>')
assert.equal(m.text, 'Hallo')
assert.equal('bcc' in m, false, 'nie BCC')
assert.equal('cc' in m, false)
assert.equal('icalEvent' in m, false, 'ohne ics keine Kalenderdatei')
assert.deepEqual(m.attachments ?? [], [])
})
})
test('sendGuestMail: ohne replyTo keine Antwortadresse, auch nicht die des Portals', async () => {
await mitSenke(async (senke) => {
const vorher = config.smtp.replyTo
config.smtp.replyTo = 'support@webklar.com'
try {
await sendGuestMail(gastMail())
} finally {
config.smtp.replyTo = vorher
}
assert.equal(senke.sent[0].replyTo ?? null, null, 'nodemailer schreibt eine fehlende Antwortadresse als null')
assert.equal('bcc' in senke.sent[0], false)
})
})
test('sendGuestMail: mit ics geht die Kalenderdatei als icalEvent (Standard REQUEST, termin.ics)', async () => {
await mitSenke(async (senke) => {
const ics = icsTermin(termin())
await sendGuestMail(gastMail({ ics }))
const m = senke.sent[0]
assert.equal(m.icalEvent.method, 'REQUEST')
assert.equal(m.icalEvent.filename, 'termin.ics')
assert.equal(m.icalEvent.content, ics)
assert.match(m.icalEvent.content, /BEGIN:VCALENDAR/)
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: icsMethod CANCEL und PUBLISH werden uebernommen, andere Methoden abgelehnt', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'CANCEL' })), icsMethod: 'CANCEL' }))
assert.equal(senke.sent[0].icalEvent.method, 'CANCEL')
await sendGuestMail(gastMail({ ics: icsTermin(termin({ methode: 'PUBLISH' })), icsMethod: 'PUBLISH' }))
assert.equal(senke.sent[1].icalEvent.method, 'PUBLISH')
await assert.rejects(sendGuestMail(gastMail({ ics: 'x', icsMethod: 'REPLY' })), TypeError)
assert.equal(senke.sent.length, 2)
})
})
test('sendGuestMail: Betreff und Absendername ohne Zeilenumbrueche; Anfuehrungszeichen im Namen bleiben heil', async () => {
await mitSenke(async (senke) => {
await sendGuestMail(gastMail({ fromName: 'Salon "Chez, Muster"\r\nBcc: boese@example.com', subject: 'Termin\r\nBcc: boese@example.com' }))
const m = senke.sent[0]
assert.ok(!/[\r\n]/.test(m.subject))
assert.ok(!/[\r\n]/.test(m.from.name))
assert.equal(m.from.address, 'noreply@webklar.com')
assert.equal('bcc' in m, false)
})
})
test('sendGuestMail: genau ein Postfach als Empfaenger und Antwortadresse', async () => {
await mitSenke(async (senke) => {
for (const to of ['a@example.com, b@example.com', 'a@example.com;b@example.com', 'Max <a@example.com>', '"a b"@example.com', 'a@example.com\r\nBcc: b@example.com', '', undefined, 'ohne-at', `${'a'.repeat(250)}@example.com`]) {
await assert.rejects(sendGuestMail(gastMail({ to })), TypeError, String(to))
}
await assert.rejects(sendGuestMail(gastMail({ replyTo: 'a@example.com, b@example.com' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ subject: '' })), TypeError)
await assert.rejects(sendGuestMail(gastMail({ fromName: '' })), TypeError)
assert.equal(senke.sent.length, 0)
})
})
test('sendGuestMail: ohne SMTP-Zugang wirft sie (wie sendCustomerMail)', async () => {
setMailTransportForTests(null)
assert.equal(config.smtp.host, '', 'Testumgebung ohne SMTP')
await assert.rejects(sendGuestMail(gastMail()), /SMTP nicht konfiguriert/)
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+221
View File
@@ -0,0 +1,221 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
zaehle,
zaehleAlle,
darfG08,
darfK21NichtZugeordnet,
darfAnfrage,
darfAnfrageSite,
darfBuchungsversuch,
darfHaltung,
darfWiderrufsversuch,
darfG01,
ipSchluessel,
resetTermineLimitsForTests,
} from '../server/services/termine/limits.js'
// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel.
const T0 = new Date('2026-10-05T08:00:00.000Z')
const STUNDE = 60 * 60 * 1000
const TAG = 24 * STUNDE
const MINUTE = 60 * 1000
const plus = (ms) => new Date(T0.getTime() + ms)
beforeEach(() => resetTermineLimitsForTests())
test('zaehle: erlaubt bis max, danach false; das Fenster beginnt mit dem ersten Ereignis und endet nach fensterMs', () => {
assert.deepEqual([1, 2, 3].map(() => zaehle('a', 3, STUNDE, T0)), [true, true, true])
assert.equal(zaehle('a', 3, STUNDE, plus(10 * 60000)), false)
assert.equal(zaehle('a', 3, STUNDE, plus(STUNDE - 1)), false, 'bis zur letzten Millisekunde des Fensters')
assert.equal(zaehle('b', 3, STUNDE, T0), true, 'andere Schluessel zaehlen getrennt')
// die abgelehnten Anfragen haben nichts gezaehlt: das neue Fenster (ab plus(STUNDE)) erlaubt wieder drei
assert.deepEqual([1, 2, 3, 4].map((i) => zaehle('a', 3, STUNDE, plus(STUNDE + i))), [true, true, true, false])
})
test('zaehle: max 0 erlaubt nie, ungueltige Angaben werfen TypeError', () => {
assert.equal(zaehle('a', 0, STUNDE, T0), false)
assert.throws(() => zaehle('a', 1.5, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', -1, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', 1, 0, T0), TypeError)
assert.throws(() => zaehle('', 1, STUNDE, T0), TypeError)
assert.throws(() => zaehle('a', 1, STUNDE, undefined), TypeError)
assert.throws(() => zaehle('a', 1, STUNDE, Date.now()), TypeError)
assert.throws(() => zaehleAlle([], T0), TypeError)
})
test('zaehleAlle: alle oder keine - scheitert eine Regel, zaehlt keine', () => {
const regeln = [
{ schluessel: 'adresse', max: 3, fensterMs: TAG },
{ schluessel: 'funktion', max: 1, fensterMs: STUNDE },
]
assert.equal(zaehleAlle(regeln, T0), true)
assert.equal(zaehleAlle(regeln, plus(1000)), false, 'die Funktionsgrenze ist erreicht')
// der Adress-Zaehler wurde dabei nicht verbraucht: nach der Stunde sind noch zwei Ereignisse an diese Adresse frei
const nurAdresse = [{ schluessel: 'adresse', max: 3, fensterMs: TAG }]
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true)
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true)
assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), false)
})
test('darfG08: 3 je Adresse und Tag (auch ueber Funktionen), 20 je Funktion und Stunde', () => {
assert.deepEqual([1, 2, 3, 4].map(() => darfG08('f1', 'max@example.com', T0)), [true, true, true, false])
assert.equal(darfG08('f2', 'max@example.com', plus(TAG - 1)), false, 'die Adresse zaehlt fuer alle Funktionen zusammen')
assert.equal(darfG08('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
resetTermineLimitsForTests()
const erlaubt = Array.from({ length: 22 }, (_, i) => darfG08('f1', `gast${i}@example.com`, T0))
assert.equal(erlaubt.filter(Boolean).length, 20)
assert.equal(darfG08('f2', 'neu@example.com', T0), true, 'andere Funktion hat ihr eigenes Kontingent')
assert.equal(darfG08('f1', 'spaet@example.com', plus(STUNDE)), true, 'nach einer Stunde wieder')
})
test('darfK21NichtZugeordnet: 30 je Funktion und Stunde', () => {
assert.equal(Array.from({ length: 32 }, () => darfK21NichtZugeordnet('f1', T0)).filter(Boolean).length, 30)
assert.equal(darfK21NichtZugeordnet('f2', T0), true)
assert.equal(darfK21NichtZugeordnet('f1', plus(STUNDE)), true)
})
test('Obergrenze: ueber 10 000 Schluessel faellt der aelteste Zaehler weg, die neueren bleiben', () => {
assert.equal(zaehle('aeltester', 1, TAG, T0), true)
assert.equal(zaehle('aeltester', 1, TAG, T0), false)
for (let i = 0; i < 9998; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000))
assert.equal(zaehle('aeltester', 1, TAG, T0), false, 'bei 9 999 Schluesseln noch da')
assert.equal(zaehle('k9997', 1, TAG, plus(2000)), false)
zaehle('neu1', 1, TAG, plus(3000)) // 10 000
zaehle('neu2', 1, TAG, plus(3000)) // 10 001: der aelteste faellt weg
assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true, 'der aelteste Zaehler wurde verdraengt')
assert.equal(zaehle('neu2', 1, TAG, plus(4000)), false, 'der neueste bleibt')
assert.equal(zaehle('k9997', 1, TAG, plus(4000)), false, 'die juengeren bleiben')
})
test('resetTermineLimitsForTests leert alles', () => {
zaehle('a', 1, TAG, T0)
assert.equal(zaehle('a', 1, TAG, T0), false)
resetTermineLimitsForTests()
assert.equal(zaehle('a', 1, TAG, T0), true)
})
// ---------------------------------------------------------------- Aufgabe 16: IP, siteKey, G01
test('ipSchluessel: IPv4 bleibt, IPv4-in-IPv6 wird IPv4, IPv6 zaehlt je /64 (auch in anderer Schreibweise)', () => {
assert.equal(ipSchluessel('203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::ffff:203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::FFFF:203.0.113.7'), '203.0.113.7')
const a = ipSchluessel('2001:db8:1:2::1')
assert.equal(ipSchluessel('2001:0db8:0001:0002:ffff:ffff:ffff:ffff'), a, 'dieselbe /64, ausgeschrieben')
assert.equal(ipSchluessel('2001:DB8:1:2:abcd::9'), a, 'dieselbe /64, Grossbuchstaben und Kurzform')
assert.equal(ipSchluessel('2001:db8:1:2:0:0:0:0'), a)
assert.notEqual(ipSchluessel('2001:db8:1:3::1'), a, 'andere /64')
assert.notEqual(ipSchluessel('2001:db8:2:2::1'), a)
assert.equal(ipSchluessel('fe80::1%eth0'), ipSchluessel('fe80::2'), 'Zonenkennung faellt weg')
assert.equal(ipSchluessel('::1'), ipSchluessel('0:0:0:0:ffff:ffff:ffff:ffff'), 'Loopback ist die /64 ::')
assert.notEqual(ipSchluessel('203.0.113.7'), ipSchluessel('203.0.113.8'))
assert.notEqual(ipSchluessel('203.0.113.7'), a, 'IPv4 und IPv6 stossen nie zusammen')
})
test('ipSchluessel: fehlende oder unlesbare Adresse ergibt einen festen Ersatzschluessel (nie einen Fehler)', () => {
for (const wert of [undefined, null, '', ' ', 'kaputt', '1.2.3', '999.1.1.1', 42, {}]) {
assert.equal(ipSchluessel(wert), 'unbekannt', JSON.stringify(wert))
}
})
test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je siteKey und Minute', () => {
assert.equal(Array.from({ length: 125 }, () => darfAnfrage('203.0.113.7', T0)).filter(Boolean).length, 120)
assert.equal(darfAnfrage('203.0.113.8', T0), true, 'andere IP')
assert.equal(darfAnfrage('203.0.113.7', plus(MINUTE)), true, 'nach einer Minute wieder')
resetTermineLimitsForTests()
const erlaubt = Array.from({ length: 125 }, (_, i) => darfAnfrage(`2001:db8:1:2::${i + 1}`, T0))
assert.equal(erlaubt.filter(Boolean).length, 120, 'alle Adressen der /64 teilen sich 120')
assert.equal(darfAnfrage('2001:db8:1:3::1', T0), true)
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 605 }, () => darfAnfrageSite('abcdefghijklmnop', T0)).filter(Boolean).length, 600)
assert.equal(darfAnfrageSite('bcdefghijklmnopq', T0), true)
assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true)
})
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
assert.equal(darfBuchungsversuch('2001:db8::1', T0) && darfBuchungsversuch('2001:db8:0:0:1::1', T0), true, 'getrennt von der IPv4')
resetTermineLimitsForTests()
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9::${i + 1}`, T0), true)
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9:1::${i + 1}`, T0), true)
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60)
assert.equal(darfHaltung('bcdefghijklmnopq', T0), true)
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true)
})
test('darfWiderrufsversuch: 10 je IP und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfWiderrufsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfWiderrufsversuch('203.0.113.8', T0), true)
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true)
})
test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => {
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false])
assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion')
assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse')
assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false)
assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01')
})
// ---------------------------------------------------------------- Speicher: IP-Zaehler getrennt, Abgelaufene zuerst
/** Eindeutige IPv4-Adresse Nr. i (10.a.b.c). */
const ipNr = (i) => `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}`
test('Speicher: 10 000 und mehr verschiedene IPs verdraengen keinen laufenden Zaehler je Adresse, Funktion oder siteKey', () => {
// G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen
for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true)
assert.equal(darfG01('f1', 'opfer@example.com', T0), false)
for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true)
assert.equal(darfG08('f1', 'opfer@example.com', T0), true)
// ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten
for (let i = 0; i < 10500; i += 1) darfAnfrage(ipNr(i), T0)
for (let i = 0; i < 10500; i += 1) darfBuchungsversuch(ipNr(i), plus(1000))
for (let i = 0; i < 10500; i += 1) darfWiderrufsversuch(ipNr(i), plus(2000))
assert.equal(darfG01('f1', 'opfer@example.com', plus(3000)), false, 'die G01-Grenze der Adresse gilt weiter')
assert.equal(darfHaltung('abcdefghijklmnop', plus(3000)), false, 'die Haltungsgrenze gilt weiter')
assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true, 'G08 zaehlt weiter ab 1 (2 von 3 frei)')
assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), true)
assert.equal(darfG08('f1', 'opfer@example.com', plus(3000)), false, 'und die dritte war die letzte')
})
test('Speicher: eine volle Map raeumt zuerst Abgelaufene ab - ein laufender Zaehler bleibt, auch der aelteste', () => {
// der aelteste Zaehler laeuft eine Stunde; danach fuellen Minuten-Zaehler die IP-Map
for (let i = 0; i < 10; i += 1) assert.equal(darfWiderrufsversuch('203.0.113.7', T0), true)
assert.equal(darfWiderrufsversuch('203.0.113.7', T0), false)
for (let i = 0; i < 9999; i += 1) darfAnfrage(ipNr(i), T0) // Map voll (10 000 Schluessel)
// zwei Minuten spaeter sind alle Minuten-Zaehler abgelaufen: neue Schluessel schaffen Platz, ohne den laufenden zu verdraengen
for (let i = 0; i < 300; i += 1) darfAnfrage(ipNr(20000 + i), plus(2 * MINUTE))
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(2 * MINUTE)), false, 'der Stunden-Zaehler ueberlebt')
assert.equal(darfAnfrage(ipNr(5), plus(2 * MINUTE)), true, 'abgelaufene Zaehler zaehlen neu')
// dasselbe in der Map fuer alle anderen Zaehler
resetTermineLimitsForTests()
assert.equal(zaehle('alt', 1, TAG, T0), true)
for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, MINUTE, T0)
for (let i = 0; i < 300; i += 1) zaehle(`neu${i}`, 1, MINUTE, plus(2 * MINUTE))
assert.equal(zaehle('alt', 1, TAG, plus(2 * MINUTE)), false, 'der Tages-Zaehler ueberlebt')
})
test('Speicher: sind alle Zaehler noch am Laufen, faellt wie bisher der aelteste weg (Obergrenze gilt je Map)', () => {
assert.equal(zaehle('aeltester', 1, TAG, T0), true)
for (let i = 0; i < 9999; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000))
for (let i = 0; i < 10000; i += 1) darfAnfrage(ipNr(i), plus(1000)) // die IP-Map ist eine andere
assert.equal(zaehle('aeltester', 1, TAG, plus(2000)), false, 'durch die IP-Zaehler nicht verdraengt')
zaehle('neu', 1, TAG, plus(3000)) // 10 001. Schluessel der Map: der aelteste faellt weg
assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true)
assert.equal(zaehle('neu', 1, TAG, plus(4000)), false)
})
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more