Files

677 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: <Text>, fehler?: { <feld>: 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) => `<div data-webklar-shop="${siteKey}"></div>\n<script src="${config.publicBaseUrl}/embed/shop.js" defer></script>`
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