Ö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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:01:24 +00:00
1 parent 097036c9a5
commit 7bf2ad2665
7 files changed
+801 -4

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js'
import zeitenRoutes from './routes/zeiten.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes)
app.use('/api/zeiten', zeitenRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
+3
View File
@@ -24,6 +24,9 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
// Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403).
// Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt.
['PUT', /^\/api\/zeiten\/[^/]+\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
+221
View File
@@ -0,0 +1,221 @@
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
+1
View File
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
'zeiten_gespeichert',
]
const DETAIL_MAX = 300