Ö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:
1 parent
097036c9a5
commit
7bf2ad2665
7 files changed
+801
-4
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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\/?$/],
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
|
||||
'termin_einstellungen',
|
||||
'termin_aktion',
|
||||
'termin_manuell',
|
||||
'zeiten_gespeichert',
|
||||
]
|
||||
|
||||
const DETAIL_MAX = 300
|
||||
|
||||
Reference in new issue
Block a user