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:
1 parent
0063c04d8a
commit
83605975b0
7 files changed
+801
-17
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user