import { Router } from 'express' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' import { feiertageImZeitraum } from '../services/termine/feiertage.js' import { heute, plusTage } from '../services/termine/zeit.js' import { createWriteLimiter } from '../services/writeLimiter.js' import { ladeZeiten, speichereZeiten } from '../services/zeiten/speicher.js' import { googleText, hauptplan, landVon, statusZu, zuUtcOderDanach } from '../services/zeiten/zeitlogik.js' /** * Kunden-Schnittstelle der Oeffnungszeiten: /api/zeiten (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 3.4). * * GET /:projectId Zeiten laden: { state, daten|null, version, kundenansicht, vorschau|null, feiertage, googleText } * PUT /:projectId { version, daten } speichern, Antwort wie GET * GET /:projectId/vorschau ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr, ohne zeit: jetzt): { offen, text, kurz } der gespeicherten Daten * * Zugang: Session, eigenes Projekt (403), Funktion im Zustand ordered, setup oder active (lesen und speichern). disabled mit * gespeicherten Zeiten: nur lesen (PUT 403); disabled ohne Zeiten, off und kein Funktionsdokument: 403. Der Schalter * ZEITEN_ENABLED entscheidet nur ueber das Einschalten (featureRegistry), nicht ueber diese Schnittstelle: wer die Funktion hat, bearbeitet sie. * Version: 0 vor dem ersten Speichern; veraltet => 409 ZEITEN_VERALTET (die Sperre `zeiten:` nimmt der Speicher selbst). * Kundenansicht (Mitarbeiter): speichern ist erlaubt (Erlaubnisliste des Waechters) und wird als `zeiten_gespeichert` protokolliert; * den WhatsApp-Haken (kontakt.whatsapp.bestaetigtAt) setzen nur Kunden selbst (R12, siehe bestaetigtAtPruefen). * Schreiben zaehlt gegen 60 Speichervorgaenge je Kunde und Stunde (im Speicher, 429); Lesen nicht. * * Fehler: { error: , fehler?: { : Text }, code? }. Texte von Appwrite oder unbekannten Fehlern verlassen den Server * nie (500 "Interner Serverfehler"). Antworten sind nie zwischenspeicherbar. */ const router = Router() const MAX_SPEICHERN_JE_STUNDE = 60 const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ // Form einer Appwrite-ID: andere Texte gehen nie als Dokument-ID an Appwrite const FEIERTAGE_TAGE = 366 // 12 Monate ab heute: derselbe Kalendertag im naechsten Jahr ist immer dabei const MELDUNG = { fremd: 'Kein Zugriff auf dieses Projekt.', nichtEingeschaltet: 'Die Öffnungszeiten sind für diese Website nicht eingeschaltet.', abgeschaltet: 'Die Öffnungszeiten sind für diese Website abgeschaltet. Du kannst sie nur noch ansehen.', veraltet: 'Inzwischen geändert – bitte neu laden.', whatsappKundenansicht: 'In der Kundenansicht kann der WhatsApp-Hinweis nicht bestätigt werden.', zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', zeit: 'Bitte gib Tag und Uhrzeit an (JJJJ-MM-TTTHH:MM).', keineZeiten: 'Bitte speichere zuerst deine Öffnungszeiten.', } // Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). let uhr = () => new Date() export function setzeUhrFuerTests(neueUhr) { uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() } // 60 Speichervorgaenge je Kunde und Stunde (im Speicher); die Uhr folgt setzeUhrFuerTests const limiter = createWriteLimiter({ max: MAX_SPEICHERN_JE_STUNDE, now: () => uhr().getTime() }) const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) export function resetZeitenRoutesForTests() { limiter.reset() uhr = () => new Date() } // ---------------------------------------------------------------- Fehler const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /** Fehler des Kunden (4xx) mit eigenem Text; mit feld zusaetzlich als Feldfehler. `fail` macht daraus die Antwort. */ const stop = (status, text, code, feld) => Object.assign(new Error(text), { status, kunde: true, ...(code ? { code } : {}), ...(feld ? { fehler: { [feld]: text } } : {}) }) /** * Antwort fuer einen Fehler: eigene Fehler (stop), Pruefergebnisse des Speichers (400 mit `fehler`) und ZEITEN_VERALTET (409) * wie sie sind; alles andere (auch Appwrite-Fehler mit 4xx) 500 ohne Rohtext. */ function fail(res, err) { const status = Number.isInteger(err?.status) ? err.status : 500 const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {} const durchgelassen = err?.kunde === true || Object.keys(texte).length > 0 || err?.code === 'ZEITEN_VERALTET' if (!durchgelassen || status < 400 || status >= 500) { console.error(`[zeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) return res.status(500).json({ error: 'Interner Serverfehler' }) } const body = { error: String(err.message) } if (Object.keys(texte).length > 0) body.fehler = texte if (typeof err.code === 'string') body.code = err.code return res.status(status).json(body) } /** Handler mit Fehlerbehandlung (Express 4 fängt async-Fehler nicht selbst). */ const route = (fn) => async (req, res, next) => { try { await fn(req, res, next) } catch (err) { fail(res, err) } } router.use((_req, res, next) => { res.set('Cache-Control', 'no-store') next() }) // ---------------------------------------------------------------- Zugang /** * Eigenes Projekt (403), Funktion im Zustand ordered, setup, active oder disabled mit gespeicherten Zeiten (sonst 403) und die * gespeicherten Zeiten => req.ctx = { projectId, project, feature, stored } (stored: ladeZeiten, null ohne Dokument). * schreiben: disabled ist nicht beschreibbar (403). */ const withZeiten = ({ schreiben = false } = {}) => route(async (req, _res, next) => { const projectId = req.params.projectId const customerId = getSessionCustomerId(req) if (!ID_RE.test(projectId)) throw stop(403, MELDUNG.fremd) let project try { project = await assertOwnProject(customerId, projectId) } catch (err) { // auch eine unbrauchbare ID (Appwrite 400) ist kein eigenes Projekt throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err } const feature = await getFeatureDoc(customerId, projectId, 'zeiten') if (!feature || !['ordered', 'setup', 'active', 'disabled'].includes(feature.state)) throw stop(403, MELDUNG.nichtEingeschaltet) if (feature.state === 'disabled' && schreiben) throw stop(403, MELDUNG.abgeschaltet) const stored = await ladeZeiten(feature.$id) if (feature.state === 'disabled' && !stored?.daten) throw stop(403, MELDUNG.nichtEingeschaltet) req.ctx = { projectId, project, feature, stored } return next() }) // ---------------------------------------------------------------- Antwort /** Alles, was der Editor nach dem Laden und nach dem Speichern braucht (daten null und version 0 vor dem ersten Speichern). */ function ansicht(req, { feature, daten, version }, now) { const plan = hauptplan(daten) const von = heute(now) return { state: feature.state, daten: daten ?? null, version, kundenansicht: isKundenansicht(req.session), vorschau: daten && plan ? statusZu(daten, plan, now) : null, feiertage: feiertageImZeitraum(von, plusTage(von, FEIERTAGE_TAGE), landVon(daten)), googleText: daten ? googleText(daten, plan, now) : '', } } router.get('/:projectId', requireSession, withZeiten(), route(async (req, res) => { const { feature, stored } = req.ctx res.json(ansicht(req, { feature, daten: stored?.daten ?? null, version: stored?.version ?? 0 }, uhr())) })) // ---------------------------------------------------------------- Vorschau const ZEIT_RE = /^(20\d{2}-\d{2}-\d{2})T([01]\d|2[0-3]):([0-5]\d)$/ /** ?zeit=JJJJ-MM-TTTHH:MM (Berliner Wanduhr) => Zeitpunkt; ohne zeit: jetzt; sonst 400. */ function vorschauZeit(req) { const roh = req.query.zeit if (roh === undefined) return uhr() const m = typeof roh === 'string' ? ZEIT_RE.exec(roh) : null if (!m) throw stop(400, MELDUNG.zeit, undefined, 'zeit') try { return zuUtcOderDanach(m[1], `${m[2]}:${m[3]}`) } catch { throw stop(400, MELDUNG.zeit, undefined, 'zeit') // kein echter Kalendertag (30.02.) } } router.get('/:projectId/vorschau', requireSession, withZeiten(), route(async (req, res) => { const { stored } = req.ctx const plan = hauptplan(stored?.daten) if (!plan) throw stop(409, MELDUNG.keineZeiten, 'keine_zeiten') res.json(statusZu(stored.daten, plan, vorschauZeit(req))) })) // ---------------------------------------------------------------- Speichern /** Zwei Zeitpunkt-Texte, die denselben Moment meinen (auch in anderer ISO-Schreibweise). */ function gleicherZeitpunkt(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false if (a === b) return true const x = Date.parse(a) return Number.isFinite(x) && x === Date.parse(b) } /** Hat der Browser einen Zeitpunkt der Bestaetigung geschickt? (fehlt er, ist der Haken nicht gesetzt: das meldet die Pruefung als Eingabefehler) */ const behauptetBestaetigung = (wert) => wert !== undefined && wert !== null && wert !== '' && wert !== false /** * R12 (Spezifikation 3.4, Kontakt-Leiste): den Zeitpunkt der WhatsApp-Bestaetigung (kontakt.whatsapp.bestaetigtAt) glaubt der Server * dem Browser nie. Ist er gegenueber dem gespeicherten Stand (bisher) neu oder anders: in der Kundenansicht 403 (Mitarbeiter * bestaetigen nichts im Namen des Kunden), beim Kunden selbst setzt der Server ihn auf jetzt. Unveraendert bleibt er erhalten; * fehlt er ganz, bleibt die Eingabe wie sie ist (die Pruefung meldet "Bitte bestaetige ..."). Veraendert die Eingabe nicht, sondern * liefert eine Kopie. */ function bestaetigtAtPruefen(req, eingabe, bisher, jetzt) { const whatsapp = eingabe?.kontakt?.whatsapp if (!istObjekt(whatsapp) || !behauptetBestaetigung(whatsapp.bestaetigtAt)) return eingabe const gespeichert = bisher?.kontakt?.whatsapp?.bestaetigtAt const bestaetigtAt = gleicherZeitpunkt(whatsapp.bestaetigtAt, gespeichert) ? gespeichert : null if (bestaetigtAt === null && isKundenansicht(req.session)) throw stop(403, MELDUNG.whatsappKundenansicht) return { ...eingabe, kontakt: { ...eingabe.kontakt, whatsapp: { ...whatsapp, bestaetigtAt: bestaetigtAt ?? jetzt.toISOString() } } } } /** Protokolltext der Kundenansicht: Projekt und neue Version (keine Inhalte). */ const protokollText = (req, version) => `${String(req.ctx.project?.projectName || req.ctx.projectId).slice(0, 120)}, Version ${version}` router.put('/:projectId', requireSession, withZeiten({ schreiben: true }), limitWrites, route(async (req, res) => { const { feature, stored } = req.ctx const jetzt = uhr() const { version, daten } = istObjekt(req.body) ? req.body : {} // Veraltet gewinnt vor jeder anderen Pruefung (auch vor dem WhatsApp-Haken): der Editor soll erst neu laden if (version !== (stored?.version ?? 0)) throw stop(409, MELDUNG.veraltet, 'ZEITEN_VERALTET') const eingabe = bestaetigtAtPruefen(req, daten, stored?.daten, jetzt) const by = isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : 'kunde' const gespeichert = await speichereZeiten({ feature, eingabe, version, by, now: jetzt }) await logIfKundenansicht(req, 'zeiten_gespeichert', protokollText(req, gespeichert.version)) res.json(ansicht(req, { feature, daten: gespeichert.daten, version: gespeichert.version }, jetzt)) })) export default router