Webshop B1: Anfrage-Dienst, Kundenroute /api/shop und Team-Mail T21

Anfrage stellen, zurückziehen und Stand lesen (ensureShopConfig, normalisiereAnfrage,
stelleAnfrage, zieheZurueck, anfrageAnsicht), Router /api/shop mit Rate-Limit 20/h und
Sperre in der Kundenansicht, Vorlage T21 Neue Webshop-Anfrage mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:40:24 +00:00
1 parent 0063c04d8a
commit 83605975b0
7 files changed
+801 -17

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { config } from '../config.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
/**
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt
* die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5.
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt
* (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde.
* Fehler: { error: <Text>, fehler?: { <feld>: Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie.
*/
const router = Router()
const MAX_WRITES_PER_HOUR = 20
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
fremd: 'Kein Zugriff auf dieses Projekt.',
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
}
/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */
function fail(res, err) {
const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type
if (!own) {
console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim())
return res.status(500).json({ error: 'Interner Serverfehler' })
}
const body = { ...err.body, error: err.message }
if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler
return res.status(err.status).json(body)
}
const route = (fn) => async (req, res, next) => {
try {
await fn(req, res, next)
} catch (err) {
fail(res, err)
}
}
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetShopRoutesForTests() {
limiter.reset()
}
const nurKunde = (req, res, next) => {
if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.kundenansicht })
return next()
}
const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' })
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
/**
* Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden;
* state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an.
*/
router.get('/status', requireSession, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw Object.assign(new Error(MELDUNG.projekt), { status: 400 })
const customerId = getSessionCustomerId(req)
try {
await assertOwnProject(customerId, projectId)
} catch (err) {
throw err?.status === 403 || err?.status === 400 ? Object.assign(new Error(MELDUNG.fremd), { status: 403 }) : err
}
const verfuegbar = shopFuerKunde(req.session?.email)
const feature = await getFeatureDoc(customerId, projectId, 'shop')
let shop = null
if (feature) {
try {
shop = await getDocument(config.collections.shopConfig, feature.$id)
} catch (err) {
if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err
}
}
res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null })
}))
router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const anfrage = await stelleAnfrage({
customerId: getSessionCustomerId(req),
projectId: projectIdOf(req),
eingabe: req.body?.anfrage,
person: person(req),
})
res.json({ anfrage })
}))
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) })
res.json({ anfrage })
}))
export default router