Das Website-Skript rechnet keine Zeitzonen und braucht tag (Berliner Kalendertag
bei ab) für „heute“ und die nächsten 14 Tage; wechsel-Einträge sind wieder
{ ab, tag, offen, text, kurz }.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.7 KiB
JavaScript
161 lines
7.7 KiB
JavaScript
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
|
|
}
|