Ö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

+33 -2
View File
@@ -27,6 +27,7 @@ const { createStaffPreviewToken } = await import('../server/services/staffPrevie
// Kunden-Router mit denselben Mount-Pfaden wie server/index.js
const termineRouter = (await import('../server/routes/termine.js')).default
const zeitenRouter = (await import('../server/routes/zeiten.js')).default
const MOUNTS = [
['/api/auth', (await import('../server/routes/auth.js')).default],
['/api/projects', (await import('../server/routes/projects.js')).default],
@@ -38,6 +39,7 @@ const MOUNTS = [
['/api/abo', (await import('../server/routes/abo.js')).default],
['/api/funktionen', (await import('../server/routes/funktionen.js')).default],
['/api/termine', termineRouter],
['/api/zeiten', zeitenRouter],
]
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
@@ -118,7 +120,7 @@ test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /a
}
})
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
test('Erlaubnisliste: genau News, Terminplaner, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
const ok = [
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'],
@@ -126,9 +128,10 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'],
['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'],
['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'],
['PUT', '/api/zeiten/abc'], ['PUT', '/api/zeiten/abc/'],
]
for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`)
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard')
assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6 + 1, 'genau sechs Termine-Eintraege und ein Zeiten-Eintrag dazu, keine Wildcard')
for (const [m, p] of [
['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'],
// Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen
@@ -140,6 +143,9 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L
['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'],
['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'],
['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'],
// Oeffnungszeiten: nur PUT auf das Projekt, sonst nichts
['PUT', '/api/zeiten'], ['PUT', '/api/zeiten/'], ['PUT', '/api/zeiten/abc/vorschau'], ['PUT', '/api/zeiten/abc/def/ghi'], ['PUT', '/api/zeitenX/abc'],
['POST', '/api/zeiten/abc'], ['PATCH', '/api/zeiten/abc'], ['DELETE', '/api/zeiten/abc'], ['POST', '/api/zeiten'],
]) {
assert.ok(!allowed(m, p), `${m} ${p}`)
}
@@ -196,6 +202,31 @@ test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf d
}
})
// Oeffnungszeiten: die Erlaubnisliste und die echten Schreib-Routen decken sich genau (eine Route, ein Eintrag); jedes andere
// Schreiben unter /api/zeiten (auch ohne Route, auch Gross-/Kleinschreibung) sperrt der Waechter, normale Kunden beruehrt er nicht.
test('Router-Abgleich: genau eine Schreib-Route der Öffnungszeiten (PUT /:projectId) steht auf der Erlaubnisliste', async () => {
const routen = writeRoutes([['/api/zeiten', zeitenRouter]])
assert.deepEqual(routen.map(({ method, path: p }) => `${method} ${p}`), ['PUT /api/zeiten/x'], 'der Router schreibt nur per PUT /:projectId')
const eintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('zeiten'))
assert.equal(eintraege.length, 1)
assert.ok(allowed('PUT', '/api/zeiten/x'))
for (const [method, p] of [['POST', '/api/zeiten/p1'], ['PATCH', '/api/zeiten/p1'], ['DELETE', '/api/zeiten/p1'], ['PUT', '/api/zeiten/p1/vorschau'], ['PUT', '/api/zeiten'], ['POST', '/API/Zeiten/p1']]) {
const res = await call(method, p, { ...kv(), body: {} })
assert.equal(res.status, 403, `${method} ${p}`)
assert.equal(res.body.code, 'view_blocked', `${method} ${p}`)
}
// ohne Kennung im Header gilt auch der erlaubte Pfad nicht (wie bei allen Schreib-Routen)
const ohneKennung = await call('PUT', '/api/zeiten/p1', { cookie: kundenansichtCookie(), body: {} })
assert.equal(ohneKennung.status, 409)
assert.equal(ohneKennung.body.code, 'view_replaced')
// der erlaubte Pfad erreicht den Router (hier: das Projekt gehoert c1, die Funktion gibt es nicht => 403 der Route, nicht des Waechters)
const durchgelassen = await call('PUT', '/api/zeiten/p1', { ...kv(), body: { version: 0, daten: {} } })
assert.equal(durchgelassen.status, 403)
assert.notEqual(durchgelassen.body.code, 'view_blocked')
assert.match(durchgelassen.body.error, /nicht eingeschaltet/)
})
test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => {
const res = await call('POST', '/api/chat/read', kv())
assert.equal(res.status, 403)