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..name', 'plaene..tage.3', 'ausnahmen..von', * 'hinweise..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 } }