Files
Webklar-Kundenbereich/server/services/zeiten/pruefen.js
T
ClaudeandClaude Opus 5.5 88fb9263f9 Ö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>
2026-10-05 00:35:10 +00:00

602 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 }
}