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>
703 lines
32 KiB
JavaScript
703 lines
32 KiB
JavaScript
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),
|
||
}
|
||
}
|