import { heute } from '../termine/zeit.js' import { customerOf, findActiveBySiteKey, projectName } from '../siteFeatures.js' import { zeitenCache } from './cache.js' import { zeitenAus } from './schalter.js' import { ladeZeiten } from './speicher.js' import { gueltigeHinweise, hauptplan, jsonLdFuer, modusUndFenster, tageListe, wechselListe } from './zeitlogik.js' /** * Oeffnungszeiten live: die oeffentliche Antwort fuer das Website-Skript (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, * Abschnitt 4.1). Die Route (Task 12) reicht den siteKey der URL durch, setzt CORS, ETag und Limits und macht aus einem Fehler * dieses Moduls ein 503; hier steht nur, was in der Antwort steht und wann sie gebaut wird. * * Die Antwort enthaelt nie interne IDs (kein $id, customerId, projectId, featureId, keine Plan-ID, nur den Kurznamen) und keine * Kundendaten ausser dem Firmennamen und den veroeffentlichten Kontaktangaben. */ const SITE_KEY_RE = /^[a-z2-7]{16}$/ const TAGE_VORSCHAU = 60 // Spec 4.1: tage je Plan (heute bis heute + 59) const ROUTE_BASIS = 'https://www.google.com/maps/dir/?api=1&destination=' const WHATSAPP_BASIS = 'https://wa.me/' const TELEFON_RE = /^[1-9][0-9]{5,14}$/ // Ziffern mit Laendervorwahl, 6 bis 15 (E.164): so speichert pruefen.js const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) const nichtLeererText = (wert) => (typeof wert === 'string' && wert.trim() !== '' ? wert.trim() : null) function pruefeJetzt(now) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum') return now.getTime() } /** Beliebiger Text fuer eine URL; ein einzelnes Surrogat aus von Hand veraenderten Daten wuerde encodeURIComponent sonst werfen. */ const kodiere = (wert) => encodeURIComponent(String(wert).toWellFormed()) /** Nur Ziffern (nie ein Text, der in tel: oder wa.me landen darf, ohne dass er so gespeichert wurde), sonst null. */ const ziffern = (wert) => (typeof wert === 'string' && TELEFON_RE.test(wert) ? wert : null) /** * Kontakt-Leiste: null, wenn sie aus ist oder es nichts anzuzeigen gibt; sonst { telefon, routeUrl, whatsappUrl } mit null fuer * Fehlendes. WhatsApp nur mit bestaetigtAt (der Kunde hat den Datenschutz-Haken gesetzt); der fremde Dienst oeffnet erst beim Klick. */ function kontaktAntwort(kontakt) { if (!istObjekt(kontakt) || kontakt.an !== true) return null const telefon = ziffern(kontakt.telefon) const adresse = nichtLeererText(kontakt.adresse) const wa = istObjekt(kontakt.whatsapp) ? kontakt.whatsapp : null const nummer = wa && nichtLeererText(wa.bestaetigtAt) ? ziffern(wa.nummer) : null const routeUrl = adresse ? ROUTE_BASIS + kodiere(adresse) : null const whatsappUrl = nummer ? `${WHATSAPP_BASIS}${nummer}?text=${kodiere(typeof wa.starttext === 'string' ? wa.starttext : '')}` : null if (!telefon && !routeUrl && !whatsappUrl) return null return { telefon, routeUrl, whatsappUrl } } /** Team-Einstellungen mit den Vorgaben (null, false, false); nur diese drei Schluessel verlassen den Server. */ function teamAntwort(team) { const t = istObjekt(team) ? team : {} return { navSelektor: nichtLeererText(t.navSelektor), kontaktleisteAus: t.kontaktleisteAus === true, hinweisleisteAus: t.hinweisleisteAus === true, } } /** Ein Plan der Antwort; interne Plan-ID bleibt draussen, der Kurzname ist der oeffentliche Schluessel. */ function planAntwort(daten, plan, now, heuteTag) { const woche = [] for (let tag = 1; tag <= 7; tag += 1) woche.push({ tag, ...modusUndFenster(plan.tage?.[tag]) }) return { kurz: plan.kurz, name: typeof plan.name === 'string' ? plan.name : '', haupt: plan.haupt === true, woche, tage: tageListe(daten, plan, heuteTag, TAGE_VORSCHAU), // tag = Berliner Kalendertag bei ab: das Skript rechnet keine Zeitzonen und braucht ihn fuer „heute“ und die naechsten 14 Tage wechsel: wechselListe(daten, plan, now).map(({ ab, tag, offen, text, kurz }) => ({ ab, tag, offen, text, kurz })), } } /** * Baut die Antwort (rein: kein Appwrite, keine Uhr ausser now) aus den gespeicherten Daten. * @param {{ daten: object|null, team: object, firma: { name: string }, now: Date }} p * daten null (oder unbrauchbar) = noch nichts gespeichert: plaene leer, kein JSON-LD (jsonld null, damit der Browser vorhandenes * JSON-LD nicht mit leeren Zeiten ueberschreibt). firma.name kommt vom Aufrufer, firma.adresse ist kontakt.adresse. * @returns {object} { aktiv: true, stand, plaene, hinweise, kontakt, jsonld, firma, team } */ export function baueOeffentlicheAntwort({ daten, team, firma, now } = {}) { pruefeJetzt(now) const d = istObjekt(daten) ? daten : null const heuteTag = heute(now) const haupt = d ? hauptplan(d) : null const plaene = Array.isArray(d?.plaene) ? d.plaene.filter((p) => istObjekt(p) && nichtLeererText(p.kurz)) : [] return { aktiv: true, stand: now.toISOString(), plaene: plaene.map((plan) => planAntwort(d, plan, now, heuteTag)), hinweise: d ? gueltigeHinweise(d, now) : [], kontakt: kontaktAntwort(d?.kontakt), jsonld: haupt ? jsonLdFuer(d, haupt, now) : null, firma: { name: nichtLeererText(firma?.name) ?? '', adresse: nichtLeererText(d?.kontakt?.adresse) }, team: teamAntwort(team), } } const AUS = Object.freeze({ aktiv: false }) /** Friert die Antwort samt allem Darunterliegenden ein: was im Speicher liegt, darf kein Aufrufer mehr veraendern. */ function friere(wert) { if (wert !== null && typeof wert === 'object' && !Object.isFrozen(wert)) { Object.freeze(wert) for (const teil of Object.values(wert)) friere(teil) } return wert } /** Firmenname des Kunden; fehlen Firmen- und Kundenname, der Projektname, sonst ''. */ async function firmenName(kunde, projectId) { const name = nichtLeererText(kunde.companyName) ?? nichtLeererText(kunde.name) if (name) return name return typeof projectId === 'string' && projectId !== '' ? (nichtLeererText(await projectName(projectId)) ?? '') : '' } async function ladeAntwort(siteKey, now) { // 'setup' antwortet schon: das Team prueft die Leisten, bevor das Ticket geschlossen und die Funktion 'active' wird (Spec 4.4) const feature = await findActiveBySiteKey(siteKey, 'zeiten', { states: ['setup', 'active'] }) if (!feature) return AUS const kunde = typeof feature.customerId === 'string' && feature.customerId !== '' ? await customerOf(feature.customerId) : null if (!kunde) return AUS const gespeichert = await ladeZeiten(feature.$id) return baueOeffentlicheAntwort({ daten: gespeichert?.daten ?? null, team: gespeichert?.team, firma: { name: await firmenName(kunde, feature.projectId) }, now, }) } /** * Die oeffentliche Antwort zu einem siteKey (nie null, nie ein Fehler fuer Unbekanntes): * Schalter aus oder siteKey nicht im Format [a-z2-7]{16} => { aktiv: false } ohne Appwrite-Abfrage und ohne Eintrag im Speicher; * sonst aus dem Speicher (30 s, auch fuer { aktiv: false } bei unbekanntem Schluessel, falschem Zustand oder geloeschtem Kunden); * sonst Funktion (setup/active) und Kunde laden, Zeiten laden, bauen, merken. * Appwrite-Fehler gehen unveraendert an den Aufrufer (die Route macht daraus 503) und werden nicht gemerkt. * Die Antwort ist eingefroren (sie wird geteilt). * @param {string} siteKey Schluessel aus der URL * @param {Date} [now] */ export async function oeffentlicheZeitenAntwort(siteKey, now = new Date()) { if (zeitenAus()) return AUS if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return AUS const jetztMs = pruefeJetzt(now) const gemerkt = zeitenCache.hole(siteKey, jetztMs) if (gemerkt !== undefined) return gemerkt const antwort = friere(await ladeAntwort(siteKey, now)) zeitenCache.setze(siteKey, antwort, jetztMs) return antwort }