Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
+33
-10
@@ -18,7 +18,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
@@ -68,6 +68,8 @@ function minTermEndOf(sub) {
|
||||
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
|
||||
const productKeyOf = (sub) => sub?.productKey || 'wartung'
|
||||
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
|
||||
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
|
||||
const ABO_FUNKTIONEN = ['analyse', 'termine']
|
||||
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
|
||||
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
|
||||
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
|
||||
@@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => {
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse
|
||||
* oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und
|
||||
* K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId,
|
||||
* wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde
|
||||
* wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus.
|
||||
*/
|
||||
async function hatAboFunktionen(sub) {
|
||||
try {
|
||||
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
|
||||
const docs = await listCustomerFeatureDocs(sub.customerId)
|
||||
return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off')
|
||||
} catch (err) {
|
||||
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
|
||||
async function customerNameOf(sub, fallback = '') {
|
||||
const customer = sub?.customerId
|
||||
@@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||
vertragsende: fmtDate(endsAt),
|
||||
vertragsende_paragraph: endParagraphOf(sub),
|
||||
},
|
||||
// Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) },
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim
|
||||
// Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) },
|
||||
})
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
@@ -706,15 +727,15 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'zeiten']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']
|
||||
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', zeiten: 'zeiten' }
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'zeiten'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -788,6 +809,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
if (order === 'termine') return 'Terminplaner (Einrichtung)'
|
||||
if (order === 'shop') return 'Webshop (Einrichtung)'
|
||||
if (order === 'zeiten') return 'Öffnungszeiten live'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
@@ -948,8 +970,9 @@ router.post('/public/cancel', async (req, res) => {
|
||||
abo: !storage,
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo)
|
||||
wartung: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung)
|
||||
funktionen: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung),
|
||||
// nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)),
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
|
||||
@@ -1097,7 +1120,7 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
|
||||
|
||||
/**
|
||||
* Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo
|
||||
* Widerruf einer Funktion (News-Pinnwand 30 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, je einmalig): gehoert zu keinem Abo
|
||||
* und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse;
|
||||
* das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von
|
||||
* Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt
|
||||
@@ -1142,7 +1165,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
|
||||
let sub = null
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
|
||||
// Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
|
||||
const feature = featureKey !== null
|
||||
const funktion = feature ? featureConfig(featureKey) : null
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
|
||||
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { aboMemo } from '../../services/aboStatus.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { validateTextform } from '../../services/analyse.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
|
||||
import {
|
||||
FEATURE_KEYS,
|
||||
FEATURE_LABELS,
|
||||
TEXTFORM_KEYS,
|
||||
adminActivate,
|
||||
assertOwnProject,
|
||||
customerOf,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
getFeatureDoc,
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
textformFassung,
|
||||
ticketNachlegen,
|
||||
} from '../../services/siteFeatures.js'
|
||||
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
|
||||
@@ -24,8 +28,10 @@ import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
|
||||
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
|
||||
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
|
||||
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
|
||||
import { freischaltHindernis } from '../../services/termine/freischaltung.js'
|
||||
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
|
||||
import { ladeStand } from '../../services/termine/kontext.js'
|
||||
import { shopTextformNoetig } from '../../services/shop/schalter.js'
|
||||
import { termineTextformNoetig } from '../../services/termine/schalter.js'
|
||||
|
||||
/**
|
||||
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
|
||||
@@ -93,15 +99,66 @@ async function customerName(customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
|
||||
|
||||
/**
|
||||
* Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die
|
||||
* Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im
|
||||
* Log, die Antwort traegt die Warnung trotzdem.
|
||||
*/
|
||||
async function notiereK16Fehlt(projectId, doc) {
|
||||
try {
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }),
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
|
||||
|
||||
/** Verlangt die Aktivierung dieser Funktion fuer diese Adresse die Zustimmung in Textform? Nur Terminplaner und Webshop, je nach Modus und Pilotliste der Funktion. */
|
||||
const textformVerlangt = { termine: termineTextformNoetig, shop: shopTextformNoetig }
|
||||
|
||||
/**
|
||||
* Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners oder des Webshops durch das Team (F13a; AVV Webshop,
|
||||
* Vorbemerkung Abs. 2). null, wenn sie entfaellt: andere Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig,
|
||||
* shopTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts fehlt (schon aktiv, oder durch Kauf freigeschaltet,
|
||||
* dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt oder ist eine Angabe ungueltig (Datum vor der Fassung
|
||||
* oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird.
|
||||
*/
|
||||
async function textformFuerAktivierung({ body, customerId, key, doc }) {
|
||||
if (!TEXTFORM_KEYS.includes(key)) return null
|
||||
if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null
|
||||
const kunde = await customerOf(customerId)
|
||||
if (!textformVerlangt[key](kunde?.email)) return null
|
||||
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
|
||||
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) })
|
||||
}
|
||||
|
||||
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
|
||||
async function unterlagenAlsAnhang(key) {
|
||||
try {
|
||||
return await featureLegalAttachments(key, { ohne: ['widerruf'] })
|
||||
} catch (err) {
|
||||
console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message)
|
||||
const funktion = key === 'shop' ? 'Der Webshop' : 'Der Terminplaner'
|
||||
throw Object.assign(new Error(`Die Rechtsunterlagen (PDF) sind nicht lesbar. ${funktion} wurde nicht freigeschaltet. Bitte später erneut versuchen.`), { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet.
|
||||
// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert
|
||||
// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird
|
||||
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
|
||||
async function adminView(customerId) {
|
||||
const docs = await listCustomerFeatureDocs(customerId)
|
||||
const projects = await listCustomerFeatures(customerId, { docs })
|
||||
const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true })
|
||||
const abo = aboMemo()
|
||||
for (const p of projects) {
|
||||
for (const key of FEATURE_KEYS) {
|
||||
@@ -116,9 +173,36 @@ async function adminView(customerId) {
|
||||
return projects
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in
|
||||
* Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht,
|
||||
* links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung).
|
||||
*/
|
||||
async function termineFeld(customerId) {
|
||||
const kunde = await customerOf(customerId)
|
||||
const { lb, avv } = featureLegalPaths('termine')
|
||||
return {
|
||||
textformNoetig: termineTextformNoetig(kunde?.email),
|
||||
avvVersion: config.termine.avvVersion,
|
||||
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
|
||||
}
|
||||
}
|
||||
|
||||
/** Dasselbe fuer den Webshop (AVV Webshop, Vorbemerkung Abs. 2): Fassung = config.shop.fassung, Adressen der versionierten Unterlagen des Webshops. */
|
||||
async function shopFeld(customerId) {
|
||||
const kunde = await customerOf(customerId)
|
||||
const { lb, avv } = featureLegalPaths('shop')
|
||||
return {
|
||||
textformNoetig: shopTextformNoetig(kunde?.email),
|
||||
avvVersion: config.shop.fassung,
|
||||
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
|
||||
}
|
||||
}
|
||||
|
||||
funktionenAdminRouter.get('/:customerId', async (req, res) => {
|
||||
try {
|
||||
res.json({ projects: await adminView(req.params.customerId) })
|
||||
const projects = await adminView(req.params.customerId)
|
||||
res.json({ projects, termine: await termineFeld(req.params.customerId), shop: await shopFeld(req.params.customerId) })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
@@ -258,6 +342,7 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
let freigeschaltet = null // Terminplaner oder Webshop mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten' && action === 'activate') {
|
||||
await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
@@ -268,7 +353,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
|
||||
const attachments = textform ? await unterlagenAlsAnhang(key) : []
|
||||
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
|
||||
if (textform) freigeschaltet = { doc: aktiviert, attachments }
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
@@ -281,10 +369,18 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
|
||||
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
|
||||
let warnung = false
|
||||
if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) {
|
||||
warnung = true
|
||||
await notiereK16Fehlt(projectId, freigeschaltet.doc)
|
||||
}
|
||||
res.json({
|
||||
projects: (await adminView(customerId)).filter((p) => p.projectId === projectId),
|
||||
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
|
||||
...(ersatzTicket ? { ersatzTicket } : {}),
|
||||
...(warnung ? { warnung } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
router.post('/sync-repos', async (_req, res) => {
|
||||
try {
|
||||
@@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo
|
||||
const known = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', repoFullName),
|
||||
Query.limit(1),
|
||||
])
|
||||
if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
const readme = await fetchRepoReadme(repoFullName)
|
||||
if (!readme) {
|
||||
return res.json({ ok: true, found: false, repoFullName })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||
@@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js'
|
||||
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||||
const MODES = ['auto', 'build', 'dev', 'off']
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||
import { servicesCatalog } from '../../services/projectKinds.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
const CHUNK = 100
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
} from '../../services/giteaAdmin.js'
|
||||
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
||||
import { completeAssignment } from '../../services/projectKindAssign.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAppwriteStaff)
|
||||
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||
router.use(requireAppwriteTeam)
|
||||
|
||||
router.post('/create-from-template', async (req, res) => {
|
||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
|
||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
|
||||
|
||||
if (!repoName && !displayName) {
|
||||
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
|
||||
@@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => {
|
||||
customerId,
|
||||
ticketId,
|
||||
owner,
|
||||
kind,
|
||||
staffName: req.appwriteUser?.name || req.appwriteUser?.email || '',
|
||||
})
|
||||
return res.status(201).json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
@@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => {
|
||||
// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const { projectName, subdomain, projectType } = req.body || {}
|
||||
// Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar)
|
||||
if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) {
|
||||
return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' })
|
||||
}
|
||||
try {
|
||||
const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType })
|
||||
return res.json({ ok: true, ...result })
|
||||
|
||||
@@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => {
|
||||
|
||||
const projects = await listDocuments(config.collections.websiteProjects, queries)
|
||||
const project = projects[0]
|
||||
if (!project) {
|
||||
// Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus)
|
||||
if (!project || (project.archived && !admin)) {
|
||||
return res.json({ allowed: false, reason: 'no_project' })
|
||||
}
|
||||
if (!isPreviewProjectReady(project)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../services/siteFeatures.js'
|
||||
import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { shopFuerKunde } from '../services/shop/schalter.js'
|
||||
import { zeitenFuerKunde } from '../services/zeiten/schalter.js'
|
||||
import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js'
|
||||
import { starteBehalten } from '../services/zeiten/behalten.js'
|
||||
@@ -75,6 +76,7 @@ async function analyseView(customerId, projectId) {
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
legal: legalPaths(config.analyse.avvVersion),
|
||||
controller,
|
||||
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +235,9 @@ router.get('/', requireSession, async (req, res) => {
|
||||
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
|
||||
// mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers
|
||||
termine: await termineAngebot(req.session.email, customerId, abos),
|
||||
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
|
||||
// kommen aus projects[].features.shop und GET /api/shop/status
|
||||
shop: { verfuegbar: shopFuerKunde(req.session.email) },
|
||||
// Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit
|
||||
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
|
||||
zeiten: await zeitenAngebot(req.session.email, customerId, abos),
|
||||
@@ -304,7 +309,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
||||
}
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, projectId)
|
||||
await assertOwnProject(customerId, projectId, { forNewOrder: true })
|
||||
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
|
||||
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||
}
|
||||
@@ -365,7 +370,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||
projectId,
|
||||
key,
|
||||
sessionId: created.id,
|
||||
consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') },
|
||||
consent: { text: hakenTexte(feature), at: klickAt },
|
||||
})
|
||||
return created
|
||||
})
|
||||
@@ -468,6 +473,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
||||
}
|
||||
const { projectId, accentColor, consent } = req.body || {}
|
||||
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true })
|
||||
const { doc } = await activateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(projectId),
|
||||
|
||||
@@ -14,7 +14,8 @@ const router = Router()
|
||||
async function loadOwnProject(req, res, next) {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null)
|
||||
if (!project || !customerId || project.customerId !== customerId) {
|
||||
// Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste)
|
||||
if (!project || !customerId || project.customerId !== customerId || project.archived) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
}
|
||||
req.project = project
|
||||
@@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(500), // ohne Limit liefert Appwrite nur 25
|
||||
])
|
||||
|
||||
const sanitized = projects.map((p) => ({
|
||||
// Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04)
|
||||
const sanitized = projects.filter((p) => !p.archived).map((p) => ({
|
||||
id: p.$id,
|
||||
projectName: p.projectName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
import express, { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { analyseDomains } from '../services/analyse.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { bildMeta, bildPfad } from '../services/shop/bilder.js'
|
||||
import { abschlussAusstehend, bestellungLesen, ipHashVon, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js'
|
||||
import { sessionHolen } from '../services/shop/connect.js'
|
||||
import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js'
|
||||
import { euro } from '../services/shop/geld.js'
|
||||
import { berechne } from '../services/shop/preise.js'
|
||||
import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js'
|
||||
import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js'
|
||||
import { shopFuerKunde, shopModus } from '../services/shop/schalter.js'
|
||||
import { formToken, pruefeFormToken } from '../services/shop/token.js'
|
||||
import { eingangText, erfasseWiderruf } from '../services/shop/widerrufe.js'
|
||||
import { darfAnfrage, ipSchluessel, zaehle } from '../services/termine/limits.js'
|
||||
|
||||
/**
|
||||
* Oeffentliche Schnittstelle des Webshops: /api/public/shop/v1 (Plan 2026-10-05-webshop.md, Block F; Muster
|
||||
* routes/publicTermine.js). Ohne Session, ohne Captcha. Hier steht das Lesen (F1: Konfiguration samt Artikeln, Artikelbilder);
|
||||
* Bestellung, Status und Widerruf (F2/F3) kommen als POST/GET-Routen in dieselbe Datei und nutzen route(), schreibe() und die
|
||||
* Fehlerhelfer.
|
||||
*
|
||||
* - Der Router bringt seinen eigenen Parser (16 KB) und seine Fehlerbehandlung mit und haengt in server/index.js VOR dem
|
||||
* globalen express.json (2 MB). Antworten sind immer JSON { ... } oder { fehler: { <feld|_>: Text }, code? }; Rohtexte von
|
||||
* Fehlern verlassen den Server nie (unbekannter Fehler => 503 "Gerade nicht erreichbar").
|
||||
* - Antworten enthalten nie customerId, projectId, featureId, $id, Stripe-IDs oder E-Mail-Adressen ausser der oeffentlichen
|
||||
* Anbieter-Adresse des Haendlers.
|
||||
* - Unbekannter siteKey oder eine Funktion, die nicht active oder disabled ist: immer 200 { aktiv: false } (kein Orakel). Bestellen,
|
||||
* Pruefen, Status und Bilder zusaetzlich nur bei Modus on bzw. Pilotmodus mit Besitzer auf der Pilotliste. Eine abgeschaltete
|
||||
* Funktion (disabled), Modus off und ein Besitzer ausserhalb der Pilotliste antworten beim Lesen mit
|
||||
* { aktiv: true, offen: false, nurWiderruf: true, ... }, und POST /widerruf (samt Preflight) geht weiter: die Widerrufsfunktion
|
||||
* ist gesetzliche Pflicht (§ 356a BGB), solange es Bestellungen geben kann.
|
||||
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
|
||||
* (config.publicBaseUrl) und gleicher Herkunft (Host-Header), Origin in kanonischer Schreibweise; sonst 403. Meldungen ohne
|
||||
* Daten (429, 503, aktiv:false) tragen `*`, bei geprueftem POST die Origin.
|
||||
* - Grenzen: je IP 120 je Minute (darfAnfrage), je Shop 600 je Minute (Schluessel 'shop:<siteKey>'); die Artikelbilder haben
|
||||
* eigene, groessere Zaehler, damit eine Seite mit vielen Bildern die Konfiguration nicht aussperrt.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
|
||||
const BODY_MAX = 16 * 1024
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const SHOP_JE_MINUTE = 600
|
||||
const BILD_JE_IP = 600
|
||||
const BILD_JE_SHOP = 6000
|
||||
const BILD_RE = /^([a-z0-9-]{1,64})-(s|l)\.webp$/
|
||||
const MIN_ANTWORT_STANDARD_MS = 300
|
||||
const LAENDER_NAMEN = { DE: 'Deutschland', AT: 'Österreich' }
|
||||
const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit']
|
||||
|
||||
const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } }
|
||||
const NICHT_GEFUNDEN = { fehler: { _: 'Nicht gefunden' } }
|
||||
const ZU_GROSS = { fehler: { _: 'Die Anfrage ist zu groß.' } }
|
||||
const UNGUELTIG = { fehler: { _: 'Ungültige Anfrage.' } }
|
||||
const MELDUNG_ZU_VIELE = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.'
|
||||
|
||||
// Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter).
|
||||
let uhr = () => new Date()
|
||||
export function setzeUhrFuerTests(neueUhr) {
|
||||
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
|
||||
}
|
||||
|
||||
/** Besitzer-Adressen fuer den Pilotmodus je ctx (ein ctx aus shopPerSiteKey lebt 30 Sekunden): ctx -> Promise<E-Mail|''>. */
|
||||
const besitzer = new WeakMap()
|
||||
export function resetPublicShopForTests() {
|
||||
uhr = () => new Date()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Antworten und Fehler
|
||||
|
||||
/** Fehler mit Statuscode fuer den Besucher (4xx); `fehlerErgebnis` macht daraus die Antwort. */
|
||||
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra })
|
||||
const zuViele = (retryAfterS) => fehlerMit(429, { _: MELDUNG_ZU_VIELE }, { retryAfter: retryAfterS })
|
||||
const nichtGefunden = () => fehlerMit(404, NICHT_GEFUNDEN.fehler)
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const nurTexte = (wert) => (istObjekt(wert) ? Object.fromEntries(Object.entries(wert).filter(([, t]) => typeof t === 'string')) : {})
|
||||
|
||||
/** Antwort fuer einen Fehler: Fehler des Besuchers (4xx mit `fehler`) wie sie sind, alles andere 503 ohne Rohtext. */
|
||||
function fehlerErgebnis(err) {
|
||||
const status = err?.status
|
||||
const fehler = nurTexte(err?.fehler)
|
||||
if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) {
|
||||
const body = { fehler }
|
||||
if (typeof err.code === 'string') body.code = err.code
|
||||
if (typeof err.produktId === 'string') body.produktId = err.produktId
|
||||
return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
|
||||
}
|
||||
console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
return { status: 503, body: NICHT_ERREICHBAR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine Antwort { status, body, typ, cache, headers }; ohne ETag, immer mit nosniff. cors = { erlaubt: Origin|null }
|
||||
* (fuer POST, F2/F3). GET und HEAD tragen immer `*`; Meldungen ohne Daten (429, 503, aktiv:false) auch bei POST.
|
||||
*/
|
||||
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
|
||||
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
|
||||
const lesen = req.method === 'GET' || req.method === 'HEAD'
|
||||
if (lesen) res.set('Access-Control-Allow-Origin', '*')
|
||||
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
|
||||
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*')
|
||||
res.status(status)
|
||||
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
|
||||
if (status === 204) return res.end()
|
||||
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
|
||||
return res.end(inhalt)
|
||||
}
|
||||
|
||||
/** Sendet eine Datei (Ergebnis { datei: absoluter Pfad }); fehlt sie, antwortet 404 wie sonst. */
|
||||
function sendeDatei(req, res, ergebnis) {
|
||||
return new Promise((resolve) => {
|
||||
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' })
|
||||
res.sendFile(
|
||||
ergebnis.datei,
|
||||
{ cacheControl: false, dotfiles: 'deny', headers: { 'Cache-Control': ergebnis.cache, 'X-Content-Type-Options': 'nosniff' } },
|
||||
(err) => {
|
||||
if (err && !res.headersSent) {
|
||||
for (const kopf of ['Cache-Control', 'Content-Type', 'Content-Length', 'Cross-Origin-Resource-Policy']) res.removeHeader(kopf)
|
||||
schreibe(req, res, { status: 404, body: NICHT_GEFUNDEN })
|
||||
} else if (err) res.destroy()
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Letzte Absicherung, wenn schon das Schreiben der Antwort scheitert: schlichtes 503, sofern noch nichts gesendet wurde. */
|
||||
function notantwort(res, err) {
|
||||
try {
|
||||
console.error(`[publicShop] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
} catch {
|
||||
// das Log darf die Notantwort nicht verhindern
|
||||
}
|
||||
try {
|
||||
if (res.headersSent) return void res.end()
|
||||
const inhalt = JSON.stringify(NICHT_ERREICHBAR)
|
||||
res.statusCode = 503
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('Content-Length', String(inhalt.length)) // nur ASCII
|
||||
return void res.end(inhalt)
|
||||
} catch {
|
||||
try {
|
||||
res.destroy()
|
||||
} catch {
|
||||
// nichts mehr zu tun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schreibeSicher(req, res, ergebnis, cors) {
|
||||
try {
|
||||
schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
notantwort(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
/** { aktiv: false }: dieselbe Antwort fuer unbekannte, inaktive und abgeschaltete Shops (GET kurz im Browser-Cache). */
|
||||
const aktivFalse = (req) => ({ body: { aktiv: false }, cache: req.method === 'GET' ? 'public, max-age=15' : 'no-store' })
|
||||
|
||||
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const minAntwortMs = () => (Number.isFinite(config.shop?.minAntwortMs) && config.shop.minAntwortMs >= 0 ? config.shop.minAntwortMs : MIN_ANTWORT_STANDARD_MS)
|
||||
|
||||
/**
|
||||
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe
|
||||
* (oder { datei, cache } fuer sendeDatei) oder wirft; cors.erlaubt setzt fn, sobald die Herkunft geprueft ist (F2/F3).
|
||||
* Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs (ab Beginn der Anfrage).
|
||||
*/
|
||||
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
|
||||
try {
|
||||
const cors = { erlaubt: null }
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await fn(req, uhr(), cors)
|
||||
} catch (err) {
|
||||
ergebnis = fehlerErgebnis(err)
|
||||
}
|
||||
if (mindestdauer) {
|
||||
const rest = minAntwortMs() - (performance.now() - req.shopStart)
|
||||
if (rest > 0) await warte(rest)
|
||||
}
|
||||
if (ergebnis.datei) await sendeDatei(req, res, ergebnis)
|
||||
else schreibe(req, res, ergebnis, cors)
|
||||
} catch (err) {
|
||||
notantwort(res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
|
||||
|
||||
router.use((req, res, next) => {
|
||||
req.shopStart = performance.now()
|
||||
// Zu grosse Rumpfe gar nicht erst lesen; die Verbindung wird danach geschlossen (der Rumpf bleibt ungelesen).
|
||||
if (Number(req.get('content-length')) > BODY_MAX) {
|
||||
res.set('Connection', 'close')
|
||||
return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
router.use(express.json({ limit: BODY_MAX }))
|
||||
|
||||
// ---------------------------------------------------------------- Zugang
|
||||
|
||||
/** E-Mail-Adresse des Shop-Besitzers (leer, wenn der Kunde fehlt); je ctx einmal geladen. */
|
||||
function besitzerEmail(ctx) {
|
||||
let gemerkt = besitzer.get(ctx)
|
||||
if (!gemerkt) {
|
||||
gemerkt = getDocument(config.collections.customers, ctx.shop.customerId).then(
|
||||
(kunde) => String(kunde?.email ?? ''),
|
||||
(err) => {
|
||||
if (err?.status === 404) return ''
|
||||
besitzer.delete(ctx) // Fehler nicht merken
|
||||
throw err
|
||||
},
|
||||
)
|
||||
besitzer.set(ctx, gemerkt)
|
||||
}
|
||||
return gemerkt
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Kontext des Shops, wenn er ueber die Schnittstelle erreichbar ist; sonst null. Modus off => null ohne jede Abfrage;
|
||||
* im Pilotmodus muss der Besitzer auf der Pilotliste stehen (SHOP_PILOT_EMAILS).
|
||||
*/
|
||||
async function erreichbarerShop(siteKey, now) {
|
||||
const modus = shopModus()
|
||||
if (modus === 'off') return null
|
||||
const ctx = await shopPerSiteKey(siteKey, { now })
|
||||
if (!ctx) return null
|
||||
if (modus === 'pilot' && !shopFuerKunde(await besitzerEmail(ctx))) return null
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugang fuer die Widerrufsfunktion (§ 356a BGB): sie bleibt erreichbar, solange die Funktion active oder disabled ist, auch bei
|
||||
* SHOP_ENABLED=off und wenn der Besitzer nicht (mehr) zum Pilotkreis gehoert; die gesetzliche Pflicht laeuft mit den Bestellungen
|
||||
* weiter. Bestellen bleibt gesperrt (`bestellbar`). Bei off wird die Pilotliste gar nicht erst gelesen.
|
||||
* @returns {Promise<{ ctx: object|null, bestellbar: boolean }>}
|
||||
*/
|
||||
async function widerrufZugang(siteKey, now) {
|
||||
const ctx = await shopPerSiteKey(siteKey, { now })
|
||||
if (!ctx) return { ctx: null, bestellbar: false }
|
||||
const modus = shopModus()
|
||||
const bestellbar = modus === 'on' || (modus === 'pilot' && shopFuerKunde(await besitzerEmail(ctx)))
|
||||
return { ctx, bestellbar }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Konfiguration
|
||||
|
||||
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
|
||||
const basisUrl = () => String(config.publicBaseUrl ?? '').replace(/\/+$/, '')
|
||||
|
||||
function anbieterOeffentlich(a) {
|
||||
return { name: a.name, strasse: a.strasse, plz: a.plz, ort: a.ort, land: a.land, email: a.email, telefon: a.telefon }
|
||||
}
|
||||
|
||||
/** Kurzer Versandtext, z. B. 'Versand nach Deutschland 4,90 €, ab 50,00 € versandkostenfrei'. */
|
||||
function versandText(e) {
|
||||
if (!e.versand.aktiv) return e.abholung.aktiv ? 'Nur Abholung' : ''
|
||||
const laender = (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => {
|
||||
const name = LAENDER_NAMEN[code] ?? code
|
||||
const kosten = e.versand.kostenCent?.[code]
|
||||
return Number.isInteger(kosten) ? `${name} ${euro(kosten)}` : name
|
||||
})
|
||||
let t = laender.length > 0 ? `Versand nach ${laender.join(', ')}` : 'Versand'
|
||||
if (Number.isInteger(e.versand.freiAbCent)) t += `, ab ${euro(e.versand.freiAbCent)} versandkostenfrei`
|
||||
return t
|
||||
}
|
||||
|
||||
function steuerHinweisKurz(e) {
|
||||
return e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : 'Alle Preise inkl. MwSt.'
|
||||
}
|
||||
|
||||
/** Der Teil `shop` der Antwort (Anzeige- und Kasseninformationen des Haendlers). */
|
||||
function shopOeffentlich(e) {
|
||||
const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : []
|
||||
return {
|
||||
name: e.shopname,
|
||||
akzent: e.akzent,
|
||||
anbieter: anbieterOeffentlich(e.anbieter),
|
||||
lieferzeit: e.lieferzeit,
|
||||
lieferlaender: e.versand.aktiv ? (Array.isArray(e.versand.laender) ? e.versand.laender : []).map((code) => ({ code, name: LAENDER_NAMEN[code] ?? code })) : [],
|
||||
abholung: { aktiv: e.abholung.aktiv === true, adresse: e.abholung.adresse, hinweis: e.abholung.hinweis },
|
||||
zahlungsarten: zahlungsartenText(e),
|
||||
zahlungsartenListe: ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a)).map((a) => zahlungsartenText({ zahlungsarten: [a] })),
|
||||
versandText: versandText(e),
|
||||
mindestbestellText: Number.isInteger(e.mindestbestellCent) ? `Mindestbestellwert ${euro(e.mindestbestellCent)}` : '',
|
||||
kleinunternehmer: e.kleinunternehmer === true,
|
||||
steuerHinweisKurz: steuerHinweisKurz(e),
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bestaetigten Rechtstexte (Fassung, die Kaeufer sehen) oder null, solange keine Fassung bestaetigt ist. */
|
||||
function rechtstexteOeffentlich(ctx) {
|
||||
const t = oeffentlicheRechtstexte(ctx)
|
||||
if (!t) return null
|
||||
return {
|
||||
agb: t.agb,
|
||||
widerrufsbelehrung: t.widerrufsbelehrung,
|
||||
widerrufsformular: t.widerrufsformular,
|
||||
versandUndZahlung: versandUndZahlung(ctx.shop.einstellungen),
|
||||
impressumUrl: t.impressumUrl,
|
||||
datenschutzUrl: t.datenschutzUrl,
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:siteKey',
|
||||
route(async (req, now) => {
|
||||
const { siteKey } = req.params
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
const { ctx, bestellbar } = await widerrufZugang(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
if (!zaehle(`shop:${siteKey}`, SHOP_JE_MINUTE, MINUTE_MS, now)) throw zuViele(60)
|
||||
|
||||
const e = ctx.shop.einstellungen
|
||||
const widerrufUrl = `${basisUrl()}/shop/${siteKey}/widerruf`
|
||||
const token = formToken(siteKey, now)
|
||||
|
||||
// Abgeschaltet (Funktion disabled, SHOP_ENABLED=off oder ausserhalb des Pilotkreises): nichts mehr zu kaufen, aber der Widerruf bleibt erreichbar
|
||||
if (ctx.doc.state === 'disabled' || !bestellbar) {
|
||||
return {
|
||||
body: {
|
||||
aktiv: true,
|
||||
offen: false,
|
||||
nurWiderruf: true,
|
||||
shop: { name: e.shopname, akzent: e.akzent, anbieter: anbieterOeffentlich(e.anbieter) },
|
||||
widerrufUrl,
|
||||
formToken: token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const dokumente = await listeProdukte(ctx.shop.id)
|
||||
const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: dokumente }), reserviert(ctx.shop.id, now)])
|
||||
const produkte = []
|
||||
for (const doc of dokumente) {
|
||||
if (doc.aktiv === false) continue
|
||||
const p = produktOeffentlich(doc, { einstellungen: e, reserviert: belegt, siteKey })
|
||||
if (p) produkte.push(p)
|
||||
}
|
||||
const kategorien = [...new Set(produkte.map((p) => text(p.kategorie)).filter(Boolean))]
|
||||
return {
|
||||
body: {
|
||||
aktiv: true,
|
||||
offen,
|
||||
shop: shopOeffentlich(e),
|
||||
kategorien,
|
||||
produkte,
|
||||
rechtstexte: rechtstexteOeffentlich(ctx),
|
||||
gewaehrleistung: { bild: `${basisUrl()}${GEWAEHRLEISTUNG_BILD}`, text: gewaehrleistungsHinweis() },
|
||||
widerrufUrl,
|
||||
formToken: token,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Herkunft, POST-Hilfen
|
||||
|
||||
const MELDUNG_NEU_LADEN = 'Bitte laden Sie die Seite neu.'
|
||||
const nichtErlaubt = () => fehlerMit(403, { _: 'Nicht erlaubt' })
|
||||
/** POST-Routen, deren Preflight dieselbe Pruefung bekommt (F3 widerruf; F2 pruefen, bestellung). */
|
||||
const POST_ROUTEN = new Set(['widerruf', 'pruefen', 'bestellung'])
|
||||
|
||||
/**
|
||||
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project) (Seiten des Betriebs), Host von
|
||||
* config.publicBaseUrl (Seiten des Portals) oder gleiche Herkunft wie die Anfrage (Host-Header). Sonst null (fehlende, fremde,
|
||||
* unlesbare Origin oder nicht in kanonischer Schreibweise).
|
||||
*/
|
||||
function erlaubteHerkunft(req, project) {
|
||||
const origin = req.get('origin')
|
||||
if (typeof origin !== 'string' || origin === '') return null
|
||||
let url
|
||||
try {
|
||||
url = new URL(origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
|
||||
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
|
||||
try {
|
||||
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
|
||||
} catch {
|
||||
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
|
||||
}
|
||||
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
|
||||
return erlaubt.has(url.host) ? origin : null
|
||||
}
|
||||
|
||||
/** Prueft die Herkunft eines POST und merkt sie in cors; sonst 403. */
|
||||
function pruefeHerkunft(req, cors, project) {
|
||||
const origin = erlaubteHerkunft(req, project)
|
||||
if (!origin) throw nichtErlaubt()
|
||||
cors.erlaubt = origin
|
||||
}
|
||||
|
||||
/** Antwort auf einen Preflight (204): erlaubte Methoden, Content-Type, 24 Stunden gueltig. */
|
||||
const vorabantwort = (origin) => ({
|
||||
status: 204,
|
||||
body: '',
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
Vary: 'Origin',
|
||||
},
|
||||
})
|
||||
|
||||
// Preflight unter /:siteKey/<POST-Route>: erlaubt fuer Herkunft des Betriebs, des Portals und gleiche Herkunft; ohne
|
||||
// erreichbaren Shop (unbekannt, off, Pilot) gibt es nichts zu pruefen und nichts zu verraten.
|
||||
router.options(
|
||||
'/:siteKey/:was',
|
||||
route(async (req, now, cors) => {
|
||||
if (!POST_ROUTEN.has(req.params.was)) throw nichtGefunden()
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
// Widerruf: auch ohne Bestellmodus erreichbar (siehe widerrufZugang); alle anderen Routen nur bei erreichbarem Shop
|
||||
const ctx = req.params.was === 'widerruf' ? (await widerrufZugang(req.params.siteKey, now)).ctx : await erreichbarerShop(req.params.siteKey, now)
|
||||
if (!ctx) return vorabantwort('*')
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
return vorabantwort(cors.erlaubt)
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Widerruf
|
||||
|
||||
/**
|
||||
* Honigtopf `kontakt_url` (jeder Wert ausser leer) und Formular-Token: Honigtopf oder zu schnell abgeschickt => dieselbe
|
||||
* Erfolgsantwort wie sonst, gespeichert wird nichts; fehlendes, fremdes oder altes Token => 400 "Seite neu laden".
|
||||
* @returns {boolean} true, wenn nur so getan werden soll (Falle)
|
||||
*/
|
||||
function istFalle(siteKey, b, now) {
|
||||
const honig = b.kontakt_url
|
||||
if (honig !== undefined && honig !== null && !(typeof honig === 'string' && honig.trim() === '')) return true
|
||||
const token = pruefeFormToken(siteKey, b.formToken, now)
|
||||
if (token === 'zu_schnell') return true
|
||||
if (token !== 'ok') throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
||||
return false
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/:siteKey/widerruf',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
const { siteKey } = req.params
|
||||
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
|
||||
const { ctx } = await widerrufZugang(siteKey, now) // active und disabled, unabhaengig von SHOP_ENABLED und Pilotliste: der Widerruf bleibt moeglich
|
||||
if (!ctx) return aktivFalse(req)
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
||||
if (istFalle(siteKey, b, now)) return { body: { ok: true, eingang: eingangText(now) } }
|
||||
|
||||
let ergebnis
|
||||
try {
|
||||
ergebnis = await erfasseWiderruf(ctx, { name: b.name, email: b.email, bestellnummer: b.bestellnummer, nachricht: b.nachricht }, { now, ip: req.ip })
|
||||
} catch (err) {
|
||||
if (err?.status === 429) throw zuViele(3600)
|
||||
throw err
|
||||
}
|
||||
return { body: { ok: true, eingang: ergebnis.eingang } }
|
||||
},
|
||||
{ mindestdauer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Pruefen, Bestellung, Status (F2)
|
||||
|
||||
const PRUEFEN_JE_IP = 60
|
||||
const BESTELLUNG_JE_IP = 10
|
||||
const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS
|
||||
const BESTELLUNG_JE_SHOP = 300
|
||||
const STATUS_JE_IP = 120
|
||||
const STUNDE_MS = 60 * MINUTE_MS
|
||||
const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/
|
||||
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
|
||||
const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' })
|
||||
// Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code)
|
||||
const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'mindestbetrag', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich'])
|
||||
const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung'])
|
||||
|
||||
/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */
|
||||
function produktKarte(docs) {
|
||||
const karte = new Map()
|
||||
for (const doc of docs) {
|
||||
let p
|
||||
try {
|
||||
p = JSON.parse(doc.produkt)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (istObjekt(p)) karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false })
|
||||
}
|
||||
return karte
|
||||
}
|
||||
|
||||
/** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */
|
||||
function bestellFehler(err) {
|
||||
if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) {
|
||||
const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}), ...(Number.isInteger(err.retryAfter) ? { retryAfter: err.retryAfter } : {}) }
|
||||
if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra)
|
||||
return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true })
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 5xx mit Kaeufertext (zahlung_nicht_moeglich): fehlerErgebnis kennt nur 4xx, also hier eigene Abbildung
|
||||
const bestellErgebnis = (err) => {
|
||||
const e = bestellFehler(err)
|
||||
if (e?.dienst) return { status: 503, body: { fehler: e.fehler, code: e.code } }
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Eingabe-Objekt des Kaeufers: nur positionen, lieferart und land (Preise, Namen u. a. werden nie gelesen). */
|
||||
const warenkorbEingabe = (b) => ({ positionen: b.positionen, lieferart: b.lieferart, land: b.land })
|
||||
|
||||
router.post(
|
||||
'/:siteKey/pruefen',
|
||||
route(async (req, now, cors) => {
|
||||
const { siteKey } = req.params
|
||||
if (!zaehle(`ip:shop-pruefen:${ipSchluessel(req.ip)}`, PRUEFEN_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const ctx = await erreichbarerShop(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw fehlerMit(400, UNGUELTIG.fehler)
|
||||
|
||||
const docs = await listeProdukte(ctx.shop.id)
|
||||
const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: docs }), reserviert(ctx.shop.id, now)])
|
||||
if (!offen) throw GESCHLOSSEN()
|
||||
const eingabe = warenkorbEingabe(b)
|
||||
const ergebnis = berechne({ produkte: produktKarte(docs), ...eingabe, einstellungen: ctx.shop.einstellungen, reserviert: belegt })
|
||||
if (!ergebnis.ok) {
|
||||
const { code, text, produktId } = ergebnis.fehler
|
||||
throw fehlerMit(KONFLIKT_CODES.has(code) ? 409 : 400, { _: text }, { code, ...(produktId ? { produktId } : {}) })
|
||||
}
|
||||
const widerrufTexte = new Map()
|
||||
for (const doc of docs) widerrufTexte.set(doc.$id, produktOeffentlich(doc, { einstellungen: ctx.shop.einstellungen, siteKey })?.widerrufText ?? '')
|
||||
return {
|
||||
body: {
|
||||
ok: true,
|
||||
positionen: ergebnis.positionen.map((p) => ({
|
||||
produktId: p.produktId,
|
||||
varianteId: p.varianteId,
|
||||
name: p.name,
|
||||
varianteText: p.varianteText,
|
||||
menge: p.menge,
|
||||
einzelText: p.einzelText,
|
||||
summeText: p.summeText,
|
||||
widerrufText: widerrufTexte.get(p.produktId) ?? '',
|
||||
})),
|
||||
texte: ergebnis.texte,
|
||||
lieferart: ergebnis.lieferart,
|
||||
land: ergebnis.land,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:siteKey/bestellung',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
const { siteKey } = req.params
|
||||
if (!zaehle(`ip:shop-bestellung:${ipSchluessel(req.ip)}`, BESTELLUNG_JE_IP, BESTELLUNG_FENSTER_MS, now)) throw zuViele(600)
|
||||
const ctx = await erreichbarerShop(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
if (!zaehle(`shop-bestellung:${siteKey}`, BESTELLUNG_JE_SHOP, STUNDE_MS, now)) throw zuViele(3600)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
||||
if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } }
|
||||
|
||||
try {
|
||||
const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now, ipHash: ipHashVon(req.ip) })
|
||||
return { body: { url } }
|
||||
} catch (err) {
|
||||
return bestellErgebnis(err)
|
||||
}
|
||||
},
|
||||
{ mindestdauer: true },
|
||||
),
|
||||
)
|
||||
|
||||
/** E-Mail-Adresse verdeckt: erstes Zeichen, ***, Domain ('m***@example.com'); leer, wenn es keine gibt. */
|
||||
function maskiereEmail(email) {
|
||||
const t = typeof email === 'string' ? email.trim() : ''
|
||||
const at = t.lastIndexOf('@')
|
||||
if (at < 1 || at === t.length - 1) return ''
|
||||
return `${t[0]}***@${t.slice(at + 1)}`
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:siteKey/bestellung/:id',
|
||||
route(async (req, now) => {
|
||||
const { siteKey, id } = req.params
|
||||
if (!zaehle(`ip:shop-status:${ipSchluessel(req.ip)}`, STATUS_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const keine = () => fehlerMit(404, { _: 'Bestellung nicht gefunden.' }) // fuer jeden Fehlerfall dieselbe Antwort
|
||||
const t = typeof req.query.t === 'string' ? req.query.t : ''
|
||||
if (!BESTELL_ID_RE.test(id) || !BESTELL_TOKEN_RE.test(t)) throw keine()
|
||||
const ctx = await erreichbarerShop(siteKey, now) // active und disabled
|
||||
if (!ctx) throw keine()
|
||||
let order = await bestellungLesen(id)
|
||||
if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine()
|
||||
|
||||
// Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers); bezahlt, aber mit
|
||||
// fehlendem Bestandsabzug oder fehlenden Mails (Teilfehler): schliesseAb holt die Schritte nach
|
||||
const accountId = ctx.shop.stripeAccountId
|
||||
if ((order.status === 'offen' || abschlussAusstehend(order)) && order.stripeSessionId && accountId) {
|
||||
try {
|
||||
const session = await sessionHolen(accountId, order.stripeSessionId)
|
||||
if (session?.status === 'complete' && session.payment_status === 'paid') {
|
||||
await schliesseAb({ accountId, session, now })
|
||||
order = (await bestellungLesen(id)) ?? order
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[publicShop] Bestellstatus: Stripe-Abgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
const gesamt = order.daten?.summen?.texte?.gesamt
|
||||
return {
|
||||
body: {
|
||||
status: String(order.status ?? ''),
|
||||
bestellnummer: String(order.bestellnummer ?? ''),
|
||||
gesamtText: typeof gesamt === 'string' && gesamt !== '' ? gesamt : euro(Number.isInteger(order.summeCent) ? order.summeCent : 0),
|
||||
lieferart: order.daten?.lieferart === 'abholung' ? 'abholung' : 'versand',
|
||||
email: maskiereEmail(order.kaeuferEmail),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Artikelbilder
|
||||
|
||||
router.get(
|
||||
'/:siteKey/bild/:datei',
|
||||
route(async (req, now) => {
|
||||
const { siteKey, datei } = req.params
|
||||
const ip = ipSchluessel(req.ip)
|
||||
if (!zaehle(`ip:shop-bild:${ip}`, BILD_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const format = BILD_RE.exec(datei)
|
||||
const ctx = format ? await erreichbarerShop(siteKey, now) : null
|
||||
if (!ctx || ctx.doc.state !== 'active') throw nichtGefunden() // ohne Hinweis, was nicht stimmt
|
||||
if (!zaehle(`shop-bild:${siteKey}`, BILD_JE_SHOP, MINUTE_MS, now)) throw zuViele(60)
|
||||
|
||||
// Das Bild muss zu einem aktiven Artikel dieses Shops gehoeren
|
||||
const [, bildId, groesse] = format
|
||||
const gehoert = (await listeProdukte(ctx.shop.id)).some((doc) => doc.aktiv !== false && (parseProdukt(doc)?.bilder ?? []).includes(bildId))
|
||||
const pfad = gehoert && (await bildMeta(siteKey, bildId)) ? bildPfad(siteKey, bildId, groesse) : null
|
||||
if (!pfad) throw nichtGefunden()
|
||||
return { datei: pfad, cache: 'public, max-age=86400' }
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Rest: unbekannte Pfade, Fehler des Parsers
|
||||
|
||||
router.use((req, res) => schreibeSicher(req, res, { status: 404, body: NICHT_GEFUNDEN }))
|
||||
|
||||
// Fehlerbehandlung des Routers (Parser): 413 zu gross, 400 kaputt oder nicht lesbar, sonst 503; nie der Text des Fehlers
|
||||
router.use((err, req, res, _next) => {
|
||||
if (err?.type === 'entity.too.large' || err?.status === 413) return schreibeSicher(req, res, { status: 413, body: ZU_GROSS })
|
||||
if (Number.isInteger(err?.status) && err.status >= 400 && err.status < 500) return schreibeSicher(req, res, { status: 400, body: UNGUELTIG })
|
||||
return schreibeSicher(req, res, fehlerErgebnis(err))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,653 @@
|
||||
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 { 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.',
|
||||
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),
|
||||
// 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). */
|
||||
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
|
||||
/** 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
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Router } from 'express'
|
||||
|
||||
/**
|
||||
* Oeffentliche Standalone-Seiten des Webshops (Plan 2026-10-05-webshop.md, H2; Spec 5.4):
|
||||
* GET /shop/:siteKey Shop mit dem Widget public/embed/shop.js
|
||||
* GET /shop/:siteKey/widerruf dieselbe Seite, das Widget oeffnet gleich die Widerrufsfunktion
|
||||
* Die Seite verraet nicht, ob der Schluessel existiert oder der Shop aktiv ist: jeder syntaktisch gueltige Schluessel
|
||||
* bekommt dieselbe Seite, ob der Shop laeuft entscheidet das Widget ueber die oeffentliche Schnittstelle. Alles andere ist 404.
|
||||
*/
|
||||
|
||||
const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
||||
const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'shop-seite.html')
|
||||
const CSP = [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'", // das Widget legt ein <style>-Element an
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
].join('; ')
|
||||
|
||||
let vorlage = null
|
||||
export function resetShopSeiteForTests() {
|
||||
vorlage = null
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function seite(res, next, siteKey, ansicht) {
|
||||
if (!SITE_KEY_RE.test(siteKey)) return res.status(404).type('text/plain').send('Not found')
|
||||
try {
|
||||
if (vorlage === null) vorlage = readFileSync(DATEI, 'utf8')
|
||||
} catch (err) {
|
||||
return next(err)
|
||||
}
|
||||
const html = vorlage.replace('__SITEKEY__', escapeAttr(siteKey)).replace('__ANSICHT__', escapeAttr(ansicht))
|
||||
res.set({
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': CSP,
|
||||
'X-Robots-Tag': 'noindex',
|
||||
})
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
|
||||
export function shopSeiteRouter() {
|
||||
const router = Router()
|
||||
router.get('/shop/:siteKey', (req, res, next) => seite(res, next, req.params.siteKey, ''))
|
||||
router.get('/shop/:siteKey/widerruf', (req, res, next) => seite(res, next, req.params.siteKey, 'widerruf'))
|
||||
return router
|
||||
}
|
||||
|
||||
export default shopSeiteRouter
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { withLocks } from '../services/keyedLock.js'
|
||||
import { connectEreignis, statusAusKonto, statusMitHinweis } from '../services/shop/connect.js'
|
||||
import { meldeEingang, schliesseAb, verwirf } from '../services/shop/bestellungen.js'
|
||||
import { bucheStripeErstattung } from '../services/shop/erstattungen.js'
|
||||
import { ladeShop, leereCache } from '../services/shop/kontext.js'
|
||||
|
||||
/**
|
||||
* Stripe-Connect-Webhook des Webshops: POST /api/shop/stripe-webhook (Plan 2026-10-05-webshop.md, Task D3).
|
||||
* Ohne Sitzung und ohne CORS; die Signatur ist die Anmeldung. Gemountet vor dem /api/shop-Router, nutzt req.rawBody des globalen
|
||||
* JSON-Parsers. Alles idempotent (Stripe wiederholt): unbekannte Konten und fremde Sitzungen ⇒ 200 ohne Wirkung, Fehler ⇒ 500.
|
||||
*/
|
||||
|
||||
const router = Router()
|
||||
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/** account.updated: Bereitschaftsstatus aller Shops mit diesem Konto neu speichern und ihren Cache leeren. */
|
||||
async function kontoAktualisiert(accountId, konto, now) {
|
||||
if (!ACCOUNT_RE.test(accountId || '')) return
|
||||
const documents = await listDocuments(config.collections.shopConfig, [Query.equal('stripeAccountId', accountId), Query.limit(25)])
|
||||
for (const doc of documents) {
|
||||
await withLocks([`shop-stripe:${doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.shopConfig, doc.$id)
|
||||
if (frisch.stripeAccountId !== accountId) return
|
||||
const shop = ladeShop(frisch)
|
||||
const status = statusAusKonto(konto, now)
|
||||
const neu = statusMitHinweis(shop.stripeStatus, status, shop.einstellungen?.zahlungsarten)
|
||||
await updateDocument(config.collections.shopConfig, doc.$id, { stripeStatus: JSON.stringify(neu) })
|
||||
})
|
||||
leereCache(doc.siteKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function verarbeite(event, now) {
|
||||
const objekt = event.data?.object
|
||||
if (!istObjekt(objekt)) return
|
||||
const accountId = typeof event.account === 'string' ? event.account : undefined
|
||||
|
||||
if (event.type === 'account.updated') {
|
||||
await kontoAktualisiert(accountId || objekt.id, objekt, now)
|
||||
return
|
||||
}
|
||||
if (!accountId) return
|
||||
|
||||
if (event.type === 'charge.refunded') {
|
||||
await bucheStripeErstattung({ accountId, charge: objekt, now })
|
||||
return
|
||||
}
|
||||
|
||||
// Ab hier Checkout-Sitzungen; nur die des Webshops
|
||||
if (objekt.metadata?.wkShop !== '1') return
|
||||
const orderId = objekt.metadata.orderId
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
if (objekt.payment_status === 'paid') await schliesseAb({ accountId, session: objekt, now })
|
||||
else await meldeEingang({ accountId, session: objekt, now })
|
||||
break
|
||||
case 'checkout.session.async_payment_succeeded':
|
||||
await schliesseAb({ accountId, session: objekt, now })
|
||||
break
|
||||
case 'checkout.session.async_payment_failed':
|
||||
await verwirf(orderId, 'zahlung_fehlgeschlagen', { now, mail: true, email: objekt.customer_details?.email, accountId })
|
||||
break
|
||||
case 'checkout.session.expired':
|
||||
await verwirf(orderId, 'abgelaufen', { now, accountId })
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
let event
|
||||
try {
|
||||
event = connectEreignis(req.rawBody, req.get('stripe-signature'))
|
||||
} catch (err) {
|
||||
return res.status(err?.status === 503 ? 503 : 400).json({ error: err?.status === 503 ? 'Webhook nicht eingerichtet.' : 'Ungültige Signatur.' })
|
||||
}
|
||||
if (!event) return res.json({ ignoriert: true })
|
||||
try {
|
||||
await verarbeite(event, new Date())
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error(`[shop] Webhook ${String(event.type).slice(0, 60)} (${String(event.id).slice(0, 40)}) fehlgeschlagen: ${err?.name}`)
|
||||
return res.status(500).json({ error: 'Verarbeitung fehlgeschlagen.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js'
|
||||
import customersRoutes from './customers.js'
|
||||
import filesRoutes from './files.js'
|
||||
import previewRoutes from './preview.js'
|
||||
import shopTeamRoutes from './shop.js'
|
||||
import termineTeamRoutes from './termine.js'
|
||||
|
||||
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
|
||||
@@ -25,5 +26,7 @@ router.use('/storage-tasks', storageTasksAdminRouter)
|
||||
router.use('/funktionen', funktionenAdminRouter)
|
||||
// Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob)
|
||||
router.use('/termine', termineTeamRoutes)
|
||||
// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage
|
||||
router.use('/shop', shopTeamRoutes)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js'
|
||||
import { anfrageAnsicht, entscheideAnfrage, leseShopConfig } from '../../services/shop/anfrage.js'
|
||||
import { listCustomerFeatureDocs } from '../../services/siteFeatures.js'
|
||||
|
||||
/**
|
||||
* Webshop im Ticketsystem (Kunden-Reiter "Funktionen"): Stand je Projekt und Entscheidung ueber die Anfrage.
|
||||
* Eingehaengt in routes/team/index.js unter /api/team/shop, dort hinter requireStaffJwt.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
const sendError = (res, err) => {
|
||||
const status = err.status >= 400 && err.status < 600 ? err.status : 500
|
||||
if (status >= 500) console.error('[team-shop]', err.message)
|
||||
return res.status(status).json({ error: err.message || 'Interner Serverfehler', ...(err.body?.code ? { code: err.body.code } : {}) })
|
||||
}
|
||||
const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err))
|
||||
const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' })
|
||||
|
||||
/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */
|
||||
async function zaehle(collection, queries, was) {
|
||||
try {
|
||||
return await countDocuments(config.collections[collection], queries)
|
||||
} catch (err) {
|
||||
console.warn(`[team-shop] ${was} nicht lesbar: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** stripeStatus (JSON-Text im shopConfig) als Objekt oder null, wenn leer oder nicht lesbar. */
|
||||
function stripeStatus(text) {
|
||||
try {
|
||||
const roh = typeof text === 'string' && text ? JSON.parse(text) : null
|
||||
return roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function projektStand(project, feature) {
|
||||
let shop = null
|
||||
if (feature) {
|
||||
try {
|
||||
shop = await leseShopConfig(feature.$id)
|
||||
} catch (err) {
|
||||
console.warn(`[team-shop] shopConfig ${feature.$id} nicht lesbar: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const shopId = feature?.$id
|
||||
const [artikel, offeneBestellungen] = shopId
|
||||
? await Promise.all([
|
||||
zaehle('shopProducts', [Query.equal('shopId', shopId)], 'Artikelzahl'),
|
||||
zaehle('shopOrders', [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')], 'Bestellzahl'),
|
||||
])
|
||||
: [0, 0]
|
||||
return {
|
||||
projectId: project.$id,
|
||||
projectName: project.projectName || '',
|
||||
state: feature?.state || 'off',
|
||||
anfrage: shop ? anfrageAnsicht(shop) : null,
|
||||
stripe: { accountId: Boolean(shop?.stripeAccountId), status: stripeStatus(shop?.stripeStatus) },
|
||||
geoeffnet: shop?.geoeffnet === true,
|
||||
zahlen: { artikel, offeneBestellungen },
|
||||
}
|
||||
}
|
||||
|
||||
// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Shop-Dokument existiert
|
||||
router.get('/:customerId', route(async (req, res) => {
|
||||
const customerId = req.params.customerId
|
||||
const [docs, projects] = await Promise.all([
|
||||
listCustomerFeatureDocs(customerId),
|
||||
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
|
||||
])
|
||||
const shopDocs = new Map(docs.filter((d) => d.featureKey === 'shop').map((d) => [d.projectId, d]))
|
||||
const sichtbar = projects.filter((p) => !p.archived || shopDocs.has(p.$id))
|
||||
res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, shopDocs.get(p.$id)))) })
|
||||
}))
|
||||
|
||||
router.post('/:customerId/anfrage', route(async (req, res) => {
|
||||
const { projectId, entscheidung, begruendung } = req.body || {}
|
||||
const ergebnis = await entscheideAnfrage({
|
||||
customerId: req.params.customerId,
|
||||
projectId,
|
||||
entscheidung,
|
||||
begruendung,
|
||||
person: person(req),
|
||||
})
|
||||
res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) })
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -15,8 +15,8 @@ import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuen
|
||||
import { einbauSnippet } from '../services/termine/einbauanleitung.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
|
||||
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
|
||||
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen } from '../services/termine/kontext.js'
|
||||
import { BRANCHEN, SCHRITTE, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
|
||||
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen, veraltet } from '../services/termine/kontext.js'
|
||||
import { zustellFehler } from '../services/termine/outbox.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { belegungAus, freieZeiten } from '../services/termine/slots.js'
|
||||
@@ -297,7 +297,8 @@ async function aboFuerVorschau(customerId) {
|
||||
/**
|
||||
* Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true).
|
||||
* Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag
|
||||
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der
|
||||
* ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), aboHinweis (der Text dazu, wenn das
|
||||
* Abo nicht bestellbar oder nicht lesbar ist, sonst null), offen (Punkte der
|
||||
* Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und
|
||||
* offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird
|
||||
* dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links
|
||||
@@ -324,6 +325,9 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
|
||||
kundenansicht: isKundenansicht(req.session),
|
||||
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
|
||||
abo,
|
||||
// der Text zum Abo (B2): einzige Quelle aboMeldung wie GET /api/funktionen (R111). In off ohne bestellbares Abo, auch wenn es nicht lesbar war
|
||||
// oder sich zwischen den beiden Abfragen des Browsers geaendert hat; sonst null. Der Browser zeigt ihn an, er hat keine Kopie.
|
||||
aboHinweis: state === 'off' && abo?.ok !== true ? aboMeldung(register, abo?.grund ?? 'unbekannt') : null,
|
||||
offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null,
|
||||
bestellung: bestellbar ? await bestellungAnsicht(register) : null,
|
||||
feeds: e && state === 'active' ? feedLinks(ctx) : {},
|
||||
@@ -462,6 +466,7 @@ const nichtInKundenansicht = route(async (req, _res, next) => {
|
||||
/**
|
||||
* Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht:
|
||||
* Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal).
|
||||
* Veralteter Tab (die gespeicherte Branche ist eine andere): 409 `EINSTELLUNG_VERALTET`; bestaetigt wird mit dem gespeicherten Stand vereinigt.
|
||||
* Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur
|
||||
* fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern.
|
||||
* Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag.
|
||||
@@ -473,7 +478,19 @@ router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ sc
|
||||
|
||||
let gespeichert
|
||||
try {
|
||||
gespeichert = await speichereFragebogen(feature, () => geprueft.fragebogen, { by: von(req), now: uhr() })
|
||||
// Unter der Sperre gegen den gespeicherten Stand gelesen (ein zweiter Tab kann dazwischen gespeichert haben): die Branche muss die
|
||||
// gespeicherte sein, sonst ist dieser Tab veraltet (409 EINSTELLUNG_VERALTET; die Branche wechselt nur POST /einstellungen/vorlage,
|
||||
// und mit ihr Vorlage, Preise und Bestätigungen). Bestätigungen gehen nie verloren: bestaetigt ist die Vereinigung aus dem
|
||||
// gespeicherten und dem gesendeten Stand (zurückgesetzt werden sie nur durch eine neue Vorlage).
|
||||
gespeichert = await speichereFragebogen(
|
||||
feature,
|
||||
(bisher) => {
|
||||
if (bisher.branche && bisher.branche !== geprueft.fragebogen.branche) throw veraltet()
|
||||
const bestaetigt = SCHRITTE.filter((schritt) => bisher.bestaetigt?.includes(schritt) || geprueft.fragebogen.bestaetigt.includes(schritt))
|
||||
return { ...geprueft.fragebogen, bestaetigt }
|
||||
},
|
||||
{ by: von(req), now: uhr() },
|
||||
)
|
||||
} catch (err) {
|
||||
throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||
import {
|
||||
fetchPreviewConfig,
|
||||
defaultPreviewConfig,
|
||||
runDeploy,
|
||||
foreignSubdomainBlock,
|
||||
deployKeepsStatus,
|
||||
buildPreviewUrl,
|
||||
} from '../../services/previewDeploy.js'
|
||||
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
||||
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
|
||||
import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js'
|
||||
import { sendPushTimeMail } from '../../services/pushTimeMail.js'
|
||||
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
|
||||
import { invalidateProjectCache } from '../../services/projectInsights.js'
|
||||
@@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => {
|
||||
}
|
||||
}
|
||||
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
|
||||
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
|
||||
const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
|
||||
// Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder
|
||||
// beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt.
|
||||
const blocked = wantsPreview
|
||||
? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop())
|
||||
: null
|
||||
const hasPreview = wantsPreview && !blocked
|
||||
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
|
||||
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
|
||||
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
|
||||
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
|
||||
|
||||
let deployResult = { deployed: false, skipped: true }
|
||||
let deployResult = blocked || { deployed: false, skipped: true }
|
||||
const configBranch = previewConfig.branch || 'main'
|
||||
|
||||
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
|
||||
try {
|
||||
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha })
|
||||
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true })
|
||||
} catch (deployErr) {
|
||||
console.error('[webhook] deploy failed:', deployErr.message)
|
||||
deployResult = { deployed: false, error: deployErr.message }
|
||||
@@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => {
|
||||
const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
|
||||
|
||||
const projectPayload = {
|
||||
projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain,
|
||||
// der Anzeigename einer fremden Config gehoert ebenfalls dem Original
|
||||
projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain,
|
||||
templateName: previewConfig.templateName || config.gitea.templateRepo,
|
||||
giteaRepoUrl: payloadNameStillCurrent
|
||||
? repo.html_url || `${config.gitea.baseUrl}/${current}`
|
||||
@@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => {
|
||||
} else if (hasPreview) {
|
||||
projectPayload.status = 'pending'
|
||||
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
|
||||
} else if (blocked) {
|
||||
// eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview;
|
||||
// ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte)
|
||||
const existing = (await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('repoFullName', current),
|
||||
Query.limit(1),
|
||||
]))[0]
|
||||
if (!existing?.subdomain) projectPayload.status = 'synced'
|
||||
else {
|
||||
// sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed')
|
||||
projectPayload.previewState = 'failed'
|
||||
projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500)
|
||||
}
|
||||
if (existing?.projectName) delete projectPayload.projectName
|
||||
} else {
|
||||
projectPayload.status = 'synced'
|
||||
}
|
||||
@@ -177,7 +199,10 @@ router.post('/gitea', async (req, res) => {
|
||||
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
|
||||
// dieselbe ticketId-Aufloesung doppelt anstoesst.
|
||||
let gitWorksheet = null
|
||||
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
|
||||
// System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder
|
||||
// Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches
|
||||
// Ticket "Website <name>" neben dem, das im Zuordnen-Assistenten gewaehlt wird.
|
||||
if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) {
|
||||
gitWorksheet = await withLocks(
|
||||
['project:' + project.$id],
|
||||
async () => {
|
||||
|
||||
Reference in new issue
Block a user