Öffnungszeiten: Prüfregeln für Zeiten und Team-Einstellungen

normalisiereZeiten/normalisiereTeam prüfen und normalisieren alles, was Kunde und
Team speichern: Fensterregeln, Ausnahmen, Hinweise, Kontakt, Telefon, Links, Größe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 00:35:10 +00:00
1 parent 5f52ef54e8
commit 88fb9263f9
2 files changed
+1368

No files matched your search

+601
View File
@@ -0,0 +1,601 @@
import { randomInt } from 'node:crypto'
import { text } from '../termine/einstellungen.js'
import { BUNDESLAENDER } from '../termine/feiertage.js'
import { heute, plusTage } from '../termine/zeit.js'
/**
* Oeffnungszeiten live: Pruefen und Normalisieren (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1).
* Rein: kein Appwrite, keine Config, kein eigener Zeitgeber ('now' ist Pflicht). Jede Speicherung (Kunde, Kundenansicht,
* Team) geht durch normalisiereZeiten / normalisiereTeam; gespeichert wird nur das Ergebnis.
*
* Ergebnis: { daten } bzw. { team } oder { fehler: { [pfad]: text } } (je Pfad die erste Meldung, Texte in Du-Form).
* Pfade tragen die IDs der Eintraege: 'plaene.<id>.name', 'plaene.<id>.tage.3', 'ausnahmen.<id>.von',
* 'hinweise.<id>.knopf.ziel', 'kontakt.telefon', 'daten' (zu gross). Eintraege ohne Objektform haben nur einen Index
* ('ausnahmen.4'); ein Eintrag ohne gueltige ID bekommt eine neue und der Pfad nennt diese.
*
* Kanonische Form (gespeichert wird nur sie, damit gleiche Zeiten gleich aussehen und wochenplanHash stabil bleibt):
* - je Tag/Eintrag modus 'zu'|'zeiten'|'rund'|'vereinbarung'; fenster nur bei 'zeiten' (dort 1-2 Fenster, nach Beginn
* sortiert), sonst immer []; alle sieben Wochentage sind da (fehlende = zu);
* - unbekannte Schluessel fallen ueberall weg; Texte ohne Steuerzeichen, Zeitpunkte als toISOString;
* - ausnahmen und hinweise BLEIBEN in Eingabereihenfolge (Gleichstand in der Zeitlogik: der spaetere Index gewinnt),
* feiertage ist nach Datum sortiert; Plaene behalten ihre Reihenfolge.
*
* Ausnahmen/Hinweise mit Ende vor heute - 30 Tagen und feiertage-Schluessel vor diesem Tag werden entfernt (nach der Pruefung).
*/
export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, fensterJeTag: 2, ausnahmen: 100, hinweise: 20, urlaubTage: 366, vergangenTage: 30, daten: 32768 })
export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24, navSelektor: 120 })
const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
const AUSNAHME_ARTEN = Object.freeze(['urlaub', 'sondertag'])
const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig'])
const KNOPF_ARTEN = Object.freeze(['link', 'anruf'])
const STANDARD_LAND = 'RP'
const DATEN_ZU_GROSS = 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.'
// Obergrenzen fuer Rohdaten, damit uebergrosse Eingaben nie teuer werden (Pruefung kommt danach mit der echten Grenze).
const ROH_TEXT = 5000
const ROH_LISTE = 1000
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
const KURZ_RE = /^[a-z0-9-]{1,24}$/
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
// Zeitpunkt mit Zeitzone ('Z' oder +hh:mm), Sekunden optional; der Kalendertag wird gesondert geprueft (Date rollt 30.02. um).
const ZEITPUNKT_RE = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const fehltWert = (wert) => wert === undefined || wert === null
/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */
const istTag = (t) => typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t
/** Kalendertage von bis zu (jeweils 'YYYY-MM-DD'), ohne Tages-Millisekunden-Annahmen ueber die Umstellung zu brauchen (UTC-Mitternacht). */
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
const hhmm = (min) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`
// ---------------------------------------------------------------- IDs und Kurznamen
function neueId(praefix) {
let zeichen = ''
for (let i = 0; i < 8; i += 1) zeichen += ID_ALPHABET[randomInt(ID_ALPHABET.length)]
return `${praefix}_${zeichen}`
}
/**
* IDs fuer eine Liste: gueltige, in der Liste noch nicht gesehene bleiben, alle anderen (fehlend, falsches Format,
* doppelt) werden neu erzeugt (kryptografisch, praefix_ + 8 Zeichen a-z0-9) und kollidieren nie mit einer gegebenen.
*/
function ausweisen(liste, praefix) {
const gesehen = new Set()
const gegeben = liste.map((e) => {
const id = istObjekt(e) ? e.id : undefined
if (typeof id === 'string' && ID_RE.test(id) && !gesehen.has(id)) {
gesehen.add(id)
return id
}
return null
})
return gegeben.map((id) => {
if (id) return id
let neu
do { neu = neueId(praefix) } while (gesehen.has(neu))
gesehen.add(neu)
return neu
})
}
/**
* Kurzname [a-z0-9-]{1,24} aus dem Namen: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich; leer = 'plan';
* ist er in vorhandene (Array oder Set), kommt -2, -3, ... davor (immer <= 24 Zeichen). Gleiche Regel im Editor (Task 27).
*/
export function kurzname(name, vorhandene = []) {
const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene)
let basis = (typeof name === 'string' ? name : '')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize('NFKD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '')
if (basis === '') basis = 'plan'
if (!belegt.has(basis)) return basis
for (let n = 2; ; n += 1) {
const endung = `-${n}`
const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}`
if (!belegt.has(kandidat)) return kandidat
}
}
// ---------------------------------------------------------------- Bausteine zum Pruefen
/**
* Bereinigter Text (text() aus dem Terminplaner: ohne Steuerzeichen, getrimmt, Umbrueche = Leerzeichen); zu lang ist ein
* Fehler, nie ein stilles Kuerzen. Fehlt der Wert, ist das Ergebnis '' (mit pflicht ein Fehler). Fehler liefern ''.
*/
function feldText(fehl, pfad, wert, max, { pflicht = false, leer = 'Bitte fülle dieses Feld aus.' } = {}) {
if (fehltWert(wert)) {
if (pflicht) fehl(pfad, leer)
return ''
}
if (typeof wert !== 'string') {
fehl(pfad, 'Bitte gib einen Text an.')
return ''
}
const s = wert.length > ROH_TEXT ? null : text(wert, ROH_TEXT)
if (s === null || [...s].length > max) {
fehl(pfad, `Höchstens ${max} Zeichen.`)
return ''
}
if (pflicht && s === '') fehl(pfad, leer)
return s
}
function wahrheit(fehl, pfad, wert, vorgabe) {
if (fehltWert(wert)) return vorgabe
if (typeof wert !== 'boolean') {
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
return vorgabe
}
return wert
}
/** Kalendertag oder null: fehlend/leer = null; ungueltig = Fehler (und null). */
function tagOptional(fehl, pfad, wert) {
if (fehltWert(wert) || wert === '') return null
if (!istTag(wert)) {
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
return null
}
return wert
}
/** Zeitpunkt (ISO mit Zeitzone) als toISOString oder null. */
function zeitpunkt(wert) {
const m = typeof wert === 'string' ? ZEITPUNKT_RE.exec(wert) : null
if (!m || !istTag(m[1])) return null
const ms = Date.parse(wert)
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
}
// ---------------------------------------------------------------- Telefon und Links
/**
* Telefonnummer -> nur Ziffern mit Laendervorwahl (deutsche Nummern mit fuehrendem 49), sonst null.
* '0171 1234567', '+49 171 1234567', '0049 171 1234567', '+49 (0) 171 1234567' -> '491711234567'; '(06 31) 12 34' -> '496311234'.
* Erlaubt sind Ziffern, Leerzeichen, ( ) / . - und ein '+' ganz vorn; Buchstaben und alles andere = null. Eine Nummer ohne
* 0, 00 oder + vorn gilt als schon international (so bleibt ein gespeichertes '491711234567' beim erneuten Speichern gleich);
* 6 bis 15 Ziffern insgesamt (E.164).
*/
export function telefonZiffern(eingabe) {
if (typeof eingabe !== 'string' || eingabe.length > 100) return null
let s = eingabe.replace(/\s+/g, ' ').trim()
if (!/^\+?[0-9 ()/.-]+$/.test(s)) return null
s = s.replace(/^(\+ ?49|00 ?49) ?\( ?0 ?\)/, '$1').replace(/[()]/g, '')
const ziffern = s.replace(/\D/g, '')
let nummer
if (s.startsWith('+')) {
nummer = ziffern
} else if (ziffern.startsWith('00')) {
nummer = ziffern.slice(2)
} else if (ziffern.startsWith('0')) {
nummer = `49${ziffern.slice(1)}`
} else {
nummer = ziffern
}
if (nummer.startsWith('0')) return null
if (nummer.startsWith('490')) nummer = `49${nummer.slice(3)}` // '+49 0171 …' (haeufiger Tippfehler)
return nummer.length >= 6 && nummer.length <= 15 ? nummer : null
}
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. Diese Adressen landen als href
* auf oeffentlichen Seiten: Anfuehrungszeichen, Apostroph und Backtick im Host werden abgelehnt, ein Apostroph im Pfad
* wird als %27 gespeichert (URL kodiert ihn dort nicht selbst). Wie urlFeld in termine/einstellungen.js (dort nicht exportiert).
*/
function linkZiel(fehl, pfad, wert) {
const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : ''
let url = null
if (s !== '' && s.length <= ZEITEN_MAX.link && !/\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 > ZEITEN_MAX.link) {
fehl(pfad, URL_MELDUNG)
return ''
}
return href
}
/** Rufnummer eines Anruf-Knopfs: nur Ziffern, Leerzeichen, / und -, ein '+' nur vorn, mindestens eine Ziffer, <= 30 Zeichen; so gespeichert wie getippt. */
function anrufZiel(fehl, pfad, wert) {
const s = typeof wert === 'string' && wert.length <= ROH_TEXT ? text(wert, ROH_TEXT) : ''
if (s === '' || [...s].length > ZEITEN_MAX.telefon || !/^\+?[0-9 /-]+$/.test(s) || !/\d/.test(s)) {
fehl(pfad, 'Bitte gib eine Telefonnummer an (nur Ziffern, Leerzeichen, + / und -, höchstens 30 Zeichen).')
return ''
}
return s
}
/** Telefonfeld des Kontakts: leer = ''; sonst Ziffern mit Laendervorwahl. */
function telefonFeld(fehl, pfad, wert, { pflicht = false } = {}) {
if (fehltWert(wert) && !pflicht) return ''
const s = feldText(fehl, pfad, wert, ZEITEN_MAX.telefon, { pflicht, leer: 'Bitte gib die Nummer an.' })
if (s === '') return ''
const ziffern = telefonZiffern(s)
if (ziffern === null) {
fehl(pfad, 'Bitte gib eine gültige Telefonnummer an (nur Ziffern, Leerzeichen, + ( ) / . und -).')
return ''
}
return ziffern
}
// ---------------------------------------------------------------- Zeitfenster
/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), 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
}
/**
* Fenster eines Tages: { fehler } oder { fenster (sortiert, 'HH:MM'), ersteVon (Minuten|null), nachtBis (Minuten|null) }.
* Regeln: <= 2 Fenster, 5er-Raster, bis == von ungueltig, ueberlappen verboten (Beruehren ist erlaubt), bis < von
* (ueber Mitternacht) nur beim letzten Fenster. nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht
* (der Aufrufer prueft ggf. gegen den Folgetag).
*/
function pruefeFenster(wert) {
if (!Array.isArray(wert)) return { fehler: 'Ungültige Zeitfenster.' }
if (wert.length > ZEITEN_LIMITS.fensterJeTag) return { fehler: `Pro Tag sind höchstens ${ZEITEN_LIMITS.fensterJeTag} Zeitfenster möglich.` }
const f = []
for (const eintrag of wert) {
const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null
const bis = von === null ? null : minuten(eintrag[1], true)
if (von === null || bis === null) return { fehler: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.' }
if (bis === von) return { fehler: 'Beginn und Ende dürfen nicht gleich sein.' }
f.push({ von, bis })
}
f.sort((a, b) => a.von - b.von)
for (let i = 0; i < f.length; i += 1) {
if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.' }
if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: 'Die Zeitfenster überschneiden sich.' }
}
const letztes = f[f.length - 1]
return {
fenster: f.map((w) => [hhmm(w.von), hhmm(w.bis)]),
ersteVon: f.length ? f[0].von : null,
nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null,
}
}
/**
* Modus + Fenster eines Tages/Eintrags: { modus, fenster, ersteVon, nachtBis } oder null (Fehler gemeldet).
* Ein gueltiger Modus ist Pflicht; 'zeiten' braucht mindestens ein Fenster; bei jedem anderen Modus wird fenster [].
*/
function modusUndFenster(fehl, modusPfad, fensterPfad, modus, fensterRoh) {
if (!MODI.includes(modus)) {
fehl(modusPfad, 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.')
return null
}
if (modus !== 'zeiten') return { modus, fenster: [], ersteVon: null, nachtBis: null }
const r = pruefeFenster(fensterRoh)
if (r.fehler) {
fehl(fensterPfad, r.fehler)
return null
}
if (r.fenster.length === 0) {
fehl(fensterPfad, 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.')
return null
}
return { modus, fenster: r.fenster, ersteVon: r.ersteVon, nachtBis: r.nachtBis }
}
// ---------------------------------------------------------------- Abschnitte
function normalisierePlaene(fehl, roh, alt) {
const keine = { liste: [], ids: null }
if (!Array.isArray(roh) || roh.length === 0) {
fehl('plaene', 'Bitte lege mindestens einen Zeitplan an.')
return keine
}
if (roh.length > ZEITEN_LIMITS.plaene) {
fehl('plaene', `Es sind höchstens ${ZEITEN_LIMITS.plaene} Zeitpläne möglich.`)
return keine
}
if (roh.filter((p) => istObjekt(p) && p.haupt === true).length !== 1) fehl('plaene', 'Genau ein Zeitplan muss der Hauptplan sein.')
// Kurznamen sind unveraenderlich (Platzhalter auf Kundenseiten nennen sie): bekannte ID = bisheriger Kurzname.
// Auch die Kurznamen frueherer, inzwischen geloeschter Plaene bleiben belegt, damit ein neuer Plan nie unter einem
// alten Platzhalter erscheint (kein stilles Umschalten).
const bisherKurz = new Map()
const belegt = new Set()
for (const p of Array.isArray(alt?.plaene) ? alt.plaene : []) {
if (istObjekt(p) && typeof p.id === 'string' && typeof p.kurz === 'string' && KURZ_RE.test(p.kurz)) {
bisherKurz.set(p.id, p.kurz)
belegt.add(p.kurz)
}
}
const idListe = ausweisen(roh, 'p')
const liste = []
roh.forEach((p, i) => {
const id = idListe[i]
if (!istObjekt(p)) return fehl(`plaene.${i}`, 'Ungültiger Eintrag.')
const pfad = `plaene.${id}`
const name = feldText(fehl, `${pfad}.name`, p.name, ZEITEN_MAX.name, { pflicht: true, leer: 'Bitte gib dem Zeitplan einen Namen.' })
let kurz = bisherKurz.get(id)
if (kurz === undefined) {
kurz = kurzname(name, belegt)
belegt.add(kurz)
}
liste.push({ id, kurz, name, haupt: p.haupt === true, tage: normalisiereWoche(fehl, pfad, p.tage) })
})
return { liste, ids: new Set(liste.map((p) => p.id)) }
}
/** Wochenplan eines Plans: alle Tage 1..7, Fehler je Tag; Fenster ueber Mitternacht werden gegen das erste Fenster des Folgetags geprueft (7 -> 1). */
function normalisiereWoche(fehl, pfad, roh) {
const quelle = fehltWert(roh) ? {} : roh
if (!istObjekt(quelle)) {
fehl(`${pfad}.tage`, 'Ungültiger Wochenplan.')
return {}
}
const tage = {}
const geprueft = {}
for (let d = 1; d <= 7; d += 1) {
const tagPfad = `${pfad}.tage.${d}`
const eintrag = quelle[d]
if (fehltWert(eintrag)) {
tage[d] = { modus: 'zu', fenster: [] }
geprueft[d] = null
} else if (!istObjekt(eintrag)) {
fehl(tagPfad, 'Ungültiger Eintrag.')
tage[d] = { modus: 'zu', fenster: [] }
geprueft[d] = null
} else {
const r = modusUndFenster(fehl, tagPfad, tagPfad, eintrag.modus, eintrag.fenster)
tage[d] = r ? { modus: r.modus, fenster: r.fenster } : { modus: 'zu', fenster: [] }
geprueft[d] = r
}
}
for (let d = 1; d <= 7; d += 1) {
const heutiger = geprueft[d]
const folgetag = geprueft[(d % 7) + 1]
if (heutiger && heutiger.nachtBis !== null && folgetag && folgetag.ersteVon !== null && heutiger.nachtBis > folgetag.ersteVon) {
fehl(`${pfad}.tage.${d}`, 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.')
}
}
return tage
}
function normalisiereFeiertage(fehl, roh, grenze) {
if (fehltWert(roh)) return {}
if (!istObjekt(roh)) {
fehl('feiertage', 'Ungültige Feiertage.')
return {}
}
const aus = {}
for (const tag of Object.keys(roh).sort()) {
const pfad = `feiertage.${tag.slice(0, 20)}`
if (!istTag(tag)) {
fehl(pfad, 'Bitte gib ein gültiges Datum an.')
continue
}
const e = roh[tag]
if (!istObjekt(e) || !MODI.includes(e.modus)) continue // nur Platzhalter ohne Abweichung: faellt weg
const r = modusUndFenster(fehl, pfad, pfad, e.modus, e.fenster)
if (r && tag >= grenze) aus[tag] = { modus: r.modus, fenster: r.fenster }
}
return aus
}
/** Liste aus der Eingabe: fehlend = leer; Nicht-Liste oder uebergrosse Rohliste = Fehler am Listenpfad. */
function rohListe(fehl, pfad, wert, max, meldung) {
if (fehltWert(wert)) return []
if (!Array.isArray(wert)) {
fehl(pfad, 'Ungültige Liste.')
return []
}
if (wert.length > ROH_LISTE) {
fehl(pfad, meldung)
return []
}
return wert
}
function normalisiereAusnahmen(fehl, roh, planIds, grenze) {
const meldung = `Es sind höchstens ${ZEITEN_LIMITS.ausnahmen} Ausnahmen möglich.`
const eingang = rohListe(fehl, 'ausnahmen', roh, ZEITEN_LIMITS.ausnahmen, meldung)
const idListe = ausweisen(eingang, 'x')
const aus = []
eingang.forEach((a, i) => {
if (!istObjekt(a)) return fehl(`ausnahmen.${i}`, 'Ungültiger Eintrag.')
const id = idListe[i]
const pfad = `ausnahmen.${id}`
const art = AUSNAHME_ARTEN.includes(a.art) ? a.art : null
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Urlaub“ oder „Sondertag“.')
const vonOk = istTag(a.von)
if (!vonOk) fehl(`${pfad}.von`, 'Bitte gib ein gültiges Datum an.')
// bis fehlt = ein Tag (bis = von)
const bisFehlt = fehltWert(a.bis) || a.bis === ''
const bisOk = bisFehlt ? vonOk : istTag(a.bis)
if (!bisFehlt && !bisOk) fehl(`${pfad}.bis`, 'Bitte gib ein gültiges Datum an.')
const von = vonOk ? a.von : ''
const bis = bisOk ? (bisFehlt ? a.von : a.bis) : ''
if (vonOk && bisOk) {
if (von > bis) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
else if (art === 'urlaub' && tageZwischen(von, bis) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Betriebsurlaub kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage dauern.`)
}
const eintragText = feldText(fehl, `${pfad}.text`, a.text, ZEITEN_MAX.ausnahmeText)
let tag = { modus: 'zu', fenster: [] }
if (art === 'sondertag') {
const r = modusUndFenster(fehl, `${pfad}.modus`, `${pfad}.fenster`, a.modus, a.fenster)
if (r) tag = { modus: r.modus, fenster: r.fenster }
} // Urlaub ist immer geschlossen
let plan = null
if (!fehltWert(a.plan) && a.plan !== '') {
if (typeof a.plan === 'string' && (planIds === null || planIds.has(a.plan))) plan = a.plan
else fehl(`${pfad}.plan`, 'Dieser Zeitplan existiert nicht (mehr).')
}
if (bisOk && bis < grenze) return // vergangen (> 30 Tage): wird nicht mehr gespeichert
aus.push({ id, art, von, bis, text: eintragText, modus: tag.modus, fenster: tag.fenster, plan })
})
if (aus.length > ZEITEN_LIMITS.ausnahmen) {
fehl('ausnahmen', meldung)
return []
}
return aus
}
function normalisiereKnopf(fehl, pfad, roh) {
if (fehltWert(roh)) return null
if (!istObjekt(roh)) {
fehl(pfad, 'Ungültiger Knopf.')
return null
}
const leer = (w) => fehltWert(w) || (typeof w === 'string' && text(w, ROH_TEXT) === '')
if (leer(roh.ziel) && leer(roh.text)) return null // ein leerer Knopf ist kein Knopf
const art = KNOPF_ARTEN.includes(roh.art) ? roh.art : null
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle „Link“ oder „Anrufen“.')
const beschriftung = feldText(fehl, `${pfad}.text`, roh.text, ZEITEN_MAX.knopfText, { pflicht: true, leer: 'Bitte gib dem Knopf eine Beschriftung.' })
let ziel = ''
if (art === 'link') ziel = linkZiel(fehl, `${pfad}.ziel`, roh.ziel)
else if (art === 'anruf') ziel = anrufZiel(fehl, `${pfad}.ziel`, roh.ziel)
return { art, ziel, text: beschriftung }
}
function normalisiereHinweise(fehl, roh, grenze) {
const meldung = `Es sind höchstens ${ZEITEN_LIMITS.hinweise} Hinweise möglich.`
const eingang = rohListe(fehl, 'hinweise', roh, ZEITEN_LIMITS.hinweise, meldung)
const idListe = ausweisen(eingang, 'h')
const aus = []
eingang.forEach((h, i) => {
if (!istObjekt(h)) return fehl(`hinweise.${i}`, 'Ungültiger Eintrag.')
const id = idListe[i]
const pfad = `hinweise.${id}`
const art = HINWEIS_ARTEN.includes(h.art) ? h.art : null
if (art === null) fehl(`${pfad}.art`, 'Bitte wähle Info, Urlaub, Aktion oder Wichtig.')
const hinweisText = feldText(fehl, `${pfad}.text`, h.text, ZEITEN_MAX.hinweisText, { pflicht: true, leer: 'Bitte gib einen Text ein.' })
const knopf = normalisiereKnopf(fehl, `${pfad}.knopf`, h.knopf)
const von = tagOptional(fehl, `${pfad}.von`, h.von)
const bisRoh = tagOptional(fehl, `${pfad}.bis`, h.bis)
const bisKaputt = !fehltWert(h.bis) && h.bis !== '' && bisRoh === null
if (von !== null && bisRoh !== null) {
if (von > bisRoh) fehl(`${pfad}.bis`, 'Das Ende darf nicht vor dem Beginn liegen.')
else if (art === 'urlaub' && tageZwischen(von, bisRoh) + 1 > ZEITEN_LIMITS.urlaubTage) fehl(`${pfad}.bis`, `Ein Urlaubshinweis kann höchstens ${ZEITEN_LIMITS.urlaubTage} Tage gelten.`)
}
if (art === 'aktion' && bisRoh === null && !bisKaputt) fehl(`${pfad}.bis`, 'Eine Aktion braucht ein Enddatum.')
if (bisRoh !== null && bisRoh < grenze) return // vergangen (> 30 Tage)
aus.push({ id, art, text: hinweisText, knopf, von, bis: bisRoh })
})
if (aus.length > ZEITEN_LIMITS.hinweise) {
fehl('hinweise', meldung)
return []
}
return aus
}
function normalisiereKontakt(fehl, roh) {
const vorgabe = { an: false, telefon: '', adresse: '', whatsapp: null }
if (fehltWert(roh)) return vorgabe
if (!istObjekt(roh)) {
fehl('kontakt', 'Ungültige Kontaktangaben.')
return vorgabe
}
return {
an: wahrheit(fehl, 'kontakt.an', roh.an, false),
telefon: telefonFeld(fehl, 'kontakt.telefon', roh.telefon),
adresse: feldText(fehl, 'kontakt.adresse', roh.adresse, ZEITEN_MAX.adresse),
whatsapp: normalisiereWhatsapp(fehl, roh.whatsapp),
}
}
/**
* WhatsApp: Nummer (Ziffern mit Vorwahl) und der Zeitpunkt der Datenschutz-Bestaetigung sind Pflicht. bestaetigtAt wird hier
* nur geprueft und uebernommen (ISO-Zeitpunkt mit Zeitzone, gespeichert als toISOString, beim erneuten Speichern unveraendert);
* ob die Kundenansicht ihn neu setzen oder aendern darf, entscheidet die Route (Vergleich mit bisher.kontakt.whatsapp.bestaetigtAt).
*/
function normalisiereWhatsapp(fehl, roh) {
if (fehltWert(roh)) return null
if (!istObjekt(roh)) {
fehl('kontakt.whatsapp', 'Ungültige WhatsApp-Angaben.')
return null
}
const nummer = telefonFeld(fehl, 'kontakt.whatsapp.nummer', roh.nummer, { pflicht: true })
const starttext = feldText(fehl, 'kontakt.whatsapp.starttext', roh.starttext, ZEITEN_MAX.starttext)
const bestaetigtAt = zeitpunkt(roh.bestaetigtAt)
if (bestaetigtAt === null) fehl('kontakt.whatsapp.bestaetigtAt', 'Bitte bestätige, dass du den WhatsApp-Hinweis in deine Datenschutzerklärung aufgenommen hast.')
return { nummer, starttext, bestaetigtAt }
}
// ---------------------------------------------------------------- Oeffentliche Funktionen
/**
* Prueft und normalisiert die Zeiten-Daten (Eingabe vom Client). bisher = zuletzt gespeicherte Daten (nur fuer die
* unveraenderlichen Kurznamen der Plaene); now = Pflicht (Vergangenes entfernen: Berliner Kalendertag).
* => { daten } oder { fehler: { [pfad]: text } }. Nach dem Normalisieren darf JSON.stringify(daten) hoechstens
* 32768 Zeichen lang sein (sonst fehler.daten).
*/
export function normalisiereZeiten(eingabe, { bisher = null, now } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('normalisiereZeiten: now fehlt')
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
if (!istObjekt(eingabe)) return { fehler: { daten: 'Die Zeiten sind ungültig.' } }
const alt = istObjekt(bisher) ? bisher : null
const grenze = plusTage(heute(now), -ZEITEN_LIMITS.vergangenTage)
let land = STANDARD_LAND
if (!fehltWert(eingabe.land)) {
if (typeof eingabe.land === 'string' && BUNDESLAENDER.includes(eingabe.land)) land = eingabe.land
else fehl('land', 'Bitte wähle ein Bundesland.')
}
const { liste: plaene, ids } = normalisierePlaene(fehl, eingabe.plaene, alt)
const daten = {
land,
plaene,
feiertage: normalisiereFeiertage(fehl, eingabe.feiertage, grenze),
ausnahmen: normalisiereAusnahmen(fehl, eingabe.ausnahmen, ids, grenze),
hinweise: normalisiereHinweise(fehl, eingabe.hinweise, grenze),
kontakt: normalisiereKontakt(fehl, eingabe.kontakt),
}
if (Object.keys(fehler).length === 0 && JSON.stringify(daten).length > ZEITEN_LIMITS.daten) fehl('daten', DATEN_ZU_GROSS)
return Object.keys(fehler).length ? { fehler } : { daten }
}
/**
* Prueft und normalisiert die Team-Einstellungen (nur ueber WOMS): { navSelektor: null|string, kontaktleisteAus, hinweisleisteAus }.
* navSelektor: <= 120 Zeichen aus [A-Za-z0-9 #.\-_>:\[\]="'] (also nie < { } ;); leer = null.
* => { team } oder { fehler: { [pfad]: text } }.
*/
export function normalisiereTeam(eingabe) {
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
if (!istObjekt(eingabe)) return { fehler: { team: 'Die Team-Einstellungen sind ungültig.' } }
let navSelektor = null
if (!fehltWert(eingabe.navSelektor)) {
const s = typeof eingabe.navSelektor === 'string' ? eingabe.navSelektor.trim() : null
if (s === null || s.length > ZEITEN_MAX.navSelektor || (s !== '' && !NAV_RE.test(s))) {
fehl('navSelektor', `Der Selektor darf höchstens ${ZEITEN_MAX.navSelektor} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.`)
} else if (s !== '') {
navSelektor = s
}
}
const team = {
navSelektor,
kontaktleisteAus: wahrheit(fehl, 'kontaktleisteAus', eingabe.kontaktleisteAus, false),
hinweisleisteAus: wahrheit(fehl, 'hinweisleisteAus', eingabe.hinweisleisteAus, false),
}
return Object.keys(fehler).length ? { fehler } : { team }
}