diff --git a/server/index.js b/server/index.js
index cdfa158..7c83540 100644
--- a/server/index.js
+++ b/server/index.js
@@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js'
+import shopRoutes from './routes/shop.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes)
+app.use('/api/shop', shopRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
diff --git a/server/mail-templates/T21 Neue Webshop-Anfrage.html b/server/mail-templates/T21 Neue Webshop-Anfrage.html
new file mode 100644
index 0000000..0c18a59
--- /dev/null
+++ b/server/mail-templates/T21 Neue Webshop-Anfrage.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+[Webshop] Neue Anfrage: {{kunde_name}}
+
+
+
+
+{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}). ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Intern · Kundenportal |
+
|
+
+
+ |
+| Neue Webshop-Anfrage |
+| Kunde | {{kunde_name}} | | E-Mail | {{email}} | | Projekt | {{projekt}} | | Warenart | {{warenart}} | | Artikelzahl | {{artikelzahl}} | | Lieferung | {{lieferung}} | | Beschreibung | {{beschreibung}} | | Nachricht | {{nachricht}} |
|
+ |
+| |
+
+ |
+| Automatische Benachrichtigung aus dem WEBklar Kundenportal · Ticketsystem |
+
+ |
+
+
+
diff --git a/server/routes/shop.js b/server/routes/shop.js
new file mode 100644
index 0000000..80a6629
--- /dev/null
+++ b/server/routes/shop.js
@@ -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: , fehler?: { : 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
diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js
index 6ebabf2..196835f 100644
--- a/server/services/shop/anfrage.js
+++ b/server/services/shop/anfrage.js
@@ -1,15 +1,39 @@
import { config } from '../../config.js'
-import { getDocument } from '../appwriteAdmin.js'
-import { getFeatureDoc } from '../siteFeatures.js'
+import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
+import { withLocks } from '../keyedLock.js'
+import { sendAdminNotice } from '../mailer.js'
+import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
+import { shopFuerKunde } from './schalter.js'
/**
- * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus
- * Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus.
- * Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist.
+ * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team
+ * entscheidet (B2: annehmen oder ablehnen); erst eine angenommene Anfrage laesst die Bestellung der Funktion zu
+ * (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des
+ * portalFeatures-Dokuments des Projekts ist. anfrageStatus: '' (keine) | offen | angenommen | abgelehnt | zurueckgezogen.
+ * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code.
*/
export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.'
+export const WARENARTEN = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' }
+export const ARTIKELZAHLEN = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
+
+const BESCHREIBUNG_MIN = 10
+const BESCHREIBUNG_MAX = 1500
+const NACHRICHT_MAX = 1000
+
+const MELDUNG = {
+ projekt: 'Bitte wähle ein Projekt.',
+ nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.',
+ vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.',
+ laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.',
+ nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.',
+ ungueltig: 'Ungültige Eingabe.',
+}
+
+const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) })
+const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
+
/**
* Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen
* ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen
@@ -18,12 +42,191 @@ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigege
export async function anfrageOffenePunkte({ customerId, projectId }) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return [ANFRAGE_OFFEN_TEXT]
- let shop
- try {
- shop = await getDocument(config.collections.shopConfig, doc.$id)
- } catch (err) {
- if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT]
- throw err
- }
+ const shop = await leseShopConfig(doc.$id)
return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT]
}
+
+/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */
+async function leseShopConfig(id) {
+ try {
+ return await getDocument(config.collections.shopConfig, id)
+ } catch (err) {
+ if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null
+ throw err
+ }
+}
+
+/**
+ * shopConfig-Dokument zum portalFeatures-Dokument holen oder anlegen (ID gleich, siteKey kopiert, Anfrage leer, Version 0).
+ * Zwei gleichzeitige Anlagen: die zweite bekommt 409 und liest das Dokument der ersten.
+ */
+export async function ensureShopConfig(featureDoc) {
+ const vorhanden = await leseShopConfig(featureDoc.$id)
+ if (vorhanden) return vorhanden
+ try {
+ return await createDocument(
+ config.collections.shopConfig,
+ {
+ customerId: featureDoc.customerId,
+ projectId: featureDoc.projectId,
+ siteKey: featureDoc.siteKey,
+ anfrageStatus: '',
+ version: 0,
+ },
+ featureDoc.$id
+ )
+ } catch (err) {
+ if (err?.status === 409) {
+ const nochmal = await leseShopConfig(featureDoc.$id)
+ if (nochmal) return nochmal
+ }
+ throw err
+ }
+}
+
+/**
+ * Eingabe der Anfrage pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }.
+ * warenart, beschreibung (10-1500 Zeichen), artikelzahl, versand/abholung (mindestens eins), unternehmer und keineAusgeschlossenen
+ * muessen true sein, nachricht (optional, bis 1000 Zeichen).
+ */
+export function normalisiereAnfrage(eingabe) {
+ const fehler = {}
+ const e = istObjekt(eingabe) ? eingabe : {}
+ const text = (wert) => (typeof wert === 'string' ? wert.toWellFormed().trim() : '')
+
+ const warenart = typeof e.warenart === 'string' && Object.hasOwn(WARENARTEN, e.warenart) ? e.warenart : ''
+ if (!warenart) fehler.warenart = 'Bitte wähle, was du verkaufen möchtest.'
+
+ const beschreibung = text(e.beschreibung)
+ if (beschreibung.length < BESCHREIBUNG_MIN) fehler.beschreibung = `Bitte beschreibe kurz, was du verkaufen möchtest (mindestens ${BESCHREIBUNG_MIN} Zeichen).`
+ else if (beschreibung.length > BESCHREIBUNG_MAX) fehler.beschreibung = `Die Beschreibung darf höchstens ${BESCHREIBUNG_MAX} Zeichen lang sein.`
+
+ const artikelzahl = typeof e.artikelzahl === 'string' && Object.hasOwn(ARTIKELZAHLEN, e.artikelzahl) ? e.artikelzahl : ''
+ if (!artikelzahl) fehler.artikelzahl = 'Bitte wähle, wie viele Artikel du etwa anbieten möchtest.'
+
+ if (e.versand !== undefined && typeof e.versand !== 'boolean') fehler.versand = MELDUNG.ungueltig
+ if (e.abholung !== undefined && typeof e.abholung !== 'boolean') fehler.abholung = MELDUNG.ungueltig
+ const versand = e.versand === true
+ const abholung = e.abholung === true
+ if (!fehler.versand && !fehler.abholung && !versand && !abholung) fehler.versand = 'Bitte wähle Versand, Abholung oder beides.'
+
+ if (e.unternehmer !== true) fehler.unternehmer = 'Bitte bestätige, dass du als Unternehmer verkaufst.'
+ if (e.keineAusgeschlossenen !== true) fehler.keineAusgeschlossenen = 'Bitte bestätige, dass du keine ausgeschlossenen Waren verkaufst.'
+
+ let nachricht = ''
+ if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') {
+ if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig
+ else {
+ nachricht = text(e.nachricht)
+ if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.`
+ }
+ }
+
+ if (Object.keys(fehler).length) return { fehler }
+ return { anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, unternehmer: true, keineAusgeschlossenen: true, nachricht } }
+}
+
+/** Anfrage fuer den Browser: { status, anfrage, at, entschiedenAt, begruendung } oder null, solange es keine gab. Unlesbares JSON => anfrage null. */
+export function anfrageAnsicht(shopDoc) {
+ const status = shopDoc?.anfrageStatus || ''
+ if (!status) return null
+ let anfrage = null
+ try {
+ const roh = typeof shopDoc.anfrage === 'string' && shopDoc.anfrage ? JSON.parse(shopDoc.anfrage) : null
+ anfrage = istObjekt(roh) ? roh : null
+ } catch {
+ anfrage = null
+ }
+ return {
+ status,
+ anfrage,
+ at: shopDoc.anfrageAt || null,
+ entschiedenAt: shopDoc.anfrageEntschiedenAt || null,
+ begruendung: shopDoc.anfrageBegruendung || '',
+ }
+}
+
+/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */
+async function pruefeZugang({ customerId, projectId, person, neu }) {
+ if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
+ let project
+ try {
+ project = await assertOwnProject(customerId, projectId, { forNewOrder: neu })
+ } catch (err) {
+ throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
+ }
+ if (!shopFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
+ return project
+}
+
+/**
+ * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre
+ * je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen`
+ * und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt).
+ * @returns {Promise