import express, { Router } from 'express' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' import { countDocuments, getDocument, listAllDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { config } from '../config.js' import { withLocks } from '../services/keyedLock.js' import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' import { createWriteLimiter } from '../services/writeLimiter.js' import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' import { kundenartWirksam } from '../services/kundenart.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js' import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js' import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' import { bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, } from '../services/shop/produkte.js' import { archiviereRechtstexte } from '../services/shop/archiv.js' import { BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig, } from '../services/shop/rechtstexte.js' import { BILD_MAX_BYTES, beanspruche, bildLimitErreicht, bildMeta, bildPfad, loescheBild, setzeBildLimitZurueck, speichereBild, } from '../services/shop/bilder.js' /** * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe-Konto (D2) und Bestellungen (E) kommen dazu. * * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter * (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an). * Kundenansicht: Artikel, Einstellungen, Rechtstexte (nur Entwurf speichern) und Bilder darf das Team mit ausfuellen und werden * protokolliert (kundenansichtGuard); Anfrage, Oeffnen und Bestaetigen der Rechtstexte bleiben dem Kunden vorbehalten (hier 403). * Schreib-Routen zaehlen gegen 300 Aufrufe je Kunde und Stunde (Anfrage-Routen gegen 20), Bilder zusaetzlich gegen 40 je Stunde. * Fehler: { error: , fehler?: { : Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie. */ const router = Router() const MAX_ANFRAGE_WRITES_PER_HOUR = 20 const MAX_WRITES_PER_HOUR = 300 const MAX_CSV_PER_HOUR = 30 const PRODUKT_DOC_MAX_JSON = 100000 const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/ 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.', nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', nichtEingerichtet: 'Der Webshop ist für dieses Projekt noch nicht eingerichtet.', zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.', bildFehlt: 'Bild nicht gefunden.', eingabe: 'Die Eingabe fehlt oder ist unlesbar.', stripeKundenansicht: 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.', stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', plattform: 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.', keinKonto: 'Es ist noch kein Stripe-Konto angelegt.', } /** 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 === 502 || 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 const erster = Object.values(err.fehler).find((t) => typeof t === 'string') if (erster) body.error = erster } if (typeof err.code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(err.code) && body.code === undefined) body.code = err.code 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 anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) const csvLimiter = createWriteLimiter({ max: MAX_CSV_PER_HOUR }) const limitCsv = csvLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) export function resetShopRoutesForTests() { anfrageLimiter.reset() schreibLimiter.reset() csvLimiter.reset() setzeBildLimitZurueck() } 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.params?.projectId ?? req.query.projectId ?? req.body?.projectId return typeof raw === 'string' ? raw : '' } router.use((_req, res, next) => { res.set('Cache-Control', 'no-store') next() }) const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt'] const BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen'] /** Stripe-Status fuer den Browser: nur bekannte Felder, nie Konto-ID oder Schluessel. */ function stripeAnsicht(shop) { const roh = shop?.stripeStatus const status = roh ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(roh, k)).map((k) => [k, roh[k]])) : null return { verbunden: Boolean(shop?.stripeAccountId), status } } /** Kennzahlen der Uebersicht (nur Anzeige): Artikel und, best effort, Bestellungen. Ein Lesefehler bei den Bestellungen zaehlt als 0. */ async function kennzahlen(shopId, produkte, now = new Date()) { const zahlen = { artikel: produkte.length, aktiveArtikel: produkte.filter((d) => d.aktiv !== false).length, offeneBestellungen: 0, bestellungen30: 0, umsatz30Cent: 0, } try { zahlen.offeneBestellungen = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')]) const seit = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) const neu = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shopId), Query.greaterThanEqual('erstelltAt', seit.toISOString())]) for (const o of neu) { if (!BEZAHLT.includes(o.status)) continue zahlen.bestellungen30 += 1 if (Number.isInteger(o.summeCent)) zahlen.umsatz30Cent += o.summeCent } } catch (err) { console.warn(`[shop] Kennzahlen der Bestellungen nicht lesbar: ${String(err?.message ?? '').slice(0, 120)}`) } return zahlen } const einbauSnippet = (siteKey) => `
\n` const vorschauUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}` const widerrufUrl = (siteKey) => `${config.publicBaseUrl}/shop/${siteKey}/widerruf` async function ticketWoid(ticketId) { if (!ticketId) return null try { return (await getDocument(config.collections.workorders, ticketId))?.woid || null } catch { return null } } /** * Stand des Webshops fuer ein Projekt. verfuegbar = Schalter fuer diesen Kunden; state = Zustand der Funktion (off ohne Dokument); * anfrage = anfrageAnsicht oder null; mit shopConfig dazu: ticketWoid, bereitschaft ({ bereit, punkte }), geoeffnet (verkauft der Shop * gerade), schalter (Schalter des Kunden), siteKey, snippet, vorschauUrl, stripe ({ verbunden, status }), zahlen. 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') const shopDoc = feature ? await leseShopConfig(feature.$id) : null const antwort = { verfuegbar, state: feature?.state || 'off', ticketWoid: null, anfrage: shopDoc ? anfrageAnsicht(shopDoc) : null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: feature?.siteKey || null, snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, } if (feature && shopDoc) { const shop = ladeShop(shopDoc) const produkte = await listeProdukte(shop.id) const ctx = { doc: feature, shop, project: null } antwort.ticketWoid = await ticketWoid(feature.ticketId) antwort.bereitschaft = await bereitschaft(ctx, { produkte }) antwort.geoeffnet = await istGeoeffnet(ctx, { produkte }) antwort.schalter = shop.geoeffnet antwort.snippet = einbauSnippet(shop.siteKey || feature.siteKey) antwort.vorschauUrl = vorschauUrl(shop.siteKey || feature.siteKey) antwort.stripe = stripeAnsicht(shop) antwort.zahlen = await kennzahlen(shop.id, produkte) } res.json(antwort) })) /** Kundenart des Kundendokuments (leer oder unbekannt = privat) - nur der Server entscheidet, nie der Client; ein unbekannter Kunde ist privat. */ async function kundenartDesKunden(customerId) { try { return kundenartWirksam(await getDocument(config.collections.customers, customerId)) } catch (err) { if (err?.status === 404) return 'privat' throw err } } router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { const customerId = getSessionCustomerId(req) const anfrage = await stelleAnfrage({ customerId, projectId: projectIdOf(req), eingabe: req.body?.anfrage, person: person(req), // Abo-Modell (neue Ansicht): Unternehmer aus der Kundenart, privat nur_betrieb; sonst unveraendert (undefined) kundenart: neueAnsicht(req) ? await kundenartDesKunden(customerId) : undefined, }) res.json({ anfrage }) })) router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitAnfrage, route(async (req, res) => { const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) }) res.json({ anfrage }) })) // ---------------------------------------------------------------- Verwaltung (C7) const stop = (status, text, code, fehlerFelder) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}), ...(fehlerFelder ? { fehler: fehlerFelder } : {}) }) const eingabeFehler = (fehlerFelder) => stop(400, Object.values(fehlerFelder)[0], undefined, fehlerFelder) const personLabel = (p) => (p.name ? `${p.name}${p.email ? ` <${p.email}>` : ''}` : p.email || 'Kunde') /** * Gemeinsame Pruefung der Verwaltungs-Routen: projectId, eigenes Projekt (403; archiviert nur lesend), Schalter (409 * nicht_verfuegbar), Funktion im Zustand zustaende (Vorgabe: schreibend setup|active, lesend setup|active|disabled; sonst 409 * nicht_eingerichtet) und shopConfig (fehlt es und der Zustand ist setup|active, wird es angelegt). * @returns {Promise<{ customerId: string, projectId: string, project: object, doc: object, shopDoc: object, shop: object }>} */ async function mitShop(req, { schreiben = false, zustaende } = {}) { const projectId = projectIdOf(req) if (!projectId) throw stop(400, MELDUNG.projekt) const customerId = getSessionCustomerId(req) let project try { project = await assertOwnProject(customerId, projectId, { forNewOrder: schreiben }) } catch (err) { throw err?.status === 403 || err?.status === 400 ? stop(403, MELDUNG.fremd) : err } if (!shopFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') const erlaubt = zustaende || (schreiben ? ['setup', 'active'] : ['setup', 'active', 'disabled']) const doc = await getFeatureDoc(customerId, projectId, 'shop') if (!doc || !erlaubt.includes(doc.state)) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') let shopDoc = await leseShopConfig(doc.$id) if (!shopDoc && ['setup', 'active'].includes(doc.state)) shopDoc = await ensureShopConfig(doc) if (!shopDoc) throw stop(409, MELDUNG.nichtEingerichtet, 'nicht_eingerichtet') return { customerId, projectId, project, doc, shopDoc, shop: ladeShop(shopDoc) } } /** Middleware: mitShop vor dem Einlesen eines Uploads; Ergebnis in req.shopCtx. */ const mitShopSchreibend = route(async (req, _res, next) => { req.shopCtx = await mitShop(req, { schreiben: true }) next() }) const protokoll = (req, text) => logIfKundenansicht(req, 'shop_aenderung', text) // ---- Einstellungen const einstellungenAntwort = (shop) => ({ einstellungen: shop.einstellungen, version: shop.version, vollstaendig: istVollstaendig(shop.einstellungen) }) router.get('/einstellungen', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) res.json(einstellungenAntwort(shop)) })) router.put('/einstellungen', requireSession, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true }) const gespeichert = await speichereEinstellungen(shop.id, req.body?.einstellungen, { erwarteteVersion: Number.isInteger(req.body?.version) ? req.body.version : undefined }) await protokoll(req, 'Einstellungen gespeichert') res.json({ einstellungen: gespeichert.einstellungen, version: gespeichert.version, vollstaendig: istVollstaendig(gespeichert.einstellungen) }) })) // ---- Artikel /** Artikel fuer den Haendler: alle gespeicherten Felder, dazu id, aktiv, sort und Vorschau-Adressen der Bilder. */ function produktAnsicht(doc, projectId) { const p = parseProdukt(doc) || {} const bilder = Array.isArray(p.bilder) ? p.bilder.filter((b) => typeof b === 'string') : [] return { ...p, id: doc.$id, name: typeof p.name === 'string' ? p.name : doc.name || '', aktiv: doc.aktiv !== false, sort: Number.isInteger(doc.sort) ? doc.sort : 0, bilder, bildUrls: bilder.map((id) => ({ id, klein: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`, gross: `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-l.webp`, })), } } const bildIdsVon = (produkt) => (Array.isArray(produkt?.bilder) ? produkt.bilder.filter((b) => typeof b === 'string') : []) /** Bilder, die nach einer Aenderung kein Artikel des Shops mehr verwendet, loeschen. */ async function raeumeBilderAuf(shop, kandidaten) { if (kandidaten.length === 0) return const inGebrauch = await bildIdsInGebrauch(shop.id) for (const id of kandidaten) if (!inGebrauch.has(id)) await loescheBild(shop.siteKey, id) } router.get('/produkte', requireSession, route(async (req, res) => { const { shop, projectId } = await mitShop(req) const docs = await listeProdukte(shop.id) res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)), maxArtikel: config.shop.maxArtikel, maxBilder: config.shop.maxBilder }) })) /** Artikel anlegen (id null) oder aendern: normalisieren, Bilder pruefen und beanspruchen, entfernte Bilder aufraeumen. */ async function speichereArtikel(req, res, id) { const { shop, customerId, projectId } = await mitShop(req, { schreiben: true }) const { produkt, fehler } = normalisiereProdukt(req.body?.produkt, { einstellungen: shop.einstellungen }) if (fehler) throw eingabeFehler(fehler) const neueBilder = bildIdsVon(produkt) const doc = await withLocks([`shop-bilder:${shop.id}`], async () => { let vorher = [] if (id) { const alt = await holeProdukt(shop.id, id) if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') vorher = bildIdsVon(parseProdukt(alt)) } for (const bildId of neueBilder) { const meta = await bildMeta(shop.siteKey, bildId) if (!meta || meta.shopId !== shop.id) throw eingabeFehler({ bilder: 'Eines der Bilder gibt es nicht (mehr). Bitte lade es neu hoch.' }) } const gespeichert = await speichereProdukt({ shopId: shop.id, customerId }, id, produkt, { geladen: leseBestandGeladen(req.body?.produkt) }) await beanspruche(shop.siteKey, neueBilder) await raeumeBilderAuf(shop, vorher.filter((b) => !neueBilder.includes(b))) return gespeichert }) await protokoll(req, `Artikel ${id ? 'geändert' : 'angelegt'}: ${produkt.name}`) res.status(id ? 200 : 201).json({ produkt: produktAnsicht(doc, projectId) }) } router.post('/produkte', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, null))) router.post('/produkte/sortierung', requireSession, limitWrites, route(async (req, res) => { const { shop, projectId } = await mitShop(req, { schreiben: true }) const docs = await sortiere(shop.id, req.body?.ids) await protokoll(req, 'Artikel sortiert') res.json({ produkte: docs.map((d) => produktAnsicht(d, projectId)) }) })) router.put('/produkte/:id', requireSession, limitWrites, route((req, res) => speichereArtikel(req, res, req.params.id))) router.delete('/produkte/:id', requireSession, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true }) const name = await withLocks([`shop-bilder:${shop.id}`], async () => { const alt = await holeProdukt(shop.id, req.params.id) if (!alt) throw stop(404, 'Diesen Artikel gibt es nicht (mehr).') await loescheProdukt(shop.id, req.params.id) await raeumeBilderAuf(shop, bildIdsVon(parseProdukt(alt))) return alt.name || '' }) await protokoll(req, `Artikel gelöscht: ${name}`) res.json({ ok: true }) })) // ---- Rechtstexte /** Antwort der Rechtstexte: Entwurf, zuletzt bestaetigte Fassung, Muster zum Zuruecksetzen, erzeugte Texte und Hash des Entwurfs. */ function rechtstexteAntwort(shop) { const e = shop.einstellungen const { entwurf, bestaetigt } = shop.rechtstexte const hash = rechtstexteHash(entwurf, e) return { entwurf, bestaetigt: bestaetigt ? { texte: bestaetigt.texte, at: bestaetigt.at, von: bestaetigt.von, aktuell: bestaetigt.hash === hash } : null, muster: { agb: musterAgb(e), widerrufsbelehrung: musterWiderrufsbelehrung(e, { widerrufUrl: widerrufUrl(shop.siteKey) }), widerrufsformular: musterWiderrufsformular(e), }, versandUndZahlung: versandUndZahlung(e), gewaehrleistung: { bild: GEWAEHRLEISTUNG_BILD, text: gewaehrleistungsHinweis() }, hash, bestaetigungText: BESTAETIGUNG_TEXT, vollstaendig: vollstaendig(entwurf), // Was das Bestaetigen des gespeicherten Entwurfs derzeit verhindert (Platzhalter, Musterkopf, fehlende Adresse der Widerrufsfunktion) probleme: pruefeBestaetigbar(entwurf, { widerrufUrl: widerrufUrl(shop.siteKey) }), } } /** shopConfig frisch lesen (unter der Sperre) und als geparsten Shop liefern. */ async function frischerShop(shopId) { return ladeShop(await getDocument(config.collections.shopConfig, shopId)) } router.get('/rechtstexte', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) res.json(rechtstexteAntwort(shop)) })) router.put('/rechtstexte', requireSession, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true }) const { rechtstexte, fehler } = normalisiereRechtstexte(req.body?.rechtstexte) if (fehler) throw eingabeFehler(fehler) const neu = await withLocks([`shop-rt:${shop.id}`], async () => { const aktuell = await frischerShop(shop.id) const json = JSON.stringify({ entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt }) if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json }) return { ...aktuell, rechtstexte: { entwurf: rechtstexte, bestaetigt: aktuell.rechtstexte.bestaetigt } } }) leereCache(shop.siteKey) await protokoll(req, 'Rechtstexte-Entwurf gespeichert') res.json(rechtstexteAntwort(neu)) })) router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true }) const hash = typeof req.body?.hash === 'string' ? req.body.hash : '' const von = personLabel(person(req)) const neu = await withLocks([`shop-rt:${shop.id}`], async () => { const aktuell = await frischerShop(shop.id) const { entwurf } = aktuell.rechtstexte if (hash === '' || hash !== rechtstexteHash(entwurf, aktuell.einstellungen)) { throw stop(409, 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', 'texte_geaendert') } if (!vollstaendig(entwurf)) throw stop(400, 'Bitte fülle AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz aus, bevor du bestätigst.') const probleme = pruefeBestaetigbar(entwurf, { widerrufUrl: widerrufUrl(aktuell.siteKey) }) if (probleme.length) { throw Object.assign(stop(400, 'Die Texte können noch nicht bestätigt werden. Bitte behebe zuerst die genannten Punkte.', 'nicht_bestaetigbar'), { body: { code: 'nicht_bestaetigbar', probleme } }) } const at = new Date().toISOString() const bestaetigt = { texte: { ...entwurf }, hash, at, von } // Fassung ins Archiv (G09 zeigt dem Kaeufer die Fassung seiner Bestellung); scheitert das, wird nichts bestaetigt await archiviereRechtstexte({ shopId: shop.id, customerId: shop.customerId, hash, texte: { ...entwurf, versandUndZahlung: versandUndZahlung(aktuell.einstellungen), gewaehrleistung: gewaehrleistungsHinweis() }, at, von, }) const json = JSON.stringify({ entwurf, bestaetigt }) if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') await updateDocument(config.collections.shopConfig, shop.id, { rechtstexte: json, rechtstexteBestaetigtAt: at, rechtstexteBestaetigtVon: von, rechtstexteHash: hash, }) return { ...aktuell, rechtstexte: { entwurf, bestaetigt } } }) leereCache(shop.siteKey) res.json(rechtstexteAntwort(neu)) })) // ---- Shop oeffnen und schliessen router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res) => { if (typeof req.body?.offen !== 'boolean') throw stop(400, 'Bitte wähle, ob der Shop geöffnet oder geschlossen sein soll.') const { shop, doc } = await mitShop(req, { schreiben: true }) const produkte = await listeProdukte(shop.id) const ctx = { doc, shop, project: null } if (req.body.offen) { const stand = await bereitschaft(ctx, { produkte }) if (!stand.bereit) throw Object.assign(stop(409, 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', 'nicht_bereit'), { body: { code: 'nicht_bereit', punkte: stand.punkte } }) } await updateDocument(config.collections.shopConfig, shop.id, { geoeffnet: req.body.offen, geoeffnetAt: new Date().toISOString() }) leereCache(shop.siteKey) const neu = { ...shop, geoeffnet: req.body.offen } res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) }) })) // ---- Stripe-Konto (D2) /** Nur der Kunde selbst richtet sein Stripe-Konto ein (weder Kundenansicht noch "Ansehen als"). */ const nurKundeStripe = (req, res, next) => { if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.stripeKundenansicht }) return next() } /** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (Stripe-Texte verlassen den Server nie, geloggt wird nur Typ und Code). */ // Connect im WEBklar-Konto nicht fertig (Plattformprofil fehlt oder Connect nicht aktiviert): das kann nur WEBklar beheben const PLATTFORM_FEHLT = /platform profile|signed up for Connect/i async function ueberStripe(fn) { try { return await fn() } catch (err) { if (err?.status >= 400 && err.status < 500 && !err.type) throw err if (err?.type === 'StripeInvalidRequestError' && PLATTFORM_FEHLT.test(String(err.message || ''))) { console.error('[shop] Stripe Connect: Plattformprofil fehlt (Stripe-Dashboard → Connect → Fragebogen ausfüllen)') throw stop(503, MELDUNG.plattform, 'plattform_nicht_bereit') } console.error(`[shop] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) throw stop(502, MELDUNG.stripeFern) } } const speichereStripe = async (shop, felder) => { await updateDocument(config.collections.shopConfig, shop.id, felder) leereCache(shop.siteKey) } const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#webshop?stripe=${was}&projekt=${encodeURIComponent(projectId)}` const onboardingFuer = (accountId, projectId) => ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(projectId, 'zurueck'), refreshUrl: stripeRueckUrl(projectId, 'neu') })) // Konto anlegen (einmalig, unter Sperre) und Onboarding-Link liefern router.post('/stripe/einrichten', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { const { shop, customerId, projectId, project } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) const accountId = await withLocks([`shop-stripe:${shop.id}`], async () => { const frisch = await frischerShop(shop.id) if (frisch.stripeAccountId) return frisch.stripeAccountId const customer = await getDocument(config.collections.customers, customerId).catch(() => null) const neu = await ueberStripe(() => kontoAnlegen({ customer: customer || { $id: customerId, email: req.session?.email }, project })) let status try { status = await ueberStripe(() => kontoStatus(neu)) } catch { status = statusAusKonto({}) } await speichereStripe(shop, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) }) return neu }) res.json({ url: await onboardingFuer(accountId, projectId) }) })) // Neuer Onboarding-Link (der alte laeuft nach wenigen Minuten ab) router.post('/stripe/link', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { const { shop, projectId } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') res.json({ url: await onboardingFuer(shop.stripeAccountId, projectId) }) })) // Status bei Stripe neu lesen und speichern router.post('/stripe/aktualisieren', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') const status = await ueberStripe(() => kontoStatus(shop.stripeAccountId)) const alt = shop.stripeStatus const zahlungsarten = Array.isArray(shop.einstellungen?.zahlungsarten) ? shop.einstellungen.zahlungsarten : ['card'] const behalteHinweis = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && zahlungsarten.some((z) => z === 'klarna' || z === 'sepa') const neu = behalteHinweis ? { ...status, hinweis: alt.hinweis } : status await speichereStripe(shop, { stripeStatus: JSON.stringify(neu) }) res.json({ stripe: stripeAnsicht({ ...shop, stripeStatus: neu }) }) })) // ---- Bestellungen und Widerrufe (E3) const seiteVon = (wert) => { const nr = Number.parseInt(wert, 10) return Number.isInteger(nr) && nr >= 1 ? nr : 1 } const textParam = (wert) => (typeof wert === 'string' ? wert : undefined) /** Status aendern verschickt Mails an Kaeufer und erstattet Geld: nie in der Kundenansicht oder "Ansehen als". */ const nurHaendler = nurKunde /** Bestellungen bearbeiten, erstatten und Widerrufe abhaken muss auch bei deaktivierter Funktion gehen (Widerruf, Erstattung bleiben Pflicht). */ const ZUSTAENDE_ABWICKLUNG = ['setup', 'active', 'disabled'] // Liste (Lesen auch in der Kundenansicht und bei deaktiviertem Shop) router.get('/bestellungen', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) res.json(await listeBestellungen(shop.id, { status: textParam(req.query.status), suche: textParam(req.query.suche), seite: seiteVon(req.query.seite) })) })) // CSV fuer die Buchhaltung (vor /bestellungen/:id, damit der Pfad eindeutig bleibt) router.get('/bestellungen.csv', requireSession, limitCsv, route(async (req, res) => { const { shop } = await mitShop(req) const erg = await bestellungenCsv(shop.id, { von: textParam(req.query.von), bis: textParam(req.query.bis) }) res.set({ 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${erg.dateiname}"`, 'X-Content-Type-Options': 'nosniff', }) res.send(erg.csv) })) router.get('/bestellungen/:id', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) const order = await bestellungLesen(req.params.id) if (!order || order.shopId !== shop.id) throw stop(404, 'Diese Bestellung gibt es nicht.') res.json({ bestellung: bestellungKunde(order) }) })) router.post('/bestellungen/:id/aktion', requireSession, nurHaendler, limitWrites, route(async (req, res) => { const { shop, doc, project } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) const aktion = typeof req.body?.aktion === 'string' ? req.body.aktion : '' const { order, mailOk } = await setzeStatus({ doc, shop, project }, req.params.id, aktion, { versanddienst: req.body?.versanddienst, sendungsnummer: req.body?.sendungsnummer, }) res.json({ bestellung: bestellungKunde(order), mailOk }) })) router.post('/bestellungen/:id/erstatten', requireSession, nurHaendler, limitWrites, route(async (req, res) => { const { shop, doc, project } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) const { order, erstattung, voll, mailOk } = await erstatte({ doc, shop, project }, req.params.id, { betragCent: req.body?.betragCent, grund: req.body?.grund, }) res.json({ bestellung: bestellungKunde(order), erstattung, voll, mailOk }) })) router.get('/widerrufe', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) res.json(await listeWiderrufe(shop.id, { seite: seiteVon(req.query.seite) })) })) router.post('/widerrufe/:id/erledigt', requireSession, nurHaendler, limitWrites, route(async (req, res) => { const { shop } = await mitShop(req, { schreiben: true, zustaende: ZUSTAENDE_ABWICKLUNG }) res.json({ widerruf: await markiereBearbeitet(shop.id, req.params.id) }) })) // ---- Bilder // Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen router.post( '/bild', requireSession, limitWrites, mitShopSchreibend, (req, res, next) => { if (bildLimitErreicht(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuVieleBilder }) return next() }, uploadSlot, express.raw({ type: 'application/octet-stream', limit: BILD_MAX_BYTES }), route(async (req, res) => { const { shop, customerId } = req.shopCtx const bild = await speichereBild({ customerId, shopId: shop.id, siteKey: shop.siteKey, buffer: req.body }) await protokoll(req, 'Bild hochgeladen') res.status(201).json({ imageId: bild.imageId, w: bild.w, h: bild.h }) }) ) // Vorschau im Portal: nur fuer den Kunden, dem das Projekt gehoert router.get('/bild/:projectId/:datei', requireSession, route(async (req, res) => { const { shop } = await mitShop(req) const treffer = BILD_DATEI_RE.exec(req.params.datei) const datei = treffer && (await bildMeta(shop.siteKey, treffer[1])) && bildPfad(shop.siteKey, treffer[1], treffer[2]) if (!datei) return res.status(404).json({ error: MELDUNG.bildFehlt }) res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' }) return res.sendFile(datei, (err) => { if (err && !res.headersSent) res.status(404).json({ error: MELDUNG.bildFehlt }) }) })) // Fehler beim Einlesen eines Uploads (zu gross, abgebrochen): verstaendlich beantworten, nie Rohtext router.use((err, _req, res, next) => { if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Bild ist größer als 8 MB.' }) if (err?.status >= 400 && err.status < 500 && res.headersSent === false) return res.status(err.status).json({ error: 'Die Anfrage ist ungültig.' }) return next(err) }) export default router