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>
222 lines
11 KiB
JavaScript
222 lines
11 KiB
JavaScript
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
|