Files
Webklar-Kundenbereich/server/routes/zeiten.js
T
ClaudeandClaude Opus 5.5 7bf2ad2665 Öffnungszeiten: Kundenschnittstelle zum Bearbeiten
GET/PUT /api/zeiten/:projectId und GET .../vorschau für den Editor im Portal.
Zugang nach Funktionszustand (ordered/setup/active lesen und speichern,
disabled mit Daten nur lesen), Version 0 vor dem ersten Speichern, 409
ZEITEN_VERALTET, 60 Speichervorgänge je Kunde und Stunde. Die Kundenansicht darf
speichern (Protokoll zeiten_gespeichert), den WhatsApp-Haken setzt nur der
Kunde selbst: bestaetigtAt kommt vom Server (R12), in der Kundenansicht 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:01:24 +00:00

222 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<projectId>` 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: <Text>, fehler?: { <pfad>: 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