Terminplaner: Einstellungen und Branchen-Vorlagen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 310f8434d6
commit 9cacd5bd3b
3 files changed
+1634

No files matched your search

+628
View File
@@ -0,0 +1,628 @@
import { randomBytes } from 'node:crypto'
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 und ohne config: 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 VORZEITIG_TAGE = 14
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
// ASCII- und C1-Steuerzeichen, Zeilen-/Absatztrenner und Richtungsumschalter (Anzeige-Spoofing in Mails und Widget)
const STEUERZEICHEN = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g
/** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */
function text(wert, max, { mehrzeilig = false } = {}) {
if (typeof wert !== 'string') return ''
let s = wert
if (mehrzeilig) {
s = s.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/\n{3,}/g, '\n\n')
} else {
s = s.replace(/[\r\n\t]+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ')
}
s = s.trim()
const zeichen = [...s]
return zeichen.length > max ? zeichen.slice(0, max).join('').trim() : 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. */
function emailAdresse(wert) {
if (typeof wert !== 'string') return null
const s = wert.replace(STEUERZEICHEN, '').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).'
/** https-URL (<= 300, ohne Zugangsdaten) in kanonischer Schreibweise, leer = null. */
function urlFeld(fehl, pfad, wert) {
if (wert === undefined || wert === null) return null
if (typeof wert !== 'string') {
fehl(pfad, URL_MELDUNG)
return null
}
const s = wert.replace(STEUERZEICHEN, '').trim()
if (s === '') return null
let url = null
if (s.length <= 300 && !/\s/.test(s)) {
try { url = new URL(s) } catch { url = null }
}
if (!url || url.protocol !== 'https:' || url.username || url.password || !url.hostname || url.href.length > 300) {
fehl(pfad, URL_MELDUNG)
return null
}
return url.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.
* => { einstellung } oder { fehler: { [pfad]: text } }.
*/
export function normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = null, 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 (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
}
// ---------------------------------------------------------------- Texte und Regeln
/** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */
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): verbindlich + Preis + Verbraucher und
* Terminbeginn vor now + 14 Tagen. Ein unbrauchbarer Beginn verlangt die Zustimmung lieber.
*/
export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) {
if (leistung?.buchungsart !== 'sofort' || !hatPreis(leistung) || !istVerbraucher(einstellung)) return false
return !(start.getTime() >= now.getTime() + VORZEITIG_TAGE * 86400000)
}
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,
},
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: verbraucher && leistungen.some((l) => l.buchungsart === 'sofort'),
erinnerung: { anbieten: e.erinnerung.anbieten, stundenVorher: e.erinnerung.stundenVorher },
gastFelder: structuredClone(e.gastFelder),
}
}
+218
View File
@@ -0,0 +1,218 @@
import { neueId, normalisiereEinstellung } from './einstellungen.js'
/**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
* Eine Vorlage liefert Leistungen, Wochenplan, Regeln und Stornotext als Vorschlag; Betriebsangaben kommen aus dem
* Kundenkonto (ausVorlage). Alle Preise sind Platzhalter zum Anpassen. Die Fristen im Stornotext stammen aus den
* Regeln (absageBisStd), damit Text und Selbstbedienung der Gaeste uebereinstimmen. TP2 nutzt dieselben Daten fuer den Fragebogen.
*/
const ZAHLARTEN = ['bar', 'ec', 'kreditkarte']
const STORNO_PROZENT = 50
/** Stornobedingungen Variante 1 (Zahlung vor Ort), recherche-recht-zahlung.md 7.3 F, mit X = Frist und Y = Prozent. */
const stornoText = (stunden, prozent) =>
`Sie können Ihren Termin bis ${stunden} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ `Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin ${prozent} % des Preises. `
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
/** Wochenplan aus { '1-5': [[von, bis], …], '6': … } (ISO-Wochentage, Pausen = Luecken). */
function wochenplan(spezifikation) {
const plan = {}
for (const [bereich, fenster] of Object.entries(spezifikation)) {
const [von, bis = von] = bereich.split('-').map(Number)
for (let tag = von; tag <= bis; tag += 1) plan[tag] = fenster
}
return plan
}
const person = (name, plan) => ({ name, typ: 'person', plan })
const platz = (name, plan) => ({ name, typ: 'platz', plan })
const leistung = (name, dauer, pufferNach, preisArt, preisCent, beschreibung = '') => ({ name, beschreibung, dauer, pufferNach, preisArt, preisCent })
const MITTAG = [['09:00', '12:00'], ['13:00', '17:00']]
const BUERO = wochenplan({ '1-5': MITTAG })
/**
* Eine Vorlage. ressourcen: Wochenplan je Person/Platz; leistungen gelten fuer alle Ressourcen der Vorlage;
* buchungsart gilt fuer alle Leistungen; gastFelder/recht/regeln ueberschreiben nur die Abweichung von den Vorgaben.
*/
function vorlage(key, name, beschreibung, daten) {
const regeln = { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, ...daten.regeln }
return {
key,
name,
beschreibung,
ressourcen: daten.ressourcen,
leistungen: daten.leistungen,
buchungsart: daten.buchungsart || 'sofort',
regeln,
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [], ...daten.gastFelder },
recht: { gaesteSind: 'verbraucher', freizeit: false, ...daten.recht },
storno: stornoText(regeln.absageBisStd, STORNO_PROZENT),
}
}
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
Object.values(wert).forEach(einfrieren)
}
return wert
}
export const VORLAGEN = einfrieren({
salon: vorlage('salon', 'Friseur und Salon', 'Haarschnitt, Färben und Bartpflege mit Terminen je Person; Preise als „ab“-Preise zum Anpassen.', {
ressourcen: [person('Stylist/in', wochenplan({ '2-5': [['09:00', '12:30'], ['13:30', '18:00']], '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Herrenschnitt', 30, 5, 'fest', 2800),
leistung('Damenschnitt', 45, 5, 'ab', 4500),
leistung('Kinderschnitt', 30, 5, 'fest', 1800),
leistung('Färben', 120, 10, 'ab', 7500),
],
}),
kosmetik: vorlage('kosmetik', 'Kosmetik und Nagelstudio', 'Gesichtsbehandlung, Maniküre und Pediküre mit Pause zum Aufräumen.', {
ressourcen: [person('Kosmetiker/in', wochenplan({ '1-5': MITTAG }))],
leistungen: [
leistung('Gesichtsbehandlung', 60, 10, 'ab', 6500),
leistung('Maniküre', 45, 10, 'fest', 3500),
leistung('Pediküre', 60, 10, 'fest', 4500),
leistung('Augenbrauen zupfen und färben', 20, 5, 'fest', 1800),
],
}),
beratung: vorlage('beratung', 'Beratung und Coaching', 'Kennenlerngespräch kostenlos, Beratungstermine mit Preis; für Berater, Coaches und Kanzleien.', {
ressourcen: [person('Berater/in', BUERO)],
leistungen: [
leistung('Kennenlerngespräch', 30, 5, 'keiner', 0, 'Kostenlos und unverbindlich.'),
leistung('Beratung', 60, 10, 'fest', 9000),
leistung('Folgetermin', 30, 5, 'fest', 4500),
],
gastFelder: { nachricht: 'optional' },
recht: { gaesteSind: 'gemischt' },
}),
werkstatt: vorlage('werkstatt', 'Werkstatt und Reifenservice', 'Zwei Arbeitsplätze, Termine nach Platz; Kennzeichen wird mit abgefragt.', {
ressourcen: [
platz('Platz 1', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
platz('Platz 2', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
],
leistungen: [
leistung('Reifenwechsel', 45, 15, 'ab', 4900),
leistung('Ölwechsel', 45, 15, 'ab', 7900),
leistung('Inspektion', 120, 15, 'ab', 15000),
leistung('Bremsencheck', 30, 5, 'fest', 2900),
],
regeln: { raster: 30, vorlaufMin: 240, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }] },
recht: { gaesteSind: 'gemischt' },
}),
handwerk: vorlage('handwerk', 'Handwerk (Besichtigung vor Ort)', 'Besichtigung und Aufmaß als Anfrage; du bestätigst den Termin und planst die Anfahrt.', {
ressourcen: [person('Team', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '16:00']] }))],
leistungen: [
leistung('Besichtigung vor Ort', 60, 30, 'keiner', 0, 'Wir schauen uns die Aufgabe vor Ort an.'),
leistung('Aufmaß', 90, 30, 'keiner', 0),
leistung('Telefonische Beratung', 30, 0, 'keiner', 0),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
recht: { gaesteSind: 'gemischt' },
}),
tattoo: vorlage('tattoo', 'Tattoo und Piercing', 'Beratung und Sessions als Anfrage; Motiv, Größe und Stelle schreibt der Gast dazu.', {
ressourcen: [person('Tätowierer/in', wochenplan({ '2-6': [['11:00', '18:00']] }))],
leistungen: [
leistung('Beratungsgespräch', 30, 10, 'keiner', 0),
leistung('Kleines Tattoo', 120, 30, 'ab', 8000),
leistung('Tattoo-Session', 240, 30, 'ab', 25000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 120, absageBisStd: 48 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
}),
fotograf: vorlage('fotograf', 'Fotograf', 'Kennenlerngespräch und Shootings als Anfrage, mit längerem Vorlauf.', {
ressourcen: [person('Fotograf/in', wochenplan({ '1-5': [['10:00', '18:00']], '6': [['10:00', '14:00']] }))],
leistungen: [
leistung('Kennenlerngespräch', 30, 10, 'keiner', 0),
leistung('Porträt-Shooting', 90, 30, 'ab', 15000),
leistung('Familien-Shooting', 120, 30, 'ab', 25000),
leistung('Business-Porträt', 60, 15, 'ab', 12000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 2880, horizontTage: 120, absageBisStd: 48 },
gastFelder: { nachricht: 'optional' },
}),
hundesalon: vorlage('hundesalon', 'Hundesalon', 'Pflege nach Größe des Hundes; Name und Rasse werden mit abgefragt.', {
ressourcen: [person('Hundefriseur/in', wochenplan({ '2-5': MITTAG, '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Kleiner Hund', 60, 10, 'ab', 3500),
leistung('Mittelgroßer Hund', 90, 10, 'ab', 5500),
leistung('Großer Hund', 120, 15, 'ab', 7500),
leistung('Krallen schneiden', 15, 5, 'fest', 1000),
],
regeln: { vorlaufMin: 240 },
gastFelder: { zusatz: [{ key: 'hund', label: 'Name und Rasse des Hundes', typ: 'text', pflicht: true }] },
}),
sonstiges: vorlage('sonstiges', 'Sonstiges', 'Neutrale Vorlage mit zwei Terminlängen, die du frei umbenennst.', {
ressourcen: [person('Team', BUERO)],
leistungen: [
leistung('Termin (30 Minuten)', 30, 0, 'keiner', 0),
leistung('Termin (60 Minuten)', 60, 0, 'keiner', 0),
],
}),
})
/** [{ key, name, beschreibung }] in fester Reihenfolge, fuer die Auswahl beim ersten Oeffnen. */
export function vorlagenListe() {
return Object.values(VORLAGEN).map(({ key, name, beschreibung }) => ({ key, name, beschreibung }))
}
/**
* Gueltige Einstellung aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer,
* impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf.
* Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler.
*/
export function ausVorlage(key, betrieb, now) {
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`)
const v = VORLAGEN[key]
const b = betrieb || {}
const ressourcen = v.ressourcen.map((r) => ({ id: neueId('r'), name: r.name, typ: r.typ, aktiv: true, plan: structuredClone(r.plan) }))
const eingabe = {
vorlage: key,
betrieb: {
name: b.name,
email: b.email,
telefon: b.telefon,
adresse: b.adresse,
kleinunternehmer: b.kleinunternehmer === true,
impressumUrl: b.impressumUrl,
datenschutzUrl: b.datenschutzUrl,
agbUrl: b.agbUrl,
websiteUrl: b.websiteUrl,
},
anrede: 'sie',
bundesland: b.bundesland,
ressourcen,
leistungen: v.leistungen.map((l) => ({ ...l, id: neueId('s'), buchungsart: v.buchungsart, aktiv: true, ressourcen: ressourcen.map((r) => r.id) })),
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: v.storno },
recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
}
const ergebnis = normalisiereEinstellung(eingabe, { now })
if (ergebnis.fehler) {
const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.')
err.fehler = ergebnis.fehler
throw err
}
return ergebnis.einstellung
}