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>
115 lines
4.6 KiB
JavaScript
115 lines
4.6 KiB
JavaScript
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
|