Files
Webklar-Kundenbereich/server/services/termine/einstellungen.js
T
ClaudeandClaude Sonnet 5.5 ee454017b0 Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben
einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js
(Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens
40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG-
Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung,
wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht
bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im
17er-Raster getestet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:55:42 +00:00

703 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomBytes } from 'node:crypto'
import { config } from '../../config.js'
import { withdrawalDeadline } from '../deadlines.js'
import { BUNDESLAENDER } from './feiertage.js'
import { heute, zuUtc } from './zeit.js'
/**
* Terminplaner-Einstellungen (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2, termineConfig.einstellung).
* Reine Funktionen ohne Appwrite (config nur fuer die Haltezeit in brauchtZustimmungVorzeitig): Pruefen und
* Normalisieren der Betriebs-Einstellung, Texte fuer Knoepfe und Preise, die oeffentliche Sicht fuer GET /config.
* `now` kommt immer als Parameter.
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
*/
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]
const GAST_FELD = ['aus', 'optional', 'pflicht']
const ZAHLARTEN = { bar: 'bar', ec: 'EC-Karte', kreditkarte: 'Kreditkarte', ueberweisung: 'Überweisung', paypal: 'PayPal' }
const RESERVIERTE_SCHLUESSEL = ['__proto__', 'constructor', 'prototype']
const VORLAGE_RE = /^[a-z]{1,20}$/
const FEED_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const MIN_MS = 60000
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const idRe = (praefix) => new RegExp(`^${praefix}_[a-z0-9]{8}$`)
// ---------------------------------------------------------------- IDs und Token
/** 'r_' / 's_' / 'a_' + 8 Zeichen aus a-z0-9 (Ressource, Leistung, Abwesenheit). */
export function neueId(praefix) {
if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Praefix: ${praefix}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of randomBytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length] // 252 = 7 * 36, ohne Verzerrung
}
}
return `${praefix}_${zeichen}`
}
/** Kalender-Abo-Token einer Ressource: 32 Zufallsbytes, base64url (43 Zeichen). */
export function neuerFeedToken() {
return randomBytes(32).toString('base64url')
}
// ---------------------------------------------------------------- Bausteine zum Pruefen
// Steuerzeichen (\p{Cc}: ASCII und C1), unsichtbare Formatzeichen (\p{Cf}: Zero-Width Space, Richtungsmarken und
// -umschalter, Word Joiner, BOM, Arabic Letter Mark, Soft Hyphen u. a.) sowie Zeilen-/Absatztrenner. Sie erzeugen
// leere oder taeuschende Namen in Absendernamen, Betreffzeilen und auf oeffentlichen Seiten.
const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu
// ZWNJ und ZWJ gehoeren zu Emoji-Sequenzen und persischer Schrift und bleiben in Freitexten erhalten.
const VERBINDER = new Set(['\u200C', '\u200D'])
/** Entfernt Steuerzeichen und unsichtbare Formatzeichen; behalteVerbinder: ZWNJ/ZWJ bleiben, umbruch: '\n' bleibt. */
function ohneSteuerzeichen(s, { behalteVerbinder = false, umbruch = false } = {}) {
return s.replace(STEUERZEICHEN, (z) => ((behalteVerbinder && VERBINDER.has(z)) || (umbruch && z === '\n') ? z : ''))
}
// Leerraum und ZWNJ/ZWJ am Rand entfernen: ein Text nur aus Verbindern bleibt sonst unsichtbar und besteht die Pflichtpruefung.
const randTrim = (s) => s.replace(/^[\s\u200C\u200D]+|[\s\u200C\u200D]+$/g, '')
/** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */
export function text(wert, max, { mehrzeilig = false } = {}) {
if (typeof wert !== 'string') return ''
let s = wert
if (mehrzeilig) {
s = ohneSteuerzeichen(s.replace(/\r\n?/g, '\n').replace(/\t/g, ' '), { behalteVerbinder: true, umbruch: true }).replace(/\n{3,}/g, '\n\n')
} else {
s = ohneSteuerzeichen(s.replace(/[\r\n\t]+/g, ' '), { behalteVerbinder: true }).replace(/ {2,}/g, ' ')
}
s = randTrim(s)
const zeichen = [...s]
return zeichen.length > max ? randTrim(zeichen.slice(0, max).join('')) : s
}
/** Wert in {min..max} (ganzzahlig, Vielfaches von schritt) oder Vorgabe; fehlender Wert ohne Vorgabe ist ein Fehler. */
function zahl(fehl, pfad, wert, { vorgabe, min, max, schritt = 1, meldung }) {
if (wert === undefined || wert === null) {
if (vorgabe === undefined) fehl(pfad, meldung)
return vorgabe
}
if (!Number.isInteger(wert) || wert < min || wert > max || wert % schritt !== 0) {
fehl(pfad, meldung)
return vorgabe
}
return wert
}
function wahl(fehl, pfad, wert, erlaubt, vorgabe, meldung) {
if (wert === undefined || wert === null) return vorgabe
if (!erlaubt.includes(wert)) {
fehl(pfad, meldung)
return vorgabe
}
return wert
}
function wahrheit(fehl, pfad, wert, vorgabe) {
if (wert === undefined || wert === null) return vorgabe
if (typeof wert !== 'boolean') {
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
return vorgabe
}
return wert
}
/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und >= 1 Punkt in der Domain, hoechstens 254 Zeichen. */
export function emailAdresse(wert) {
if (typeof wert !== 'string') return null
const s = ohneSteuerzeichen(wert).trim().toLowerCase()
if (s.length === 0 || s.length > 254) return null
if (!/^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s)) return null
return s
}
const URL_MELDUNG = 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).'
// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern.
const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/
/**
* https-URL (<= 300, ohne Zugangsdaten, nur ASCII-Hostname) in kanonischer Schreibweise, leer = null. Diese Adressen
* landen als href auf oeffentlichen Seiten und in Mails: Anfuehrungszeichen, Apostroph und Backtick im Host werden
* abgelehnt, ein Apostroph im Pfad wird als %27 gespeichert (URL kodiert ihn dort nicht selbst).
*/
function urlFeld(fehl, pfad, wert) {
if (wert === undefined || wert === null) return null
if (typeof wert !== 'string') {
fehl(pfad, URL_MELDUNG)
return null
}
const s = ohneSteuerzeichen(wert).trim()
if (s === '') return null
let url = null
if (s.length <= 300 && !/\s/.test(s)) {
try { url = new URL(s) } catch { url = null }
}
const href = url ? url.href.replace(/'/g, '%27') : ''
if (!url || url.protocol !== 'https:' || url.username || url.password || !HOST_RE.test(url.hostname) || href.length > 300) {
fehl(pfad, URL_MELDUNG)
return null
}
return href
}
/** ID eines Eintrags: fehlt sie, entsteht eine neue; sonst Format + Eindeutigkeit je Liste. */
function idFeld(fehl, pfad, wert, praefix, gesehen) {
let id = wert
if (id === undefined || id === null || id === '') {
do { id = neueId(praefix) } while (gesehen.has(id))
} else if (typeof id !== 'string' || !idRe(praefix).test(id)) {
fehl(pfad, 'Ungültige ID.')
return null
} else if (gesehen.has(id)) {
fehl(pfad, 'Diese ID kommt doppelt vor.')
return null
}
gesehen.add(id)
return id
}
function tagGueltig(tag) {
return typeof tag === 'string' && zuUtc(tag, '12:00') !== null
}
/** 'HH:MM' -> Minuten (5er-Raster, Ende darf 24:00 sein), sonst null. */
function minuten(wert, istEnde) {
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null
return h * 60 + min
}
const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
/** Liste von Fenstern [['09:00','12:30'], …]: 5er-Raster, aufsteigend, ohne Ueberlappung, Ende > Beginn. */
function fensterListe(fehl, pfad, wert) {
if (!Array.isArray(wert)) {
fehl(pfad, 'Ungültige Zeitfenster.')
return []
}
const aus = []
let vorher = null
for (const fenster of wert) {
const von = Array.isArray(fenster) && fenster.length === 2 ? minuten(fenster[0], false) : null
const bis = von === null ? null : minuten(fenster[1], true)
if (von === null || bis === null) {
fehl(pfad, 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben (Ende bis 24:00).')
return []
}
if (bis <= von) {
fehl(pfad, 'Das Ende muss nach dem Beginn liegen.')
return []
}
if (vorher && von < vorher.bis) {
fehl(pfad, von < vorher.von ? 'Zeitfenster müssen aufsteigend sortiert sein.' : 'Zeitfenster überlappen sich.')
return []
}
vorher = { von, bis }
aus.push([hhmm(von), hhmm(bis)])
}
return aus
}
// ---------------------------------------------------------------- Abschnitte
function normalisiereBetrieb(fehl, roh) {
const b = istObjekt(roh) ? roh : {}
const name = text(b.name, 80)
if (!name) fehl('betrieb.name', 'Bitte gib den Namen deines Betriebs an.')
const email = emailAdresse(b.email)
if (!email) fehl('betrieb.email', 'Bitte gib eine gültige E-Mail-Adresse an (genau ein Postfach).')
return {
name,
email: email || '',
telefon: text(b.telefon, 40),
kleinunternehmer: wahrheit(fehl, 'betrieb.kleinunternehmer', b.kleinunternehmer, false),
adresse: text(b.adresse, 200),
impressumUrl: urlFeld(fehl, 'betrieb.impressumUrl', b.impressumUrl),
datenschutzUrl: urlFeld(fehl, 'betrieb.datenschutzUrl', b.datenschutzUrl),
agbUrl: urlFeld(fehl, 'betrieb.agbUrl', b.agbUrl),
websiteUrl: urlFeld(fehl, 'betrieb.websiteUrl', b.websiteUrl),
}
}
/** Liste mit Obergrenze: undefined/null = leer; Nicht-Liste und zu lang = Fehler am Listenpfad. */
function liste(fehl, pfad, wert, max, meldung) {
if (wert === undefined || wert === null) return []
if (!Array.isArray(wert)) {
fehl(pfad, 'Ungültige Liste.')
return []
}
if (wert.length > max) {
fehl(pfad, meldung)
return []
}
return wert
}
function normalisiereWochenplan(fehl, pfad, plan) {
if (plan === undefined || plan === null) return {}
if (!istObjekt(plan)) {
fehl(pfad, 'Ungültiger Wochenplan.')
return {}
}
const aus = {}
for (const tag of Object.keys(plan)) {
if (!/^[1-7]$/.test(tag)) {
fehl(`${pfad}.${tag.slice(0, 20)}`, 'Ungültiger Wochentag (1 = Montag bis 7 = Sonntag).')
continue
}
const fenster = fensterListe(fehl, `${pfad}.${tag}`, plan[tag])
if (fenster.length) aus[tag] = fenster
}
return aus
}
function normalisiereRessourcen(fehl, roh, alt) {
const feedTokens = new Map(
(Array.isArray(alt?.ressourcen) ? alt.ressourcen : [])
.filter((r) => typeof r?.id === 'string' && typeof r.feedToken === 'string' && FEED_TOKEN_RE.test(r.feedToken))
.map((r) => [r.id, r.feedToken]),
)
const gesehen = new Set()
const aus = []
liste(fehl, 'ressourcen', roh, GRENZEN.ressourcen, `Es sind höchstens ${GRENZEN.ressourcen} Personen oder Plätze möglich.`).forEach((r, i) => {
const pfad = `ressourcen.${i}`
if (!istObjekt(r)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, r.id, 'r', gesehen)
const name = text(r.name, 80)
if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.')
aus.push({
id,
typ: wahl(fehl, `${pfad}.typ`, r.typ, ['person', 'platz'], 'person', 'Bitte „Person“ oder „Platz“ wählen.'),
name,
aktiv: wahrheit(fehl, `${pfad}.aktiv`, r.aktiv, true),
plan: normalisiereWochenplan(fehl, `${pfad}.plan`, r.plan),
feedToken: (id && feedTokens.get(id)) || neuerFeedToken(), // nie vom Client
})
})
return aus
}
function normalisiereLeistungen(fehl, roh, ressourcen) {
const alleIds = new Set(ressourcen.map((r) => r.id))
const aktiveIds = new Set(ressourcen.filter((r) => r.aktiv).map((r) => r.id))
const gesehen = new Set()
const aus = []
liste(fehl, 'leistungen', roh, GRENZEN.leistungen, `Es sind höchstens ${GRENZEN.leistungen} Leistungen möglich.`).forEach((l, i) => {
const pfad = `leistungen.${i}`
if (!istObjekt(l)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, l.id, 's', gesehen)
const name = text(l.name, 100)
if (!name) fehl(`${pfad}.name`, 'Bitte gib einen Namen an.')
const dauer = zahl(fehl, `${pfad}.dauer`, l.dauer, { min: 5, max: 480, schritt: 5, meldung: 'Die Dauer muss zwischen 5 und 480 Minuten liegen (in 5-Minuten-Schritten).' })
const pufferNach = zahl(fehl, `${pfad}.pufferNach`, l.pufferNach, { vorgabe: 0, min: 0, max: 120, schritt: 5, meldung: 'Die Pause danach muss zwischen 0 und 120 Minuten liegen (in 5-Minuten-Schritten).' })
if (dauer !== undefined && dauer + pufferNach > 500) fehl(`${pfad}.dauer`, 'Dauer und Pause danach dürfen zusammen höchstens 500 Minuten betragen.')
const preisArt = wahl(fehl, `${pfad}.preisArt`, l.preisArt, ['fest', 'ab', 'keiner'], 'keiner', 'Bitte „Festpreis“, „ab“ oder „kein Preis“ wählen.')
const preisCent = preisArt === 'keiner'
? 0
: zahl(fehl, `${pfad}.preisCent`, l.preisCent, { vorgabe: 0, min: 0, max: 1000000, meldung: 'Der Preis muss zwischen 0 und 10.000 Euro liegen.' })
const aktiv = wahrheit(fehl, `${pfad}.aktiv`, l.aktiv, true)
const zuordnung = []
if (l.ressourcen !== undefined && l.ressourcen !== null && !Array.isArray(l.ressourcen)) {
fehl(`${pfad}.ressourcen`, 'Ungültige Zuordnung.')
} else {
for (const rid of l.ressourcen || []) {
if (typeof rid !== 'string' || !alleIds.has(rid)) {
fehl(`${pfad}.ressourcen`, 'Die Zuordnung enthält eine unbekannte Person oder einen unbekannten Platz.')
} else if (!zuordnung.includes(rid)) {
zuordnung.push(rid)
}
}
}
if (aktiv && !zuordnung.some((rid) => aktiveIds.has(rid))) {
fehl(`${pfad}.ressourcen`, 'Eine aktive Leistung braucht mindestens eine aktive Person oder einen aktiven Platz.')
}
aus.push({
id,
name,
beschreibung: text(l.beschreibung, 300),
dauer,
pufferNach,
preisArt,
preisCent,
buchungsart: wahl(fehl, `${pfad}.buchungsart`, l.buchungsart, ['sofort', 'anfrage'], 'sofort', 'Bitte „sofort buchen“ oder „Anfrage“ wählen.'),
ressourcen: zuordnung,
aktiv,
reihenfolge: i + 1,
})
})
return aus
}
function normalisiereAbwesenheiten(fehl, roh, ressourcen) {
const alleIds = new Set(ressourcen.map((r) => r.id))
const gesehen = new Set()
const aus = []
liste(fehl, 'abwesenheiten', roh, GRENZEN.abwesenheiten, `Es sind höchstens ${GRENZEN.abwesenheiten} Abwesenheiten möglich.`).forEach((a, i) => {
const pfad = `abwesenheiten.${i}`
if (!istObjekt(a)) return fehl(pfad, 'Ungültiger Eintrag.')
const id = idFeld(fehl, `${pfad}.id`, a.id, 'a', gesehen)
let ressourceId = null
if (a.ressourceId !== undefined && a.ressourceId !== null) {
if (typeof a.ressourceId === 'string' && alleIds.has(a.ressourceId)) ressourceId = a.ressourceId
else fehl(`${pfad}.ressourceId`, 'Unbekannte Person oder unbekannter Platz.')
}
const vonOk = tagGueltig(a.von)
const bisOk = tagGueltig(a.bis)
if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.')
if (!bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.')
if (vonOk && bisOk && a.von > a.bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
aus.push({ id, ressourceId, von: vonOk ? a.von : '', bis: bisOk ? a.bis : '', titel: text(a.titel, 80) })
})
return aus
}
function normalisiereSonderoeffnungen(fehl, roh) {
if (roh === undefined || roh === null) return {}
if (!istObjekt(roh)) {
fehl('sonderoeffnungen', 'Ungültige Sonderöffnungen.')
return {}
}
const tage = Object.keys(roh)
if (tage.length > GRENZEN.sonderoeffnungen) {
fehl('sonderoeffnungen', `Es sind höchstens ${GRENZEN.sonderoeffnungen} Sonderöffnungstage möglich.`)
return {}
}
const aus = {}
for (const tag of tage.sort()) {
const pfad = `sonderoeffnungen.${tag.slice(0, 20)}`
if (!tagGueltig(tag)) {
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
continue
}
aus[tag] = fensterListe(fehl, pfad, roh[tag]) // [] = an diesem Tag geschlossen
}
return aus
}
function normalisiereGastFelder(fehl, roh) {
const g = istObjekt(roh) ? roh : {}
const meldung = 'Bitte „aus“, „optional“ oder „Pflicht“ wählen.'
const zusatz = []
const schluessel = new Set()
liste(fehl, 'gastFelder.zusatz', g.zusatz, GRENZEN.zusatzfelder, `Es sind höchstens ${GRENZEN.zusatzfelder} Zusatzfelder möglich.`).forEach((z, i) => {
const pfad = `gastFelder.zusatz.${i}`
if (!istObjekt(z)) return fehl(pfad, 'Ungültiger Eintrag.')
const key = typeof z.key === 'string' ? z.key : ''
if (!/^[a-z0-9_]{1,20}$/.test(key) || RESERVIERTE_SCHLUESSEL.includes(key)) {
fehl(`${pfad}.key`, 'Der Schlüssel besteht aus 1 bis 20 Kleinbuchstaben, Ziffern oder Unterstrichen.')
} else if (schluessel.has(key)) {
fehl(`${pfad}.key`, 'Dieser Schlüssel kommt doppelt vor.')
}
schluessel.add(key)
const label = text(z.label, 60)
if (!label) fehl(`${pfad}.label`, 'Bitte gib eine Beschriftung an.')
const typ = wahl(fehl, `${pfad}.typ`, z.typ, ['text', 'auswahl'], 'text', 'Bitte „Text“ oder „Auswahl“ wählen.')
let optionen = []
if (typ === 'auswahl') {
const roheOptionen = Array.isArray(z.optionen) ? z.optionen : []
optionen = [...new Set(roheOptionen.map((o) => text(o, 60)).filter(Boolean))]
if (roheOptionen.length > GRENZEN.optionen || optionen.length === 0) {
fehl(`${pfad}.optionen`, `Bitte gib 1 bis ${GRENZEN.optionen} Auswahlmöglichkeiten an.`)
optionen = optionen.slice(0, GRENZEN.optionen)
}
}
zusatz.push({ key, label, typ, optionen, pflicht: wahrheit(fehl, `${pfad}.pflicht`, z.pflicht, false) })
})
return {
telefon: wahl(fehl, 'gastFelder.telefon', g.telefon, GAST_FELD, 'optional', meldung),
nachricht: wahl(fehl, 'gastFelder.nachricht', g.nachricht, GAST_FELD, 'aus', meldung),
zusatz,
}
}
function normalisiereZahlung(fehl, roh) {
const z = istObjekt(roh) ? roh : {}
if (z.modus !== undefined && z.modus !== null && z.modus !== 'vorort') {
fehl('zahlung.modus', z.modus === 'online' || z.modus === 'beides' ? 'Online-Zahlung kommt bald' : 'Bitte „vor Ort“ wählen.')
}
let arten = ['bar', 'ec', 'kreditkarte']
if (z.vorOrtArten !== undefined && z.vorOrtArten !== null) {
if (!Array.isArray(z.vorOrtArten) || z.vorOrtArten.some((a) => !Object.hasOwn(ZAHLARTEN, a))) {
fehl('zahlung.vorOrtArten', 'Bitte wähle Zahlarten aus der Liste.')
} else {
arten = Object.keys(ZAHLARTEN).filter((a) => z.vorOrtArten.includes(a)) // feste Reihenfolge, ohne Doppelte
}
}
return { modus: 'vorort', vorOrtArten: arten }
}
/** Naechste Fassung 'YYYY-MM-DD-n': am selben Tag n + 1, sonst 1. */
function naechsteStornoVersion(bisherige, tag) {
const m = /^(\d{4}-\d{2}-\d{2})-(\d+)$/.exec(bisherige)
return m && m[1] === tag ? `${tag}-${Number(m[2]) + 1}` : `${tag}-1`
}
function normalisiereStorno(roh, alt, now) {
const neu = text(istObjekt(roh) ? roh.text : '', 2000, { mehrzeilig: true })
const altText = typeof alt?.text === 'string' ? alt.text : ''
const altVersion = typeof alt?.version === 'string' ? alt.version : ''
// Version nie vom Client: neue Fassung nur bei geaendertem (oder bisher unversioniertem) Text; wird der Text
// entfernt, bleibt die letzte Fassungsnummer stehen, damit sie spaeter nicht erneut vergeben wird.
const version = neu !== '' && (neu !== altText || altVersion === '') ? naechsteStornoVersion(altVersion, heute(now)) : altVersion
return { text: neu, version }
}
function pruefeEntfernte(fehl, alteListe, neueListe, zaehler, pfad, meldung) {
const bleibt = new Set(neueListe.map((e) => e.id))
for (const eintrag of Array.isArray(alteListe) ? alteListe : []) {
if (!eintrag || typeof eintrag.id !== 'string' || bleibt.has(eintrag.id)) continue
const n = istObjekt(zaehler) && Object.hasOwn(zaehler, eintrag.id) ? zaehler[eintrag.id] : 0
if (Number.isInteger(n) && n > 0) fehl(`${pfad}.${eintrag.id}`, meldung(eintrag, n))
}
}
const termine = (n) => (n === 1 ? '1 künftigen Termin' : `${n} künftige Termine`)
const MELDUNG_LEISTUNG = (_, n) => `Für diese Leistung gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.`
const MELDUNG_RESSOURCE = (r, n) => (r.typ === 'platz'
? `Für diesen Platz gibt es noch ${termine(n)}. Deaktiviere ihn stattdessen.`
: `Für diese Person gibt es noch ${termine(n)}. Deaktiviere sie stattdessen.`)
// ---------------------------------------------------------------- Normalisieren
/**
* Prueft und normalisiert eine Einstellung (Eingabe vom Client oder aus einer Vorlage).
* bisher = zuletzt gespeicherte Einstellung (Feed-Token, Storno-Fassung, Entfernen-Pruefung);
* kuenftigeTermine = { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } } kuenftiger belegender Termine.
* Pflichtangaben fuer Gaeste (fehlendePflichtangaben): Telefon immer, Anschrift bei Widerrufsbelehrung. entwurf: true
* laesst nur diese beiden Pruefungen aus (Entwurf aus einer Vorlage, Kunde ohne Telefon/Anschrift im Konto); als
* oeffentlich buchbar gilt eine solche Einstellung erst, wenn die Angaben da sind (kontextPerSiteKey).
* => { einstellung } oder { fehler: { [pfad]: text } }.
*/
export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, entwurf = false, now } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereEinstellung: now fehlt')
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
if (!istObjekt(eingabe)) return { fehler: { _: 'Die Einstellungen sind ungültig.' } }
const alt = istObjekt(bisher) ? bisher : null
let vorlage = eingabe.vorlage ?? alt?.vorlage ?? null
if (vorlage !== null && !(typeof vorlage === 'string' && VORLAGE_RE.test(vorlage))) {
fehl('vorlage', 'Ungültige Vorlage.')
vorlage = null
}
const betrieb = normalisiereBetrieb(fehl, eingabe.betrieb)
const ressourcen = normalisiereRessourcen(fehl, eingabe.ressourcen, alt)
const leistungen = normalisiereLeistungen(fehl, eingabe.leistungen, ressourcen)
pruefeEntfernte(fehl, alt?.leistungen, leistungen, kuenftigeTermine?.leistungen, 'leistungen', MELDUNG_LEISTUNG)
pruefeEntfernte(fehl, alt?.ressourcen, ressourcen, kuenftigeTermine?.ressourcen, 'ressourcen', MELDUNG_RESSOURCE)
const regeln = istObjekt(eingabe.regeln) ? eingabe.regeln : {}
const erinnerung = istObjekt(eingabe.erinnerung) ? eingabe.erinnerung : {}
const recht = istObjekt(eingabe.recht) ? eingabe.recht : {}
const einstellung = {
schema: 1,
vorlage,
betrieb,
anrede: wahl(fehl, 'anrede', eingabe.anrede, ['sie', 'du'], 'sie', 'Bitte „Sie“ oder „Du“ wählen.'),
farbe: farbe(fehl, eingabe.farbe),
bundesland: wahl(fehl, 'bundesland', eingabe.bundesland, BUNDESLAENDER, 'RP', 'Bitte wähle ein Bundesland.'),
feiertageGeschlossen: wahrheit(fehl, 'feiertageGeschlossen', eingabe.feiertageGeschlossen, true),
sonderoeffnungen: normalisiereSonderoeffnungen(fehl, eingabe.sonderoeffnungen),
abwesenheiten: normalisiereAbwesenheiten(fehl, eingabe.abwesenheiten, ressourcen),
ressourcen,
leistungen,
regeln: {
raster: wahl(fehl, 'regeln.raster', regeln.raster, RASTER, 15, 'Das Raster muss 5, 10, 15, 20, 30 oder 60 Minuten betragen.'),
vorlaufMin: zahl(fehl, 'regeln.vorlaufMin', regeln.vorlaufMin, { vorgabe: 120, min: 0, max: 20160, meldung: 'Die Vorlaufzeit muss zwischen 0 Minuten und 14 Tagen liegen.' }),
horizontTage: zahl(fehl, 'regeln.horizontTage', regeln.horizontTage, { vorgabe: 60, min: 1, max: 365, meldung: 'Der Buchungszeitraum muss zwischen 1 und 365 Tagen liegen.' }),
absageBisStd: zahl(fehl, 'regeln.absageBisStd', regeln.absageBisStd, { vorgabe: 24, min: 0, max: 336, meldung: 'Die Absagefrist muss zwischen 0 und 336 Stunden liegen.' }),
maxOffeneJeEmail: zahl(fehl, 'regeln.maxOffeneJeEmail', regeln.maxOffeneJeEmail, { vorgabe: 3, min: 1, max: 10, meldung: 'Erlaubt sind 1 bis 10 offene Termine je E-Mail-Adresse.' }),
personWaehlbar: wahrheit(fehl, 'regeln.personWaehlbar', regeln.personWaehlbar, true),
},
gastFelder: normalisiereGastFelder(fehl, eingabe.gastFelder),
erinnerung: {
anbieten: wahrheit(fehl, 'erinnerung.anbieten', erinnerung.anbieten, true),
stundenVorher: wahl(fehl, 'erinnerung.stundenVorher', erinnerung.stundenVorher, ERINNERUNG_STUNDEN, 24, 'Die Erinnerung geht 2, 12, 24 oder 48 Stunden vorher raus.'),
},
storno: normalisiereStorno(eingabe.storno, alt?.storno, now),
recht: {
gaesteSind: wahl(fehl, 'recht.gaesteSind', recht.gaesteSind, ['verbraucher', 'unternehmer', 'gemischt'], 'verbraucher', 'Bitte „Privatpersonen“, „Unternehmen“ oder „beides“ wählen.'),
freizeit: wahrheit(fehl, 'recht.freizeit', recht.freizeit, false),
},
zahlung: normalisiereZahlung(fehl, eingabe.zahlung),
aufbewahrungTage: zahl(fehl, 'aufbewahrungTage', eingabe.aufbewahrungTage, { vorgabe: 90, min: 14, max: 365, meldung: 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.' }),
}
if (!entwurf) {
for (const pfad of fehlendePflichtangaben(einstellung)) fehl(pfad, MELDUNG_PFLICHT[pfad])
}
if (Object.keys(fehler).length === 0 && JSON.stringify(einstellung).length > GRENZEN.json) {
fehl('_', 'Die Einstellungen sind zu umfangreich. Bitte entferne Zeitfenster oder Einträge.')
}
return Object.keys(fehler).length ? { fehler } : { einstellung }
}
function farbe(fehl, wert) {
if (wert === undefined || wert === null) return VORGABE_FARBE
const s = typeof wert === 'string' ? wert.trim().toLowerCase() : ''
if (!/^#[0-9a-f]{6}$/.test(s)) {
fehl('farbe', 'Bitte wähle eine Farbe im Format #rrggbb.')
return VORGABE_FARBE
}
return s
}
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
export const MELDUNG_PFLICHT = {
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
}
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
export function sichtbareLeistungen(einstellung) {
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
}
/**
* Zeigt die oeffentliche Sicht eine Widerrufsbelehrung (oeffentlicheEinstellung.widerruf)? Verbraucher-Gaeste und
* mindestens eine sichtbare Leistung, gleich ob sofort oder als Anfrage (Ruling R55: auch ein Anfrage-Vertrag, der mit
* der Annahme zustande kommt, ist ein Fernabsatzvertrag; die Belehrung steht im G02 jeder Verbraucher-Buchung).
*/
export function zeigtWiderruf(einstellung) {
return istVerbraucher(einstellung) && sichtbareLeistungen(einstellung).length > 0
}
/**
* Pflichtangaben des Betriebs, die in einer Einstellung fehlen: 'betrieb.telefon' (immer noetig) und 'betrieb.adresse'
* (noetig, sobald die Widerrufsbelehrung erscheint, zeigtWiderruf). Reihenfolge Telefon, Anschrift; [] = vollstaendig.
* Ohne diese Angaben ist der Terminplaner fuer Gaeste nicht buchbar.
*/
export function fehlendePflichtangaben(einstellung) {
const betrieb = einstellung?.betrieb || {}
const fehlt = []
if (typeof betrieb.telefon !== 'string' || betrieb.telefon.trim() === '') fehlt.push('betrieb.telefon')
if (zeigtWiderruf(einstellung) && (typeof betrieb.adresse !== 'string' || betrieb.adresse.trim() === '')) fehlt.push('betrieb.adresse')
return fehlt
}
// ---------------------------------------------------------------- Texte und Regeln
/** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */
export function hatPreis(leistung) {
return !!leistung && leistung.preisArt !== 'keiner' && Number.isInteger(leistung.preisCent) && leistung.preisCent > 0
}
/** '45,00 €' | 'ab 45,00 €' | '' (kein Preis). */
export function preisText(leistung) {
if (!hatPreis(leistung)) return ''
const euro = String(Math.floor(leistung.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
const cent = String(leistung.preisCent % 100).padStart(2, '0')
return `${leistung.preisArt === 'ab' ? 'ab ' : ''}${euro},${cent} €`
}
/** Knopf im Widget; bestaetigen = Knopf auf der Bestaetigungsseite. */
export function knopfText(leistung, { bestaetigen = false } = {}) {
if (leistung?.buchungsart === 'anfrage') return bestaetigen ? 'Anfrage absenden' : 'Termin anfragen'
if (hatPreis(leistung)) return bestaetigen ? 'Zahlungspflichtig bestätigen' : 'Zahlungspflichtig buchen'
return bestaetigen ? 'Termin bestätigen' : 'Termin buchen'
}
/** Gaeste gelten als Verbraucher, ausser der Betrieb hat nur Unternehmen oder bietet eine Freizeitleistung an. */
export function istVerbraucher(einstellung) {
const recht = einstellung?.recht || {}
return recht.gaesteSind !== 'unternehmer' && !recht.freizeit
}
/**
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): Preis + Verbraucher und
* - sofort buchbar: Terminbeginn bis zum Ende der Widerrufsfrist (withdrawalDeadline, Berliner Kalender, §§ 187, 188, 193
* BGB). Der Vertrag kommt spaetestens nach der Haltezeit (config.termine.holdMinutes) zustande, gerechnet wird mit
* `now` plus Haltezeit.
* - Anfrage: immer. Den Tag der Annahme kennt niemand im Voraus, die Frist beginnt erst dann; die Zustimmung zu viel zu
* verlangen schadet nicht.
* Ein unbrauchbarer Beginn verlangt die Zustimmung lieber.
*/
export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) {
if (!hatPreis(leistung) || !istVerbraucher(einstellung)) return false
if (leistung.buchungsart === 'anfrage') return true
if (leistung.buchungsart !== 'sofort') return false
const fristEnde = withdrawalDeadline(new Date(now.getTime() + config.termine.holdMinutes * MIN_MS)).endsAt.getTime()
return !(start.getTime() > fristEnde)
}
export function preisHinweis(einstellung) {
return einstellung?.betrieb?.kleinunternehmer === true ? 'kein Ausweis der USt nach § 19 UStG' : 'inkl. USt'
}
/** 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' (§ 312j Abs. 1 BGB: Zahlungsart steht vor der Buchung). */
export function zahlungText(einstellung) {
const arten = (einstellung?.zahlung?.vorOrtArten || []).filter((a) => Object.hasOwn(ZAHLARTEN, a)).map((a) => ZAHLARTEN[a])
return arten.length ? `Bezahlung vor Ort: ${arten.join(', ')}` : 'Bezahlung vor Ort'
}
// ---------------------------------------------------------------- Oeffentliche Sicht
/**
* Felder fuer GET /config (ohne aktiv, hinweis, texte, formToken – die setzt die Route). Nur aktive Leistungen mit
* mindestens einer aktiven Ressource, nur aktive Ressourcen; nie feedToken, Betriebs-E-Mail oder interne Regeln.
*/
export function oeffentlicheEinstellung(einstellung, now) {
const e = einstellung
const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const aktiveIds = new Set(ressourcen.map((r) => r.id))
const verbraucher = istVerbraucher(e)
const leistungen = []
for (const l of e.leistungen || []) {
if (!l.aktiv) continue
const ids = (l.ressourcen || []).filter((id) => aktiveIds.has(id))
if (ids.length === 0) continue
leistungen.push({
id: l.id,
name: l.name,
beschreibung: l.beschreibung,
dauer: l.dauer,
preis: preisText(l),
preisArt: l.preisArt,
buchungsart: l.buchungsart,
knopf: knopfText(l),
verbraucher,
ressourcen: ids,
})
}
return {
heute: heute(now),
betrieb: {
name: e.betrieb.name,
telefon: e.betrieb.telefon,
adresse: e.betrieb.adresse,
impressumUrl: e.betrieb.impressumUrl,
datenschutzUrl: e.betrieb.datenschutzUrl,
agbUrl: e.betrieb.agbUrl, // https-Adresse oder null (urlFeld); das Widget zeigt sie als Link neben den Stornobedingungen
},
anrede: e.anrede,
farbe: e.farbe,
leistungen,
ressourcen: ressourcen.map((r) => ({ id: r.id, name: r.name, typ: r.typ })),
personWaehlbar: e.regeln.personWaehlbar,
regeln: { horizontTage: e.regeln.horizontTage, absageBisStd: e.regeln.absageBisStd },
zahlung: { modus: 'vorort', text: zahlungText(e) },
preisHinweis: preisHinweis(e),
storno: e.storno?.text ? { text: e.storno.text, version: e.storno.version } : null,
widerruf: zeigtWiderruf(e),
erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher },
gastFelder: structuredClone(e.gastFelder),
}
}