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>
602 lines
27 KiB
JavaScript
602 lines
27 KiB
JavaScript
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 }
|
||
}
|