Der Editor sendet neben bestand den beim Laden gesehenen Wert (bestandGeladen, je Variantenwert mit id). speichereProdukt behaelt unter der Artikelsperre den aktuell gespeicherten Stand, wenn gesendet gleich geladen ist, und schreibt nur vom Haendler geaenderte Bestaende. bestandGeladen wird nie gespeichert; ohne den Wert (alter Browser) gilt der gesendete Bestand wie bisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
31 KiB
JavaScript
636 lines
31 KiB
JavaScript
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 { 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 {
|
|
BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular,
|
|
normalisiereRechtstexte, 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.',
|
|
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)
|
|
}))
|
|
|
|
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, 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, 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),
|
|
}
|
|
}
|
|
|
|
/** 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 at = new Date().toISOString()
|
|
const bestaetigt = { texte: { ...entwurf }, hash, 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). */
|
|
async function ueberStripe(fn) {
|
|
try {
|
|
return await fn()
|
|
} catch (err) {
|
|
if (err?.status >= 400 && err.status < 500 && !err.type) throw err
|
|
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
|
|
|
|
// 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 })
|
|
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 })
|
|
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 })
|
|
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
|