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