Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+118 -28
View File
@@ -165,6 +165,82 @@ const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'
const ticketProjektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen).
* Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach
* dem Test) oder undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. null gilt ebenso,
* ausser bei nullIstWert (Widerrufsbelehrung): dort heisst null "keine Unterlage" (so setzen Tests eine Bestellung ohne sie).
*/
function termineRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner')
const termineAvv = termineRechtsunterlage('avv-terminplaner')
const termineWiderruf = termineRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const TERMINE_PREIS = '149 €'
const TERMINE_DAUER = 'meist innerhalb von 1–2 Wochen'
/**
* Bestelltexte des Terminplaners (Task F7): vier Haken, alle Besteller (D1), ohne Adressen im Text (die versionierten Links
* zeigt die Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.termine.avvVersion; die
* dynamischen Teile (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text.
* Die Haken werden je Fassung einmal gebaut und danach unveraendert herausgegeben (Tests aendern einzelne Texte und stellen sie
* zurueck); aendert sich die Fassung zur Laufzeit, entstehen sie neu. Bei Textaenderung neues Fassungsdatum und neuen Hash im
* Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und die Pflichtinformationen sind Entwurf bis
* zur Kanzlei (Kanzlei-Paket, Fragen Q3, Q5, Q19, Q20).
*/
function termineConsent() {
let versionGesetzt // undefined = config.termine.avvVersion
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.termine.avvVersion
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${v} zu.`,
},
{
id: 'gesundheit',
haken: true,
text: 'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung 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).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${TERMINE_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${TERMINE_DAUER}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.termine.avvVersion ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${TERMINE_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
@@ -181,19 +257,30 @@ const features = {
label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' },
priceLabel: '30 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB)
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB,
// Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind
// Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (<F>); bestehende
// Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind.
consent: {
version: '2026-09-30',
get version() {
return config.termine.avvVersion
},
knopf: 'Zahlungspflichtig bestellen · 30 €',
items: [
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus 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).',
},
],
},
leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' },
// Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = <F>
leistungsbeschreibung: {
get version() {
return config.termine.avvVersion
},
url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html',
},
avv: null,
widerruf: null,
returnHash: 'news',
@@ -220,7 +307,7 @@ const features = {
knopf: 'Beitrag schreiben',
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo',
preisHinweis: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`,
anleitung: false,
},
requiresWartungsAbo: false,
@@ -233,30 +320,29 @@ const features = {
key: 'termine',
product: 'termineSetup',
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
priceLabel: '149 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F
consent: {
version: 'ENTWURF-2026-10-03',
knopf: 'Zahlungspflichtig bestellen · 149 €',
items: [
{ id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' },
{ id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' },
{
id: 'beginn',
haken: true,
text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.',
},
{
id: 'information',
haken: true,
text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.',
},
],
priceLabel: TERMINE_PREIS,
setupEstimate: TERMINE_DAUER,
consent: termineConsent(),
// Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
get leistungsbeschreibung() {
return termineLb.get()
},
set leistungsbeschreibung(wert) {
termineLb.set(wert)
},
get avv() {
return termineAvv.get()
},
set avv(wert) {
termineAvv.set(wert)
},
get widerruf() {
return termineWiderruf.get()
},
set widerruf(wert) {
termineWiderruf.set(wert)
},
leistungsbeschreibung: { version: 'ENTWURF', url: '' },
avv: { version: 'ENTWURF', url: '' },
widerruf: null,
returnHash: 'termine',
returnProjekt: true,
ticket: {
@@ -617,6 +703,10 @@ export const config = {
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Terminplaner-Bestellunterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung, Bestelltexte, News-
// Zustimmung): Berliner Kalendertag des Fassungs-Commits (JJJJ-MM-TT). Eine ausgelieferte Fassung wird nie
// ueberschrieben: nach Uebergabe an die Kanzlei oder Livegang heisst jede Textaenderung neues Datum und neue Dateien.
avvVersion: '2026-10-05',
holdMinutes: 30,
zelleMin: 5,
mailsProStunde: 200,
+3 -3
View File
@@ -41,7 +41,7 @@ import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.js'
import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { analyseLegalRouter } from './services/analyseLegal.js'
import { legalRouter } from './services/legalDocs.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import teamRoutes from './routes/team/index.js'
import legacyAdminRedirect from './routes/legacyAdminRedirect.js'
@@ -115,8 +115,8 @@ app.use('/embed/', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*')
next()
})
// Adressen ohne Fassung: aktuelle geprüfte Fassung der Analyse-Rechtstexte (vor express.static)
app.use('/rechtliches', analyseLegalRouter())
// Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static)
app.use('/rechtliches', legalRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
@@ -9,7 +9,7 @@
Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -51,7 +51,7 @@ a{color:#111111;}
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; war die Einrichtung auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist abgeschlossen, verrechnen wir gegebenenfalls einen Wertersatz (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; hat die Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen, verrechnen wir gegebenenfalls einen Wertersatz für die bis zu Ihrem Widerruf erbrachte Leistung (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
@@ -7,13 +7,15 @@
Betreff: Deine Bestellung: {{funktion}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}, {{fassung}}, {{lb_url}}, {{avv_url}}, {{widerruf_url}}, {{zustimmungen_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu, anlagen, anhang, zustimmungen
funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen).
preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis).
danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal.
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
behalten = der Kunde behält die Funktion nach dem Ende seines Wartungs-Abos für einmalig 30 € (Öffnungszeiten live): nichts wird neu eingerichtet. neu = der Normalfall, die Einrichtung läuft jetzt los; genau einer der beiden ist an (bestellMail setzt neu = nicht behalten).
anlagen = Vertragsunterlagen der Funktion (nur Funktionen mit Rechtsdokumenten): fassung = Fassung der Texte, lb_url/avv_url/widerruf_url = versionierte Adressen von Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular. anhang (innerhalb von anlagen) = die drei PDFs hängen an der Mail (dauerhafter Datenträger); fehlt der Anhang, entfällt der Satz.
zustimmungen = Wortlaut und Zeitpunkt der erteilten Zustimmungen (zustimmungen_text, mehrzeilig) aus dem Nachweis zur bezahlten Bestellung.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -42,6 +44,12 @@ a{color:#111111;}
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung.<!-- WENN neu --> Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.<!-- ENDE neu --><!-- WENN behalten --> Du behältst {{funktion_akk}} für Dein Projekt, auch ohne Wartungs-Abo.<!-- ENDE behalten --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong><!-- WENN neu --> – Einrichtung<!-- ENDE neu --><!-- WENN behalten --> – behalten<!-- ENDE behalten --></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><!-- WENN neu --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr><!-- ENDE neu --></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;"><!-- WENN neu -->Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}<!-- ENDE neu --><!-- WENN behalten -->Deine Einstellungen und Daten bleiben unverändert, und es wird nichts neu eingebaut. Du musst nichts weiter tun.<!-- ENDE behalten --></div></td></tr></table></td></tr>
<!-- WENN anlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Vertragsunterlagen</div><div class="mut" style="color:#4a4843;">Fassung {{fassung}}: <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung</a>, <a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag</a> und <a href="{{widerruf_url}}" style="color:#111111;">Widerrufsbelehrung mit Muster-Widerrufsformular</a>.<!-- WENN anhang --> Alle drei hängen als PDF an dieser E-Mail. Bitte bewahre sie auf.<!-- ENDE anhang --></div></td></tr></table></td></tr>
<!-- ENDE anlagen -->
<!-- WENN zustimmungen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Zustimmungen</div><div class="mut" style="color:#4a4843;">{{zustimmungen_text}}</div></td></tr></table></td></tr>
<!-- ENDE zustimmungen -->
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
@@ -7,7 +7,8 @@
Betreff: [Abo] WIDERRUF eingegangen: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer).
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -61,7 +62,7 @@ a{color:#111111;}
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende):<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
@@ -8,7 +8,7 @@
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team, anhang_fehlt
funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“.
ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt.
zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer.
@@ -18,6 +18,7 @@
gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs).
abo = die Funktion wurde ohne Zahlung eingeschaltet, weil sie im Wartungs-Abo des Kunden enthalten ist (Gratisweg, der Kunde hat selbst eingeschaltet); es gibt keine Rechnung.
team = das Team hat die Funktion ohne Zahlung freigeschaltet („Aktivieren ohne Zahlung“); es gibt keine Rechnung.
anhang_fehlt = die Vertragsunterlagen (PDF) sind nicht beim Kunden angekommen (Anhang nicht lesbar, Mail nicht versandt oder Unterlagen einer anderen Fassung als bestellt); das Team sendet sie von Hand nach.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -65,6 +66,9 @@ a{color:#111111;}
<!-- WENN gesundheit -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:16px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;">Gesundheitsbereich prüfen: {{gesundheit_text}}</div></td></tr></table></td></tr>
<!-- ENDE gesundheit -->
<!-- WENN anhang_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Unterlagen von Hand nachsenden</div><div class="mut" style="color:#4a4843;">Die Vertragsunterlagen (Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular, jeweils als PDF) sind nicht mit der Bestätigung beim Kunden angekommen. Bitte dem Kunden die Unterlagen in der bestellten Fassung per E-Mail nachsenden. Der Grund steht im Log des Portals.</div></td></tr></table></td></tr>
<!-- ENDE anhang_fehlt -->
<!-- WENN anleitung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{anleitung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE anleitung -->
+33 -16
View File
@@ -19,6 +19,7 @@ import {
switchToWartung,
} from '../services/planSwitch.js'
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
import { featureConfig } from '../services/featureRegistry.js'
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
@@ -705,13 +706,15 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine']
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -784,6 +787,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
if (order === 'termine') return 'Terminplaner (Einrichtung)'
const abo = productNameOf(sub)
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
}
@@ -1092,19 +1096,29 @@ async function withdrawOrder(sub, d, receivedAt) {
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
/**
* Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags-
* ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team
* bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand.
* Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo
* und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse;
* das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von
* Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt
* (Zeit des Klicks der bezahlten Sitzung, ohne Nachweis orderedAt); das spaeteste Fristende entscheidet ueber "rechtzeitig".
* Dokumente ohne featureKey stammen aus der Zeit, als es nur die News gab.
*/
async function withdrawFeature(d, receivedAt) {
async function withdrawFeature(d, receivedAt, featureKey) {
const customer =
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
const orders = customer ? await paidFeatureOrders(customer.$id) : []
// Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt
const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt)
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
const alle = customer ? await paidFeatureOrders(customer.$id) : []
const orders = alle.filter((o) => (o.featureKey || 'news') === featureKey)
const endOf = (o) => {
const anker = o.vertragAt || o.orderedAt
return anker ? new Date(withdrawalDeadline(anker).endsAt) : null
}
const ends = orders.map(endOf).filter(Boolean)
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => e.getTime()))) : null
const lines = orders
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
.map((o) => {
const ende = endOf(o)
return `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${ende ? `, Fristende ${fmtDate(ende)}` : ''}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`
})
.join('\n')
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
@@ -1127,12 +1141,14 @@ router.post('/public/withdraw', async (req, res) => {
let sub = null
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
// Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const feature = d.order === 'funktion'
// Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
if (feature) {
fn = await withdrawFeature(d, receivedAt)
fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
sub = await findActiveSubscriptionByEmail(d.email)
@@ -1153,7 +1169,7 @@ router.post('/public/withdraw', async (req, res) => {
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
produkt: feature ? funktion.label.name : productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1176,7 +1192,8 @@ router.post('/public/withdraw', async (req, res) => {
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
produkt: feature ? funktion.label.name : productLabelOf(sub),
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
+98 -12
View File
@@ -1,24 +1,31 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { aboMemo } from '../../services/aboStatus.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
customerOf,
disable,
freischaltenNachAbnahme,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
ticketNachlegen,
} from '../../services/siteFeatures.js'
import { featureHaken } from '../../services/featureRegistry.js'
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
import { freischaltHindernis } from '../../services/termine/freischaltung.js'
import { ladeStand } from '../../services/termine/kontext.js'
/**
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
@@ -42,13 +49,13 @@ export function composeAdminNote(previous, { by, action, note }, now = new Date(
return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
}
async function ticketExists(ticketId) {
if (!ticketId) return false
/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */
async function readTicket(ticketId) {
if (!ticketId) return null
try {
await getDocument(config.collections.workorders, ticketId)
return true
return await getDocument(config.collections.workorders, ticketId)
} catch (err) {
if (err.status === 404) return false
if (err.status === 404) return null
throw err
}
}
@@ -76,16 +83,33 @@ async function zeitenZusatz(doc) {
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */
async function customerName(customerId) {
try {
return (await customerOf(customerId))?.name || customerId
} catch (err) {
console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message)
return customerId
}
}
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs })
const abo = aboMemo()
for (const p of projects) {
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)))
view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
}
}
@@ -129,6 +153,55 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
}
})
/**
* Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur
* ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein
* customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument.
* state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder
* ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null.
* Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes
* Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke).
*/
funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => {
try {
const { customerId } = req.params
const { projectId } = req.query
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
const project = await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'termine')
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 })
}
const stand = await ladeStand(doc.$id)
const ticket = await readTicket(doc.ticketId)
const ticketVersion = ticket ? ticketStand(ticket.details) : null
const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({
feature: doc,
project,
einstellung: stand?.einstellung ?? null,
fragebogen: stand?.fragebogen ?? null,
version: stand?.version ?? 0,
updatedAt: stand?.updatedAt ?? null,
kunde: { name: await customerName(customerId) },
now: new Date(),
})
const drift = driftWarnung(ticketVersion, anleitungsStand.version)
// Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen
res.json({
stand: anleitungsStand,
warnungen: drift ? [...warnungen, drift] : warnungen,
abschnitte,
text,
state: doc.state,
ticketWoid: ticket?.woid || null,
ticketVersion,
hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null,
})
} catch (err) {
sendError(res, err)
}
})
/**
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
@@ -162,31 +235,44 @@ async function zeitenAbschalten({ customerId, projectId, by, note }) {
return nachAbschaltung(doc)
}
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404
// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad),
// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket
// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register.
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
const TICKET_ACTIONS = ['abnahme', 'ticket']
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try {
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') {
if (!ACTIONS.includes(action)) {
return res.status(400).json({ error: 'Unbekannte Aktion' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Projekt fehlt' })
}
if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) {
return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' })
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
let ersatzTicket
if (key === 'zeiten') {
if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note })
else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
if (key === 'zeiten' && action === 'activate') {
await zeitenAktivieren({ customerId, projectId, by, note })
} else if (key === 'zeiten' && action === 'disable') {
ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
} else {
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, key, { by, adminNote })
} else if (action === 'abnahme') {
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
} else if (action === 'ticket') {
await ticketNachlegen({ customerId, projectId, key, by, adminNote })
} else {
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
+35 -4
View File
@@ -15,6 +15,7 @@ import {
listCustomerFeatures,
markOrdered,
runActivation,
unterlagenAdressen,
} from '../services/siteFeatures.js'
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
@@ -23,7 +24,7 @@ import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../ser
import { starteBehalten } from '../services/zeiten/behalten.js'
import { aboMemo } from '../services/aboStatus.js'
import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
import {
ELIGIBILITY_MESSAGES,
@@ -93,8 +94,10 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv']
/**
* Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
* das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv |
* null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im
* Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten).
* null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der
* Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. Ein Lesefehler im Abo ergibt
* bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen
* oder IDs enthalten).
*/
async function termineAngebot(email, customerId, abos = aboMemo()) {
if (!termineFuerKunde(email)) return { verfuegbar: false }
@@ -108,7 +111,33 @@ async function termineAngebot(email, customerId, abos = aboMemo()) {
} catch (err) {
console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
}
return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' }
const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt')
return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis }
}
/**
* Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner)
* { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus
* dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die
* Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus.
*/
async function rechtlichesJeFunktion() {
const legal = {}
for (const key of featureKeys()) {
const unterlagen = unterlagenAdressen(featureConfig(key))
if (!unterlagen) {
legal[key] = null
continue
}
let belehrung = null
try {
belehrung = (await featureHaken(key).belehrung?.()) ?? null
} catch (err) {
console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`)
}
legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung }
}
return legal
}
/**
@@ -187,6 +216,8 @@ router.get('/', requireSession, async (req, res) => {
features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])),
// Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt
ustHinweis: config.ustHinweis,
// Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null)
legal: await rechtlichesJeFunktion(),
setupEstimate: config.funktionen.setupEstimate,
consentText: config.funktionen.consentText,
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
+8 -18
View File
@@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { einbauSnippet } from '../services/termine/einbauanleitung.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
@@ -278,20 +279,6 @@ function feedLinks(ctx) {
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()
@@ -311,7 +298,9 @@ async function aboFuerVorschau(customerId) {
* Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true).
* Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und
* offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird
* dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links
* gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active).
*/
router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => {
@@ -322,6 +311,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
const customerId = getSessionCustomerId(req)
const register = featureConfig('termine')
const abo = state === 'off' ? await aboFuerVorschau(customerId) : null
const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung
res.json({
state,
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
@@ -334,11 +324,11 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
kundenansicht: isKundenansicht(req.session),
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
abo,
offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null,
bestellung: state === 'off' ? await bestellungAnsicht(register) : null,
offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null,
bestellung: bestellbar ? await bestellungAnsicht(register) : null,
feeds: e && state === 'active' ? feedLinks(ctx) : {},
siteKey: ctx.feature.siteKey || null,
einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {},
einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {},
anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0,
fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar
})
+7 -48
View File
@@ -1,54 +1,13 @@
// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/)
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { Router } from 'express'
import { config } from '../config.js'
// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/):
// duenner Wrapper um die Dokument-Registry legalDocs.js (Satz 'analyse')
import { legalPaths as registryPaths, legalPdfAttachments as registryAttachments, legalPdfFiles as registryFiles, legalRouter } from './legalDocs.js'
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
export const legalPaths = (version) => registryPaths('analyse', version)
function checkVersion(version) {
if (!VERSION_RE.test(String(version))) throw new Error('Analyse: ungültige Fassung')
return String(version)
}
export function legalPaths(version) {
const v = checkVersion(version)
return {
avv: `/rechtliches/avv-website-analyse-${v}.html`,
lb: `/rechtliches/leistungsbeschreibung-website-analyse-${v}.html`,
}
}
export function legalPdfFiles(version) {
const v = checkVersion(version)
return {
avv: { filename: `AVV-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `avv-website-analyse-${v}.pdf`) },
lb: { filename: `Leistungsbeschreibung-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `leistungsbeschreibung-website-analyse-${v}.pdf`) },
}
}
export const legalPdfFiles = (version) => registryFiles('analyse', version)
/** Beide PDFs als Mail-Anhaenge; wirft, wenn eine Datei fehlt oder nicht lesbar ist. */
export async function legalPdfAttachments(version) {
const files = legalPdfFiles(version)
return Promise.all(
[files.avv, files.lb].map(async (f) => ({
filename: f.filename,
content: await readFile(f.path),
contentType: 'application/pdf',
}))
)
}
export const legalPdfAttachments = (version) => registryAttachments('analyse', version)
/** Adressen ohne Fassung liefern immer die aktuelle geprüfte Fassung (config.analyse.avvVersion). */
export function analyseLegalRouter() {
const router = Router()
const send = (key) => (_req, res, next) => {
const file = join(publicDir, legalPaths(config.analyse.avvVersion)[key])
res.sendFile(file, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
}
router.get('/avv-website-analyse.html', send('avv'))
router.get('/leistungsbeschreibung-website-analyse.html', send('lb'))
return router
}
export const analyseLegalRouter = () => legalRouter({ saetze: ['analyse'] })
+32 -11
View File
@@ -28,14 +28,15 @@ async function termineBestellpruefung({ customerId, projectId, feature }) {
/**
* Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung
* ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler
* (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument
* unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher.
* ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet
* freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed:
* ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt
* das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird
* hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher.
*/
async function termineFreischaltbar(doc) {
const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')])
if (!(await laeuft(doc.customerId))) return false
return (await ladeEinstellung(doc.$id)) !== null
const { freischaltHindernis } = await import('./termine/freischaltung.js')
return (await freischaltHindernis(doc)) === null
}
/**
@@ -68,14 +69,34 @@ async function zeitenNachFreischaltung(doc) {
}
/**
* Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der
* Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion().
* Platzhalter der Widerrufsbelehrung der Oeffnungszeiten (Haken belehrung von zeiten). Der Terminplaner liefert seine echte
* Belehrung mit termineBelehrung (Task F7); fuer die Oeffnungszeiten bleibt dieser Platzhalter, bis ihr Belehrungstext feststeht.
*/
export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'
/**
* Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der
* aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst
* beim Aufruf.
*/
async function termineUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('termine')
}
/**
* Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf
* zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen
* wird sie erst beim Aufruf (kein Importzyklus).
*/
async function termineBelehrung() {
const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js')
return widerrufsbelehrungFunktion()
}
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
*/
@@ -84,8 +105,8 @@ const STANDARD_HAKEN = {
termine: {
verfuegbar: (email) => termineFuerKunde(email),
bestellpruefung: termineBestellpruefung,
unterlagenBereit: async () => true,
belehrung: async () => BELEHRUNG_PLATZHALTER,
unterlagenBereit: termineUnterlagenBereit,
belehrung: termineBelehrung,
freischaltbar: termineFreischaltbar,
},
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung und
+161
View File
@@ -0,0 +1,161 @@
// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/):
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum.
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
import { access, open, readFile, stat } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { Router } from 'express'
import { config } from '../config.js'
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */
let basisDir = publicDir
/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */
export function setLegalDirForTests(dir) {
basisDir = dir ?? publicDir
}
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
/**
* Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (<slug>-<Fassung>.html|pdf),
* pdf = Dateiname des Mail-Anhangs (<pdf>-<Fassung>.pdf). config.js baut die Adressen des Terminplaners aus denselben
* Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab.
*/
const SAETZE = {
analyse: {
avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' },
lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' },
},
termine: {
lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' },
avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' },
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
},
}
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
const FUNKTION_SATZ = { news: null, termine: 'termine' }
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion)
function satzDokumente(satz) {
if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz')
return SAETZE[satz]
}
function pruefeFassung(version) {
if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung')
return String(version)
}
/** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/<slug>-<Fassung>.html' }. */
export function legalPaths(satz, version) {
const dokumente = satzDokumente(satz)
const v = pruefeFassung(version)
return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`]))
}
/** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */
export function legalPdfFiles(satz, version) {
const dokumente = satzDokumente(satz)
const v = pruefeFassung(version)
return Object.fromEntries(
Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }])
)
}
/**
* PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine
* Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein).
*/
export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) {
const dateien = legalPdfFiles(satz, version)
for (const doc of ohne) {
if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument')
}
return Promise.all(
Object.entries(dateien)
.filter(([doc]) => !ohne.includes(doc))
.map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' }))
)
}
function funktionSatz(featureKey) {
if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion')
return FUNKTION_SATZ[featureKey]
}
/**
* Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei
* unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). opts.fassung waehlt eine andere
* (ausgelieferte) Fassung, etwa die, der ein Kunde bei der Bestellung zugestimmt hat; sonst gilt die aktuelle.
*/
export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) {
const satz = funktionSatz(featureKey)
return satz ? legalPdfAttachments(satz, fassung ?? aktuelleFassung(satz), opts) : []
}
/**
* Adressen der HTML-Seiten der Funktion ({ lb, avv, widerruf }, versioniert) in der aktuellen oder der gewaehlten Fassung
* (opts.fassung); news => null. Wirft bei unbekannter Funktion oder ungueltiger Fassung.
*/
export function featureLegalPaths(featureKey, { fassung } = {}) {
const satz = funktionSatz(featureKey)
return satz ? legalPaths(satz, fassung ?? aktuelleFassung(satz)) : null
}
/**
* Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die
* ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler.
*/
async function istLesbaresPdf(pfad) {
const info = await stat(pfad)
if (!info.isFile() || info.size <= 0) return false
const datei = await open(pfad, 'r')
try {
const kopf = Buffer.alloc(5)
const { bytesRead } = await datei.read(kopf, 0, 5, 0)
return bytesRead === 5 && kopf.toString('latin1') === '%PDF-'
} finally {
await datei.close()
}
}
/**
* true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite
* verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte
* Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle.
*/
export async function featureLegalReady(featureKey, { fassung } = {}) {
try {
const satz = funktionSatz(featureKey)
if (!satz) return true
const v = fassung ?? aktuelleFassung(satz)
await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse))))
const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path)))
return lesbar.every(Boolean)
} catch {
return false
}
}
/**
* Adressen ohne Fassung (/rechtliches/<slug>.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer
* Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der
* Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient
* werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes).
*/
export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) {
const router = Router()
for (const satz of saetze) {
for (const d of Object.values(satzDokumente(satz))) {
router.get(`/${d.slug}.html`, (_req, res, next) => {
const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`)
res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
})
}
}
return router
}
+410 -46
View File
@@ -4,9 +4,10 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { aboLage, laeuft } from './aboStatus.js'
import { berlinDateTime } from './berlinTime.js'
import { loeschTag } from './termine/frist.js'
import { createFeatureTicket } from './ticketAdmin.js'
@@ -416,20 +417,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
const customer = await customerOf(doc.customerId).catch(() => null)
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
...texte,
})
const ticketId = await legeTicketAn(setup, project, customer)
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
return { doc: updated, created: true, fall: null }
})
}
/**
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
* @param {object} project websiteProjects-Dokument
* @param {object|null} customer Kundendokument, best effort
* @returns {Promise<string>} ID des neuen Tickets
*/
async function legeTicketAn(doc, project, customer) {
const feature = featureConfig(doc.featureKey)
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
...texte,
})
return ticketId
}
/**
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
@@ -648,6 +663,192 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
return activated
}
// ---------------------------------------------------------------------------
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
// ---------------------------------------------------------------------------
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
const ABNAHME_HINDERNIS = {
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'],
}
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
async function dokumentFuerTeamAktion(customerId, projectId, key) {
assertKey(key)
const doc = await getFeatureDoc(customerId, projectId, key)
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
return doc
}
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
async function frischesDokument(vorab, key) {
try {
return await getDocument(config.collections.portalFeatures, vorab.$id)
} catch (err) {
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
throw err
}
}
/**
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
*/
async function abnahmeHindernis(doc) {
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
return pruefe(doc)
}
/**
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
*/
async function nacharbeitNachFreischaltung(doc) {
const { nachFreischaltung } = featureHaken(doc.featureKey)
if (typeof nachFreischaltung !== 'function') return
try {
await nachFreischaltung(doc)
} catch (err) {
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
}
}
/**
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
* @returns {Promise<{ doc: object, activated: boolean }>}
*/
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
const doc = await frischesDokument(vorab, key)
if (doc.state === 'active') return { doc, activated: false }
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
const grund = await abnahmeHindernis(doc)
if (grund) {
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
throw httpError(status, text)
}
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('active'),
activatedAt: now.toISOString(),
...(note ? { adminNote: note } : {}),
})
return { doc: updated, activated: true }
})
if (ergebnis.activated) {
await nacharbeitNachFreischaltung(ergebnis.doc)
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
await meldeFreigeschaltet(ergebnis.doc)
}
return ergebnis
}
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
async function ticketBesteht(ticketId) {
try {
await getDocument(config.collections.workorders, ticketId)
return true
} catch (err) {
if (err.status === 404) return false
throw err
}
}
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
async function bezahlteSitzung(doc) {
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
let session
try {
session = await retrieveCheckoutSession(doc.stripeSessionId)
} catch (err) {
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
}
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
return session
}
/**
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
*/
async function ticketNachZahlung(doc, note) {
const session = await bezahlteSitzung(doc)
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
if (!nachher.ticketId) return { doc: nachher, created: false }
if (!note) return { doc: nachher, created: true }
try {
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
} catch (err) {
// Ticket und Mails sind raus; die Notiz ist nur ein Vermerk
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
return { doc: nachher, created: true }
}
}
/**
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
* schreibt; das Dokument wird darunter erneut gelesen.
*/
async function ersetzeGeloeschtesTicket(doc, note) {
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
const customer = await customerOf(frisch.customerId).catch(() => null)
const ticketId = await legeTicketAn(frisch, project, customer)
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
return { doc: updated, created: true }
})
}
/**
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
* (c) Ticket vorhanden: nichts, created false.
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
* (ein Ticket, created nur einmal true).
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
* @returns {Promise<{ doc: object, created: boolean }>}
*/
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
const doc = await frischesDokument(vorab, key)
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
if (!doc.ticketId) return ticketNachZahlung(doc, note)
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
return ersetzeGeloeschtesTicket(doc, note)
})
}
/**
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
@@ -679,14 +880,29 @@ function einrichtungsPreis(feature) {
return eur(amountCents)
}
/**
* Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und
* die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News).
* @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null}
*/
export function unterlagenAdressen(feature) {
const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {}
if (!lb?.url || !avv?.url || !widerruf?.url) return null
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) }
}
/**
* K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag
* (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab.
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
* Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt
* fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText`
* (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen
* Registerhaken dafuer.
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
*/
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten = false }) {
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
const { funktion, formen, preisHinweis, danach } = feature.mails
return {
template: 'K15',
@@ -704,11 +920,47 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten
danach,
portal_url: portalUrl(feature),
rechnung_url: invoiceUrl || '',
fassung: unterlagen?.fassung ?? '',
lb_url: unterlagen?.lb ?? '',
avv_url: unterlagen?.avv ?? '',
widerruf_url: unterlagen?.widerruf ?? '',
zustimmungen_text: zustimmungenText,
},
blocks: {
rechnung: Boolean(invoiceUrl),
anlagen: unterlagen !== null,
anhang: unterlagen !== null && anhang === true,
zustimmungen: zustimmungenText !== '',
behalten: Boolean(behalten),
neu: !behalten,
},
blocks: { rechnung: Boolean(invoiceUrl), behalten: Boolean(behalten), neu: !behalten },
}
}
/**
* Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der
* Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis.
* @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte)
*/
export function zustimmungenText(nachweis) {
let texte
try {
texte = JSON.parse(nachweis?.texte ?? '')
} catch {
return ''
}
if (!Array.isArray(texte)) return ''
const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '')
if (haken.length === 0) return ''
const am = new Date(nachweis.acceptedAt)
const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am))
const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '')
return [
`Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`,
...haken.map((t) => `– ${t.text}`),
].join('\n')
}
/** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */
export function bereitMail(feature, { kundeName, projekt }) {
const { funktion, formen, bereit, knopf } = feature.mails
@@ -780,7 +1032,7 @@ async function ticketWoidOf(doc) {
/**
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
* Block gesundheit bleibt hier aus (D4 fuellt ihn).
* Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice).
*/
function sendT16({ funktion, ...werte }, bloecke) {
return sendAdminNotice({
@@ -798,15 +1050,66 @@ function sendT16({ funktion, ...werte }, bloecke) {
gesundheit_text: '',
...werte,
},
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, abo: false, team: false, ...bloecke },
blocks: {
zahlung_fehlgeschlagen: false,
ungeklaert: false,
kein_abo: false,
bereits_aktiv: false,
anleitung: false,
gesundheit: false,
anhang_fehlt: false,
abo: false,
team: false,
...bloecke,
},
})
}
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */
const GESUNDHEIT_TEXT_MAX = 500
/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */
function gesundheitText(meldungen) {
const alle = meldungen.join('; ')
if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle
let text = ''
let n = 0
for (const meldung of meldungen) {
const naechster = text ? `${text}; ${meldung}` : meldung
if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break
text = naechster
n += 1
}
return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' })
}
/**
* T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs
* (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei
* jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil
* termine/kontext.js dieses Modul importiert.
*/
async function gesundheitHinweis(doc) {
if (doc.featureKey !== 'termine') return ''
try {
const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')])
const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung)
return meldungen.length ? gesundheitText(meldungen) : ''
} catch (err) {
console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message)
return ''
}
}
/**
* keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo).
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt).
*/
async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) {
const mails = featureConfig(doc.featureKey)?.mails
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
const anleitung = Boolean(mails?.anleitung) && !failed
const gesundheit = failed ? '' : await gesundheitHinweis(doc)
await sendT16(
{
kunde_name: customer?.name || customer?.email || doc.customerId,
@@ -816,8 +1119,9 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
gesundheit_text: gesundheit,
},
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt }
)
}
@@ -986,6 +1290,62 @@ async function onBehaltenPaid(session) {
await bucheBehalten(session)
}
/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */
const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false })
/**
* Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung
* zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle
* Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und
* Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links
* raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle
* ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne
* Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie.
* @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>}
*/
async function k15Rechtsunterlagen(doc, feature, session) {
const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true })
let nachweis = null
try {
nachweis = await zustimmungFuerSitzung(session.id)
} catch (err) {
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message)
return ohne('')
}
const zustimmungen = zustimmungenText(nachweis)
const aktuell = feature.consent.version
let vereinbart = aktuell
if (nachweis) {
try {
vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung
} catch {
vereinbart = undefined
}
if (typeof vereinbart !== 'string' || vereinbart === '') {
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`)
return ohne(zustimmungen)
}
}
try {
const legal = await import('./legalDocs.js')
const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart }
if (vereinbart !== aktuell) {
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`)
if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`)
}
const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene)
if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion')
const adressen = legal.featureLegalPaths(doc.featureKey, eigene)
const absolut = (pfad) => `${config.publicBaseUrl}${pfad}`
const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) }
return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false }
} catch (err) {
console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message)
// die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind
return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null }
}
}
async function onFeaturePaid(session) {
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session)
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
@@ -1005,16 +1365,34 @@ async function onFeaturePaid(session) {
} catch (err) {
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
}
// K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem
// Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team
// "Unterlagen von Hand nachsenden" (Block anhang_fehlt).
const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false
let anhangFehlt = false
if (customer?.email) {
try {
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
await sendCustomerMail(
customer.email,
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
)
const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN
anhangFehlt = rechtlich.fehlt
await sendCustomerMail(customer.email, {
...bestellMail(feature, {
kundeName: customer.name || '',
projekt: await projectName(doc.projectId),
invoiceUrl,
unterlagen: rechtlich.unterlagen,
anhang: rechtlich.anhang,
zustimmungenText: rechtlich.zustimmungen,
}),
attachments: [...rechtlich.attachments],
})
} catch (err) {
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
if (mitRechtsdokumenten) anhangFehlt = true
}
} else if (mitRechtsdokumenten) {
console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`)
anhangFehlt = true
}
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
@@ -1027,7 +1405,7 @@ async function onFeaturePaid(session) {
}
}
try {
await teamNotice(doc, customer, { keinAbo })
await teamNotice(doc, customer, { keinAbo, anhangFehlt })
} catch (err) {
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
}
@@ -1094,32 +1472,18 @@ async function heileZeiten({ now }) {
/**
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden
* betroffenen Kunden. Zum Schluss, nur im Lauf des Jobs (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die
* Projektliste eines Kunden (GET /api/funktionen, mit customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
* betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf).
* termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
*/
export async function runActivation({ now = new Date(), customerId = null } = {}) {
const activated = await activateClosedTickets({ now, customerId })
for (const doc of activated) {
// Nacharbeit vor der K16 und vor dem Adress-Check: auch ein Kunde ohne Adresse bekommt sie; ein Fehler bricht nichts ab
const { nachFreischaltung } = featureHaken(doc.featureKey)
if (typeof nachFreischaltung === 'function') {
try {
await nachFreischaltung(doc)
} catch (err) {
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
}
}
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) continue
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
await sendCustomerMail(
customer.email,
bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
)
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
if (activated.length > 0) {
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
for (const doc of activated) {
await nacharbeitNachFreischaltung(doc)
await meldeFreigeschaltet(doc)
}
}
if (!customerId) await heileZeiten({ now })
+2 -1
View File
@@ -228,7 +228,8 @@ export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = fal
// Zustimmungen
const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {}
const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== ''
// Stornobedingungen gelten nur fuer Leistungen mit Preis: bei kostenlosen Leistungen kein Pflichthaken und kein Nachweis
const stornoNoetig = !manuell && hatPreis(leistung) && typeof e.storno?.text === 'string' && e.storno.text !== ''
if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno)
const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false
if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig)
+856
View File
@@ -0,0 +1,856 @@
import { config } from '../../config.js'
import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js'
import { gesundheitsWarnungen } from './fragebogen.js'
import {
VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
} from './einstellungen.js'
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js'
import { datumKurz, heute } from './zeit.js'
/**
* Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
* Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
*
* Zwei Teile in einer Datei:
* - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
* Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse,
* Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
* haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit.
*/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const KNOPF_MAX = 40
const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe
// ---------------------------------------------------------------- Text
/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */
export function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[z])
}
/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */
function knopfTextBereinigt(roh) {
if (typeof roh !== 'string') return ''
const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim()
return Array.from(eine).slice(0, KNOPF_MAX).join('')
}
// ---------------------------------------------------------------- Einbau-Code
/**
* Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript.
* modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt
* als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen".
*/
export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) {
const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : ''
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
const attribute = [
`data-webklar-termine="${esc(siteKey)}"`,
`data-api="${esc(apiBase)}"`,
`data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`,
...(knopf ? [`data-knopf="${esc(knopf)}"`] : []),
]
return [
`<div ${attribute.join(' ')}>`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
].join('\n')
}
// ---------------------------------------------------------------- Farben
/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */
export function luminanz(farbe) {
const rgb = [1, 3, 5].map((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), wie kontrast() im Widget. */
const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */
export function kontrast(hexA, hexB) {
return verhaeltnis(luminanz(hexA), luminanz(hexB))
}
/**
* 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). 1:1 aus dem Widget.
*/
export function textFarbeAuf(farbe) {
const l = luminanz(farbe)
const weiss = verhaeltnis(l, 1)
const dunkel = verhaeltnis(l, luminanz('#111827'))
const beste = Math.max(weiss, dunkel)
if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000'
return weiss >= dunkel ? '#ffffff' : '#111827'
}
/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */
const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',')
/**
* CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus
* `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen;
* alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe.
* Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift),
* kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der
* Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund.
*/
export function cssVorschlag(farbe) {
const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE
const akzentText = textFarbeAuf(akzent)
const textKontrast = kontrast(akzent, akzentText)
const seitenKontrast = kontrast(akzent, '#ffffff')
const hinweise = []
if (seitenKontrast < MIN_RAHMEN) {
hinweise.push(
`Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` +
`mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.`
)
}
const css = [
`/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`,
'[data-webklar-termine]{',
` --wkt-akzent:${akzent};`,
` --wkt-akzent-text:${akzentText};`,
'}',
].join('\n')
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
}
// ================================================================ Generator einbauanleitung()
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung.
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie
// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext
// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert,
// als data-knopf).
/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */
export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const MAX_ABWESENHEITEN = 20
const MAX_SONDEROEFFNUNGEN = 20
const NICHT_ANGEGEBEN = 'nicht angegeben'
const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const liste = (wert) => (Array.isArray(wert) ? wert : [])
const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
/** '2026-10-05' => '05.10.2026'. */
const tagText = (tag) => tag.split('-').reverse().join('.')
/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
function fensterText(fenster) {
return liste(fenster)
.filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
.map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
.join(', ')
}
/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
export function wochenplanText(plan) {
const aus = []
for (let tag = 1; tag <= 7; tag += 1) {
const text = fensterText(istObjekt(plan) ? plan[tag] : null)
if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
}
return aus
}
/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
export function kuerzeListe(eintraege, max) {
if (eintraege.length <= max) return [...eintraege]
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
}
const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar })
/** Zeilen als Aufzaehlung: '- Eintrag'. */
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
// ---------------------------------------------------------------- Abschnitt Modell
function standText(d) {
if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
}
function abschnittModell(d) {
const { e, betrieb: b } = d
const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
return neuerAbschnitt('modell', 'Modell', [
`Erzeugt am ${datumKurz(d.now)}`,
`Stand der Einstellungen: ${standText(d)}`,
...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
`Betrieb: ${angabe(b.name)}`,
`Anschrift: ${angabe(b.adresse)}`,
`Telefon: ${angabe(b.telefon)}`,
`Impressum: ${angabe(b.impressumUrl)}`,
`Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
`Farbe: ${d.farbe}`,
`Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
`Gäste: ${gaeste}`,
`Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
`Portal-Adresse (data-api): ${d.portal}`,
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Leistungen
/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
function zuordnungText(ids, nachId) {
const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
const plaetze = treffer.filter((r) => r.typ === 'platz').length
const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
return `${label}: ${treffer.map((r) => r.name).join(', ')}`
}
function leistungsZeile(l, nachId) {
const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
+ `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
}
function abschnittLeistungen(d) {
if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
const aktiv = d.leistungen.filter((l) => l.aktiv).length
return neuerAbschnitt('leistungen', 'Leistungen', [
`${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Ressourcen
function ressourcenBlock(r) {
const plan = wochenplanText(r.plan)
return [
`- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
].join('\n')
}
/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
function abwesenheitsZeilen(d) {
const eintraege = liste(d.e.abwesenheiten)
.filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
.map((a) => {
const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
})
return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
}
/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
function sonderoeffnungsZeilen(d) {
const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
const eintraege = Object.keys(roh)
.filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
.sort()
.map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
}
function abschnittRessourcen(d) {
const abwesenheiten = abwesenheitsZeilen(d)
const sonder = sonderoeffnungsZeilen(d)
return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
].join('\n\n'))
}
// ---------------------------------------------------------------- Abschnitt Regeln
/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
function vorlaufText(minuten) {
if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
if (minuten === 0) return 'kein Vorlauf'
if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
return anzahl(minuten, 'Minute', 'Minuten')
}
function stornoZeilen(storno) {
const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
if (!text) return ['Stornobedingungen: keine hinterlegt']
return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
}
function abschnittRegeln(d) {
const { e } = d
const r = istObjekt(e.regeln) ? e.regeln : {}
const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
return neuerAbschnitt('regeln', 'Regeln', [
`Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
`Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
`Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
`Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
`Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
`Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
`Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
zahlungText(e),
`Preishinweis: ${preisHinweis(e)}`,
zeigtWiderruf(e)
? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
: 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
...stornoZeilen(e.storno),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Gastfelder
function abschnittGastfelder(d) {
const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
})
return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
`Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
`Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
function abschnittBenachrichtigungen(d) {
const { e } = d
const sichtbar = sichtbareLeistungen(e)
const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
const widerruf = zeigtWiderruf(e)
const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
`Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
+ 'Antworten gehen an den Betrieb.',
['Mails an Gäste:', ...aufzaehlung([
`Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
'Absage und Verschiebung',
...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
])].join('\n'),
['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
...(sofort ? ['Neue Buchung'] : []),
...(anfrage ? ['Neue Terminanfrage'] : []),
'Absage oder Verschiebung durch den Gast',
...(widerruf ? ['Widerruf'] : []),
])].join('\n'),
'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
].join('\n\n'))
}
// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau)
const EINBAU_MODI = ['inline', 'knopf']
const MODUS_TEXT = {
inline: 'inline – die Buchungsstrecke steht direkt in der Seite',
knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog',
}
// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind
const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */
function kundenwunsch(fragebogen) {
const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {}
const modusGewaehlt = EINBAU_MODI.includes(einbau.modus)
const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt }
for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) {
wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed()
}
return wunsch
}
/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */
function wunschZeilen(bezeichnung, wert) {
if (!wert) return []
const [erste, ...weitere] = wert.split('\n')
return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))]
}
function kundenwunschBlock(wunsch) {
const zeilen = [
...wunschZeilen('Knopf-Text', wunsch.knopfText),
...wunschZeilen('Stelle', wunsch.stelle),
...wunschZeilen('Gestaltung', wunsch.gestaltung),
...wunschZeilen('Bemerkung', wunsch.bemerkung),
]
return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen'
}
// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS
function abschnittEinbau(d) {
const w = d.wunsch
const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus'
const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN
const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN
const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN
return neuerAbschnitt('einbau', 'Einbau', [
`siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`,
`Modus: ${MODUS_TEXT[w.modus]} (${quelle})`,
['So wird eingebaut:', ...aufzaehlung([
'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt '
+ '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, '
+ 'setzt keine Cookies und lädt nichts von Dritten.',
'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden '
+ `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`,
'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.',
'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, '
+ 'bevor das Widget freigeschaltet wird.',
`Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), `
+ 'das Widget verlinkt sie im Fuß.',
])].join('\n'),
kundenwunschBlock(w),
].join('\n\n'))
}
/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */
const baueSnippet = (d) =>
einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText })
const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true)
const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true)
// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du)
const DATENSCHUTZ_HINWEIS =
'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. '
+ 'Beides im Portal eintragen lassen und die Anleitung neu laden.'
/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */
function datenschutzTexte(einstellung) {
try {
return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') }
} catch (err) {
if (err instanceof TypeError) return null
throw err
}
}
/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */
const abschnittDatenschutz = (anrede) => (d) => {
const id = `datenschutz_${anrede}`
const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)`
const absatz = d.datenschutz?.[anrede]
return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS)
}
// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste
// Wer den Datenschutz-Absatz einfuegt: die Regel der Leistungsbeschreibung (Nr. 4 Abs. 9); Checkliste und Schritt 4 der Kurzfassung teilen sie
const DATENSCHUTZ_REGEL =
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
function abschnittCheckliste(d) {
const { e } = d
const widerruf = zeigtWiderruf(e)
const preis = sichtbareLeistungen(e).some(hatPreis)
const knopf = d.wunsch.modus === 'knopf'
const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt'
const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt'
const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : ''
const punkte = [
// vor der Freischaltung
'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.',
...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []),
'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.',
`Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_REGEL}).`,
`URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`,
`„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`,
// am sichtbaren Widget
'Probebuchung bis „Fast geschafft“ durchgespielt: 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 stornieren (Verwaltungslink aus der E-Mail oder im Portal).',
...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []),
...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []),
...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []),
...(widerruf && knopf
? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.']
: []),
'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.',
'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.',
'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.',
]
return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Abrechnung
const RECHNUNG_RE = /^https:\/\/\S+$/i
/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */
function einrichtungsPreis() {
const preis = zeile(config.features?.termine?.priceLabel)
return preis || '149 €'
}
function abschnittAbrechnung(d) {
if (d.feature?.unlockedByPurchase !== true) {
return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.')
}
const rechnung = zeile(d.feature.invoiceUrl)
return neuerAbschnitt('abrechnung', 'Abrechnung', [
`Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`,
'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
].join('\n'))
}
// ---------------------------------------------------------------- Warnungen
const WARNUNG_PFLICHT = {
'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
'betrieb.adresse':
'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
}
const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.'
const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.'
/**
* Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich)
* und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML).
*/
const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
/**
* Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen:
* wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer.
*/
export function gesundheitsMeldungen(einstellung) {
return gesundheitsWarnungen(einstellung).map(meldungText)
}
/**
* Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits-
* meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit
* in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen.
*/
function warnungsTeile(d) {
const davor = []
if (d.entwurf) davor.push(ENTWURF_BANNER)
for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
const danach = [...cssVorschlag(d.farbe).hinweise]
if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ)
return { davor, gesundheit: gesundheitsMeldungen(d.e), danach }
}
// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4)
// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz
// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die
// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet,
// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen
// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge-
// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets.
const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere"
const KURZ_NAME_MAX = 60
const KURZ_KOPF_MAX = 120
const KURZ_WARNUNGEN = 5
const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:'
const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'
const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' })
const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' })
/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */
function standZeile(version, geaendert, hatEinstellung) {
const n = Number.isInteger(version) && version >= 1 ? version : 0
if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'
if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)`
return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}`
}
/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */
function kurzKopf(k) {
const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain)
return [
'Funktion: Terminplaner',
`Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`,
`Domain: ${kopfWert(domain)}`,
`Kunde: ${kopfWert(k.kunde)}`,
`siteKey: ${k.siteKey}`,
standZeile(k.version, k.geaendert, k.hatEinstellung),
].join('\n')
}
/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */
function kurzWunschZeilen(bezeichnung, wert) {
const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '')
return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)]
}
/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */
function kurzWunsch(w) {
const zeilen = [
...kurzWunschZeilen('Knopf-Text', w.knopfText),
...kurzWunschZeilen('Stelle', w.stelle),
...kurzWunschZeilen('Gestaltung', w.gestaltung),
...kurzWunschZeilen('Bemerkung', w.bemerkung),
]
return [
'Kundenwunsch (Text des Kunden):',
` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`,
...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']),
].join('\n')
}
/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */
function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) {
const n = eintraege.length
const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : ''
const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ')
return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}`
}
function kurzUmfang(k) {
return [
'Umfang:',
kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }),
kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }),
kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'),
].join('\n')
}
/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */
function kurzSchritte(k) {
const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form'
const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : ''
return [
'Schritte:',
'1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.',
'2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.',
'3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.',
`4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_REGEL}).`,
'5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).',
`6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`,
].join('\n')
}
/**
* Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen,
* ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink.
*/
function kurzfassung(k) {
const pflicht = {
kopf: kurzKopf(k),
gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '',
snippet: `Einbau-Code (Snippet):\n${k.snippet}`,
schritte: kurzSchritte(k),
verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK,
}
const umfang = k.hatEinstellung ? kurzUmfang(k) : ''
const wunsch = kurzWunsch(k.wunsch)
const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`)
let mitUmfang = Boolean(umfang)
let mitWunsch = true
const zusammen = () =>
[
pflicht.kopf,
mitWunsch ? wunsch : '',
mitUmfang ? umfang : '',
warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '',
pflicht.gesundheit,
pflicht.snippet,
pflicht.schritte,
pflicht.verweis,
].filter(Boolean).join('\n\n').toWellFormed()
let text = zusammen()
if (text.length > TICKET_DETAILS_MAX) {
mitUmfang = false
text = zusammen()
}
while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) {
warnungen.pop()
text = zusammen()
}
if (text.length > TICKET_DETAILS_MAX) {
mitWunsch = false
text = zusammen()
}
return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text
}
const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m
/**
* Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des
* Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand.
*/
export function ticketStand(details) {
if (typeof details !== 'string') return null
const treffer = STAND_RE.exec(details)
return treffer ? Number(treffer[1]) : null
}
/**
* Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version.
* Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket).
*/
export function driftWarnung(ticketVersion, jetzt) {
if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null
return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
}
// ---------------------------------------------------------------- Zusammenbau
const ABSCHNITTE = [
abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen,
abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung,
]
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
function projektText(project) {
const name = zeile(project?.projectName) || zeile(project?.subdomain)
const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
return name && domain && name !== domain ? `${name} (${domain})` : name || domain
}
/**
* Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
*
* @param {object} p
* @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase,
* invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht)
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
* @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung)
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
* @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
* abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
* text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
* Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert".
* kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile,
* ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis).
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
const banner = entwurf ? [ENTWURF_BANNER] : []
const geaendertAm = updatedAt ? new Date(updatedAt) : null
const gemeinsam = {
feature,
project,
siteKey: zeile(feature?.siteKey),
portal: zeile(config.publicBaseUrl),
wunsch: kundenwunsch(fragebogen),
anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
version,
geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '',
}
if (!istObjekt(einstellung)) {
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
const warnungen = [...banner, 'Keine Einstellungen gespeichert']
const kurz = kurzfassung({
...gemeinsam,
kunde: zeile(kunde?.name),
hatEinstellung: false,
anrede: null,
leistungen: [],
ressourcen: [],
zusatzfelder: [],
warnungen,
gesundheit: [],
snippet: abschnitte[0].text,
})
return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz }
}
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const daten = {
...gemeinsam,
e: einstellung,
now,
entwurf: Boolean(entwurf),
heuteTag: heute(now),
betrieb,
telefon: zeile(betrieb.telefon),
kunde: zeile(kunde?.name),
projekt: projektText(project),
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
haltezeit: config.termine?.holdMinutes,
ressourcen,
leistungen: liste(einstellung.leistungen).filter(istObjekt),
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
datenschutz: datenschutzTexte(einstellung),
}
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
// der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
const teile = warnungsTeile(daten)
const kurz = kurzfassung({
...daten,
hatEinstellung: true,
anrede: einstellung.anrede,
zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt),
warnungen: [...teile.davor, ...teile.danach],
gesundheit: teile.gesundheit,
snippet: abschnitte.find((a) => a.id === 'snippet').text,
})
return {
stand,
warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach],
abschnitte,
text: volltext(abschnitte.filter((a) => a.id !== andere)),
kurz,
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js'
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
*/
const VORGABE_FARBE = '#7c3aed'
export 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]
+57
View File
@@ -0,0 +1,57 @@
import { aboMemo } from '../aboStatus.js'
import { featureConfig } from '../featureRegistry.js'
import { sendCustomerMail } from '../mailer.js'
import { bereitMail, customerOf, projectName } from '../siteFeatures.js'
import { ladeEinstellung } from './kontext.js'
// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier,
// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die
// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese
// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert.
/**
* Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund:
* 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch)
* 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert
* 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt)
* Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der
* Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das
* Dokument dann in 'setup'.
* abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente
* im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard).
* @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument
* @returns {Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>}
*/
export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) {
let laeuft
try {
laeuft = await abo.laeuft(doc.customerId)
} catch (err) {
console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message)
return 'abo_unbekannt'
}
if (!laeuft) return 'kein_abo'
if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung'
return null
}
/**
* K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail).
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
* @param {{ attachments?: object[] }} [optionen]
*/
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) return
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
await sendCustomerMail(customer.email, { ...mail, attachments })
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
}
}
+38
View File
@@ -106,6 +106,44 @@ export async function ladeEinstellung(featureId) {
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
}
/**
* Der Fragebogen aus einem termineConfig-Dokument, so wie er gespeichert ist (geparstes Objekt) oder null. Anders als fragebogenAus kein
* leerer Fragebogen als Ersatz: fehlt das Attribut, ist es leer oder kein JSON-Objekt, ist das Ergebnis null (kaputtes JSON mit einer Logzeile).
*/
function fragebogenRoh(doc) {
const roh = doc.fragebogen
if (roh === undefined || roh === null || roh === '') return null
try {
const fragebogen = JSON.parse(roh)
if (!istObjekt(fragebogen)) throw new Error('kein Objekt')
return fragebogen
} catch (err) {
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
return null
}
}
/**
* Der gespeicherte Stand einer Funktion fuer das Einrichtungs-Ticket und die Anleitung (TP2 D4): Einstellung, Version, Zeitpunkt der letzten
* Aenderung und Fragebogen, ohne Modus- und Zustandspruefung. Anders als ladeEinstellung unterscheidet er "noch nie gespeichert" von "unlesbar":
* null nur ohne termineConfig-Dokument; unlesbares JSON macht nur das jeweilige Feld null (die Version des Dokuments bleibt). Fehlt Collection
* oder Datenbank, wird der Fehler weitergereicht.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<{ einstellung: object|null, version: number, updatedAt: string|null, fragebogen: object|null }|null>}
*/
export async function ladeStand(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
if (!doc) return null
const { einstellung, version } = liesDokument(doc)
return {
einstellung,
version,
updatedAt: typeof doc.updatedAt === 'string' && doc.updatedAt !== '' ? doc.updatedAt : null,
fragebogen: fragebogenRoh(doc),
}
}
async function baueKontext(feature) {
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
+246 -15
View File
@@ -1,9 +1,9 @@
/**
* 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.
* Reine Funktionen und Konstanten, ohne Appwrite; sie nutzen Hilfsfunktionen aus einstellungen.js (zeigtWiderruf) und lesen
* config.termine.holdMinutes (Gueltigkeit des Bestaetigungslinks im Datenschutz-Absatz, bei jedem Aufruf gelesen), sonst keine Konfiguration. Die Texte
* sind Rechtstexte und stehen deshalb immer in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem
* Formular und der Datenschutz-Absatz folgen der Anrede.
*
* 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:
@@ -13,10 +13,28 @@
* Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft
* den Wortlaut gegen die Quelldateien.
*
* datenschutzAbsatz: Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs (Betrieb = Verantwortlicher).
* Der Wortlaut steht auch in docs/rechtliches/termine-datenschutz-absatz.md (Fassung Sie / Fassung Du); test/termineRechtstexte.test.js
* haelt beide gleich. Aenderungen am Text immer in Markdown und Funktion gemeinsam.
*
* Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen.
*/
export const RECHTSTEXTE_VERSION = '2026-10-02'
import { config } from '../../config.js'
import { zeigtWiderruf } from './einstellungen.js'
/**
* Fassung der Gaestetexte (steht im Nachweis jeder Buchung, archiviert in docs/rechtliches/archiv/termine-gaestetexte-<Fassung>.md).
* Gleich config.termine.avvVersion (test/termineRechtstexte.test.js haelt beide gleich); eine Textaenderung nach der Auslieferung
* bekommt ein neues Datum, ein neues Archiv und einen neuen Hash-Pin. Buchungen aus aelteren Fassungen behalten ihre Fassung.
*/
export const RECHTSTEXTE_VERSION = '2026-10-05'
/**
* Kennzeichen, ob die Gaestetexte noch als Entwurf (nur Pilot, vor dem Einsatz bei fremden Betrieben zu pruefen) gelten. Ist es true,
* nennt die Einbauanleitung das als erste Warnung. Seit der Endfassung der Gaestetexte (Fassung 2026-10-05) false.
*/
export const RECHTSTEXTE_ENTWURF = false
const ZEILENUMBRUCH = '\n'
const ABSATZ = '\n\n'
@@ -48,12 +66,18 @@ const verbinde = (teile) => teile.filter(Boolean).join(', ')
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.
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt bei den Gästen ein geeigneter Hinweis statt einer
// Internetadresse, bei der Bestellung einer Funktion (WEBklar als Unternehmer) die Adresse der Widerrufsseite.
const EINGANGSBESTAETIGUNG =
'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.'
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.'
+ EINGANGSBESTAETIGUNG
const WIDERRUFSFUNKTION_WEBKLAR = `Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. ${EINGANGSBESTAETIGUNG}`
// Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen.
const WERTERSATZ =
@@ -66,7 +90,7 @@ function kontakt({ name, adresse, telefon, email }) {
return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`])
}
function belehrungsAbsaetze(daten) {
function belehrungsAbsaetze(daten, widerrufsfunktion = WIDERRUFSFUNKTION) {
return [
'Widerrufsbelehrung',
'Widerrufsrecht',
@@ -75,7 +99,7 @@ function belehrungsAbsaetze(daten) {
`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,
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 '
@@ -88,9 +112,9 @@ function belehrungsAbsaetze(daten) {
]
}
function formularAbsaetze({ name, adresse, email }) {
function formularAbsaetze({ name, adresse, email }, anZeile = `An ${verbinde([name, adresse, email])}:`) {
const zeilen = [
`An ${verbinde([name, adresse, email])}:`,
anZeile,
'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)',
@@ -117,12 +141,41 @@ export function widerrufsbelehrung(betrieb) {
return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ)
}
/** WEBklar als Unternehmer (Bestellung einer Funktion im Kundenportal): Kontaktangaben der Widerrufsbelehrung. */
export const WEBKLAR_KONTAKT = Object.freeze({
name: 'WEBklar GbR',
adresse: 'Am Schwimmbad 10, 67722 Winnweiler',
telefon: '0176 23726355',
email: 'support@webklar.com',
})
/**
* Widerrufsbelehrung und Muster-Widerrufsformular fuer die Bestellung der Einrichtung einer Funktion (Terminplaner, News):
* WEBklar ist der Unternehmer, Sie-Form, Text mit Zeilenumbruechen wie widerrufsbelehrung. Gleiche Bausteine wie bei den
* Gaesten (Frist „des Vertragsabschlusses.“, Wertersatz fuer Dienstleistungen), aber die Widerrufsfunktion nennt die Adresse
* der Widerrufsseite (Wortlaut wie in den Abo-Bedingungen, Abschnitt Widerrufsbelehrung) und die „An“-Zeile des Formulars nennt
* die E-Mail-Adresse mit „E-Mail:“. Quelle des Dokuments docs/rechtliches/widerrufsbelehrung-funktionen.md; test/termineRechtstexte.test.js
* haelt beide gleich und gleicht die Belehrung mit public/abo-bedingungen.html ab.
*/
export function widerrufsbelehrungFunktion() {
const daten = betriebsdaten(WEBKLAR_KONTAKT, { vollstaendig: true })
const an = `An ${verbinde([daten.name, daten.adresse, `E-Mail: ${daten.email}`])}:`
return [...belehrungsAbsaetze(daten, WIDERRUFSFUNKTION_WEBKLAR), ...formularAbsaetze(daten, an)].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
/**
* Aufbewahrung vergangener Termine in Tagen; ein nicht ganzzahliger Wert ist die Vorgabe (90). Den Bereich 14 bis 365 stellt die
* Einstellung sicher (normalisiereEinstellung); der Loeschlauf (jobs.js) prueft ihn zusaetzlich beim Lesen.
*/
const aufbewahrungNachTermin = (einstellung) =>
Number.isInteger(einstellung?.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
/**
* 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
@@ -132,7 +185,7 @@ const AUFBEWAHRUNG_VORGABE_TAGE = 90
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 tage = aufbewahrungNachTermin(einstellung)
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.`
@@ -147,6 +200,180 @@ export function formularHinweis(einstellung) {
+ `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
}
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (recherche-recht.md 11.1)
const DS_PROTOKOLL_TAGE = 14 // Server-Protokolle (Datenschutzerklaerung von WEBklar, Abschnitt 3)
const DS_EMAIL_WARTESCHLANGE =
'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.'
/** Auftragsverarbeiter und Server: in beiden Anreden derselbe Text. */
const DS_AUFTRAGSVERARBEITER =
'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.'
const wenn = (bedingung, text) => (bedingung ? text : '')
/** Sätze eines Absatzes, durch ein Leerzeichen getrennt; ein weggelassener Satz (leer) hinterlässt keine Lücke. */
const saetze = (...liste) => liste.filter(Boolean).join(' ')
/**
* Die fuenf bedingten Stellen des Absatzes (in der Markdown-Quelle „[[wenn …: …]]“): Telefon-Feld, Nachricht oder Zusatzfelder,
* Erinnerung, Widerruf (wie der Widerrufsknopf im Widget: zeigtWiderruf), Telefonnummer des Betriebs.
*/
function datenschutzBedingungen(einstellung, daten) {
const gast = einstellung.gastFelder || {}
return {
telefonFeld: gast.telefon !== 'aus',
freitext: gast.nachricht !== 'aus' || (Array.isArray(gast.zusatz) && gast.zusatz.length > 0),
erinnerung: Boolean(einstellung.erinnerung?.anbieten),
widerruf: zeigtWiderruf(einstellung),
telefon: daten.telefon !== '',
}
}
// Absaetze der Sie-Fassung, je Absatz eine kleine Funktion; d = { firma, verantwortlich, email, telefon, tage, haltMinuten, ...Bedingungen }
const DS_SIE = [
() => 'Online-Terminbuchung',
() => 'Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
(d) =>
'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(d.telefonFeld, ', auf Wunsch Ihre Telefonnummer')
+ wenn(d.freitext, ' 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(d.erinnerung, ', zum Beispiel ob Sie eine Erinnerung gewünscht haben')
+ '.',
(d) =>
saetze(
'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(
d.erinnerung,
'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(
d.widerruf,
'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).',
),
(d) =>
'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(d.erinnerung, ' 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 ${d.firma}, Ihre Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
() => DS_AUFTRAGSVERARBEITER,
() =>
'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 ${DS_PROTOKOLL_TAGE} 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.',
(d) =>
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
(d) =>
saetze(
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
wenn(d.telefon, `Sie können uns auch telefonisch erreichen: ${d.telefonnummer}.`),
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.',
),
(d) =>
'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 ${d.verantwortlich}, E-Mail ${d.email}.`,
]
// Absaetze der Du-Fassung, gleicher Aufbau wie DS_SIE
const DS_DU = [
() => 'Online-Terminbuchung',
() => 'Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
(d) =>
'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(d.telefonFeld, ', auf Wunsch deine Telefonnummer')
+ wenn(d.freitext, ' 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(d.erinnerung, ', zum Beispiel ob du eine Erinnerung gewünscht hast')
+ '.',
(d) =>
saetze(
'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(
d.erinnerung,
'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(
d.widerruf,
'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).',
),
(d) =>
'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(d.erinnerung, ' 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 ${d.firma}, deine Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
() => DS_AUFTRAGSVERARBEITER,
() =>
'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 ${DS_PROTOKOLL_TAGE} 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.',
(d) =>
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
(d) =>
saetze(
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
wenn(d.telefon, `Du kannst uns auch telefonisch erreichen: ${d.telefonnummer}.`),
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.',
),
(d) =>
'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 ${d.verantwortlich}, E-Mail ${d.email}.`,
]
/**
* Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs, als reiner Text (nicht HTML-maskiert):
* Absaetze durch eine Leerzeile, erste Zeile „Online-Terminbuchung“. anrede === 'du' ist die Du-Fassung, alles andere die Sie-Fassung
* (wie formularHinweis); die Anrede kommt aus dem Parameter, nicht aus einstellung.anrede. Sätze, die zu abgeschalteten Funktionen
* gehören, fehlen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs, siehe datenschutzBedingungen).
* Die Aufbewahrung nach dem Termin ist einstellung.aufbewahrungTage (ungültig: 90). Fehlt der Betriebsname oder die E-Mail-Adresse:
* TypeError (wie bei den anderen Rechtstexten); eine fehlende Anschrift steht einfach nicht im Text.
*/
export function datenschutzAbsatz(einstellung, anrede) {
const daten = betriebsdaten(einstellung?.betrieb)
const absaetze = anrede === 'du' ? DS_DU : DS_SIE
const d = {
...datenschutzBedingungen(einstellung, daten),
firma: daten.name,
verantwortlich: verbinde([daten.name, daten.adresse]),
email: daten.email,
telefonnummer: daten.telefon,
tage: aufbewahrungNachTermin(einstellung),
haltMinuten: config.termine.holdMinutes, // Gueltigkeit des Bestaetigungslinks, bei jedem Aufruf gelesen
}
return absaetze.map((absatz) => absatz(d)).join(ABSATZ)
}
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
/**
@@ -162,8 +389,12 @@ export const ZUSTIMMUNG_VORZEITIG = (name) =>
/** 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.`
/**
* Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher.
* Der zweite Satz weist auf das Widerrufsrecht der Einwilligung hin (Art. 7 Abs. 3 DSGVO); der Hash im Nachweis deckt beide Saetze.
*/
export const ZUSTIMMUNG_ERINNERUNG = (stunden) =>
`Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
/** Hinweis ueber dem Nachrichtenfeld (Gesundheitsbereich ist ausgeschlossen, Spezifikation Abschnitt 4). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
+43 -5
View File
@@ -1,10 +1,48 @@
// STUB (Block B) – ersetzt Task D4
import { minimaleTicketDetails } from '../featureRegistry.js'
import { TICKET_DETAILS_MAX, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
import { einbauanleitung } from './einbauanleitung.js'
import { ladeStand } from './kontext.js'
/**
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details).
* ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben.
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details, TP2 Task D4).
* ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt: feature = Registereintrag (der Generator braucht ihn nicht),
* doc = portalFeatures-Dokument (siteKey, $id, customerId, projectId, unlockedByPurchase kommen von dort), project = websiteProjects-Dokument,
* customer = Kunde oder null (best effort).
*
* Inhalt: einbauanleitung(...).kurz, gebaut aus dem gespeicherten Stand der Einstellungen (ladeStand). Die Funktion wirft nie: bezahlt ist bezahlt,
* das Ticket entsteht immer. Scheitert das Lesen oder der Generator, stehen die Kopfzeilen, der Hinweis, dass die Anleitung nicht erzeugt werden
* konnte, und der Verweis auf das WOMS im Ticket (notfallDetails). Das Log nennt keine Daten des Betriebs.
*/
export async function termineTicketDetails(ctx) {
return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
try {
const { doc, project, customer } = ctx
const stand = await ladeStand(doc.$id)
const { kurz } = einbauanleitung({
feature: doc,
project,
einstellung: stand?.einstellung ?? null,
fragebogen: stand?.fragebogen ?? null,
version: stand?.version ?? 0,
updatedAt: stand?.updatedAt ?? null,
kunde: { name: customer?.name || doc.customerId || '' },
now: new Date(),
})
if (typeof kurz !== 'string' || kurz.trim() === '') throw new Error('Kurzfassung leer')
return kurz
} catch (err) {
console.error(`[termine-ticket] Kurzfassung der Einbauanleitung nicht erzeugbar, Notfall-Details: ${err?.message}`)
return notfallDetails(ctx)
}
}
const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).'
const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.'
/** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */
function notfallDetails(ctx) {
try {
const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } })
return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX)
} catch {
return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}`
}
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { neueId, normalisiereEinstellung } from './einstellungen.js'
import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js'
/**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
@@ -206,7 +206,8 @@ export function ausVorlage(key, betrieb, now) {
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: v.storno },
// Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag
storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' },
recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
}