Terminplaner: Kunden-Schnittstelle – Nachbesserungen
- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42) - Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und Terminplaner statt kopierter Funktion (R43) - Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern ueberschreibt den Schaden statt dauerhaft 409 - U+FEFF als Escape statt als rohes Zeichen - Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e198fb1a
commit
bb45d35ec7
8 files changed
+319
-65
No files matched your search
+5
-22
@@ -7,11 +7,10 @@ import { uploadSlot } from './chat.js'
|
||||
import { config } from '../config.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const MAX_WRITES_PER_HOUR = 30
|
||||
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
|
||||
|
||||
function fail(res, err) {
|
||||
@@ -23,26 +22,10 @@ function fail(res, err) {
|
||||
}
|
||||
|
||||
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
|
||||
const recentWrites = new Map()
|
||||
function writeRateLimited(customerId) {
|
||||
const now = Date.now()
|
||||
for (const [key, times] of recentWrites) {
|
||||
const live = times.filter((t) => now - t < HOUR_MS)
|
||||
if (live.length === 0) recentWrites.delete(key)
|
||||
else if (live.length !== times.length) recentWrites.set(key, live)
|
||||
}
|
||||
const list = recentWrites.get(customerId) || []
|
||||
list.push(now)
|
||||
recentWrites.set(customerId, list)
|
||||
return list.length > MAX_WRITES_PER_HOUR
|
||||
}
|
||||
|
||||
function limitWrites(req, res, next) {
|
||||
if (writeRateLimited(getSessionCustomerId(req))) {
|
||||
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
const limitWrites = createWriteLimiter({ max: 30 }).middleware(
|
||||
(req) => getSessionCustomerId(req),
|
||||
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
|
||||
)
|
||||
|
||||
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
|
||||
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
|
||||
|
||||
+35
-29
@@ -5,8 +5,10 @@ import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { analysePrivacyData } from '../services/analysePrivacy.js'
|
||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { verschiebeZeiten } from '../services/termine/ablauf.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
import { imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
|
||||
import { zustellFehler } from '../services/termine/outbox.js'
|
||||
@@ -33,7 +35,6 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
|
||||
|
||||
const router = Router()
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const MAX_WRITES_PER_HOUR = 120
|
||||
const MAX_TAGE = 62
|
||||
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
|
||||
@@ -70,6 +71,7 @@ const MELDUNG = {
|
||||
leistung: 'Bitte eine Leistung auswählen.',
|
||||
ressource: 'Unbekannte Person oder unbekannter Platz.',
|
||||
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
|
||||
keineBuchung: 'Diesen Termin gibt es nicht.',
|
||||
ungueltig: 'Ungültige Eingabe.',
|
||||
}
|
||||
|
||||
@@ -112,28 +114,12 @@ const route = (fn) => async (req, res, next) => {
|
||||
|
||||
// ---------------------------------------------------------------- Schreib-Limit
|
||||
|
||||
// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE
|
||||
const recentWrites = new Map()
|
||||
function writeRateLimited(customerId) {
|
||||
const now = uhr().getTime()
|
||||
for (const [key, times] of recentWrites) {
|
||||
const live = times.filter((t) => now - t < HOUR_MS)
|
||||
if (live.length === 0) recentWrites.delete(key)
|
||||
else if (live.length !== times.length) recentWrites.set(key, live)
|
||||
}
|
||||
const list = recentWrites.get(customerId) || []
|
||||
list.push(now)
|
||||
recentWrites.set(customerId, list)
|
||||
return list.length > MAX_WRITES_PER_HOUR
|
||||
}
|
||||
|
||||
const limitWrites = route((req, res, next) => {
|
||||
if (writeRateLimited(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuViele })
|
||||
return next()
|
||||
})
|
||||
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
|
||||
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
|
||||
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
|
||||
|
||||
export function resetTermineRoutesForTests() {
|
||||
recentWrites.clear()
|
||||
limiter.reset()
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
@@ -345,6 +331,12 @@ function buchungJson(b, zustellFehlerIds) {
|
||||
|
||||
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
|
||||
|
||||
/**
|
||||
* Buchungen im Zeitraum `von` bis `bis` (Berliner Tage 'JJJJ-MM-TT', beide Tage eingeschlossen, beide Pflicht), nach Beginn
|
||||
* sortiert, mit Sperren. Zeitraum: `bis` darf höchstens 62 Tage nach `von` liegen (bis ≤ von + 62 Tage, also bis zu 63
|
||||
* Kalendertage) und nicht davor. `status` (optional) ist eine Liste bekannter Status, durch Komma getrennt; ohne Angabe
|
||||
* kommen alle Status.
|
||||
*/
|
||||
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = req.ctx
|
||||
const { von: vonTag, bis: bisTag, status } = req.query
|
||||
@@ -370,9 +362,27 @@ router.get('/buchungen', requireSession, withTermine(), route(async (req, res) =
|
||||
res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) })
|
||||
}))
|
||||
|
||||
/**
|
||||
* Freie Startzeiten eines Tages für den Betrieb (Termin eintragen, Verschieben): ohne Vorlauf und Horizont, nie in der
|
||||
* Vergangenheit. `leistung` und `ressource` (Vorgabe 'egal') wählen, was gesucht wird.
|
||||
* Mit `ohne=<Buchungs-ID>` (Verschieben) gelten Leistung, Dauer und Person der Buchung, genau wie beim Verschieben selbst
|
||||
* (ablauf.js verschiebeZeiten, Ruling R42); `leistung` und `ressource` aus der Abfrage werden dann ignoriert. Unbekannte
|
||||
* Buchung, Buchung einer anderen Funktion oder Sperre => 404.
|
||||
*/
|
||||
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = mitEinstellung(req)
|
||||
const { leistung, ressource = 'egal', tag, ohne } = req.query
|
||||
const now = uhr()
|
||||
|
||||
if (ohne !== undefined) {
|
||||
if (typeof ohne !== 'string' || !ID_RE.test(ohne)) throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
|
||||
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
|
||||
const buchung = await hole(ohne)
|
||||
if (!buchung || buchung.featureId !== ctx.feature.$id || buchung.quelle === 'sperre') throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
|
||||
const { zeiten } = await verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln: true })
|
||||
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
||||
}
|
||||
|
||||
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
|
||||
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
|
||||
}
|
||||
@@ -380,22 +390,18 @@ router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
|
||||
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
|
||||
}
|
||||
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
|
||||
if (ohne !== undefined && (typeof ohne !== 'string' || !ID_RE.test(ohne))) throw stop(400, MELDUNG.ungueltig, undefined, 'ohne')
|
||||
|
||||
const now = uhr()
|
||||
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
|
||||
// ohneRegeln: der Betrieb trägt auch kurzfristig und jenseits des Buchungszeitraums ein (nie in die Vergangenheit);
|
||||
// ohne = die Buchung, die verschoben wird, belegt ihre neue Zeit nicht selbst
|
||||
const zeiten = freieZeiten({
|
||||
einstellung: ctx.einstellung,
|
||||
leistungId: leistung,
|
||||
ressourceId: ressource,
|
||||
tag,
|
||||
belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}),
|
||||
belegung: belegungAus(amTag, now),
|
||||
now,
|
||||
ohneRegeln: true,
|
||||
})
|
||||
res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
||||
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------- Buchungen schreiben
|
||||
@@ -462,7 +468,7 @@ router.get('/export.csv', requireSession, withTermine(), route(async (req, res)
|
||||
b.gastNachricht,
|
||||
]
|
||||
})]
|
||||
const csv = `${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
|
||||
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
|
||||
res.set({
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="termine.csv"',
|
||||
|
||||
Reference in new issue
Block a user