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:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+183 -1
View File
@@ -30,6 +30,7 @@ function nonNegativeNumber(raw, fallback) {
}
const termineEnabled = (process.env.TERMINE_ENABLED || '').trim().toLowerCase()
const shopEnabled = (process.env.SHOP_ENABLED || '').trim().toLowerCase()
const zeitenEnabled = (process.env.ZEITEN_ENABLED || '').trim().toLowerCase()
/** Komma-Liste aus einer Umgebungsvariablen: getrimmt, klein geschrieben, ohne Leereintraege; leer ergibt den Vorgabewert. */
@@ -126,6 +127,16 @@ const termineSetup = {
priceNickname: 'Terminplaner-Einrichtung 149 €',
}
// Webshop: einmalige Einrichtung (Einmalpreis, kein Abo); der Shop selbst verkauft ueber das eigene Stripe-Konto des Kunden (Connect)
const shopSetup = {
key: 'shopSetup',
name: 'Webshop – Einrichtung',
amountCents: 29900,
lookupKey: 'webklar_shop_einrichtung_29900',
oneTime: true,
priceNickname: 'Webshop-Einrichtung 299 €',
}
// Oeffnungszeiten live: einmalige Einrichtung (Einmalpreis, kein Abo); im Wartungs-Abo ist die Funktion enthalten
const zeitenSetup = {
key: 'zeitenSetup',
@@ -242,7 +253,85 @@ function termineConsent() {
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
* Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem
* Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab.
*/
function shopRechtsunterlage(slug, { nullIstWert = false } = {}) {
let ueberschrieben // undefined = Standard
const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` })
return {
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
set(wert) {
const std = standard()
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
ueberschrieben = zurueck ? undefined : wert
},
}
}
const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop')
const shopAvv = shopRechtsunterlage('avv-webshop')
const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
const SHOP_PREIS = '299 €'
const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen'
/**
* Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die
* Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile
* (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie
* termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung
* neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und
* die Pflichtinformationen sind Entwurf bis zur Kanzlei.
*/
function shopConsent() {
let versionGesetzt // undefined = config.shop.fassung
let gebaut = null // { version, items }
const version = () => versionGesetzt ?? config.shop.fassung
const items = (v) => [
{
id: 'unterlagen',
haken: true,
text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`,
},
{
id: 'verantwortung',
haken: true,
text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.',
},
{
id: 'stripe',
haken: true,
text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.',
},
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
},
{
id: 'information',
haken: true,
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
},
]
return {
get version() {
return version()
},
set version(wert) {
versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert
},
knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`,
get items() {
const v = version()
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
return gebaut.items
},
}
}
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
@@ -369,6 +458,58 @@ const features = {
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
},
shop: {
key: 'shop',
product: 'shopSetup',
label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' },
priceLabel: SHOP_PREIS,
setupEstimate: SHOP_DAUER,
consent: shopConsent(),
// Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
get leistungsbeschreibung() {
return shopLb.get()
},
set leistungsbeschreibung(wert) {
shopLb.set(wert)
},
get avv() {
return shopAvv.get()
},
set avv(wert) {
shopAvv.set(wert)
},
get widerruf() {
return shopWiderruf.get()
},
set widerruf(wert) {
shopWiderruf.set(wert)
},
returnHash: 'webshop',
returnProjekt: true,
ticket: {
topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { shopTicketDetails } = await import('./services/shop/ticketDetails.js')
return shopTicketDetails(ctx)
},
},
mails: {
funktion: 'Webshop',
formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' },
knopf: 'Webshop öffnen',
danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.',
bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.',
preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
},
// Oeffnungszeiten live (Spec 2026-10-04): 30 EUR einmalig, im Wartungs-Abo ohne Aufpreis (aboInklusive, consentFrei)
zeiten: {
key: 'zeiten',
@@ -484,6 +625,12 @@ export const config = {
termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings',
termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims',
termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox',
// Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs)
shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig',
shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts',
shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders',
shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe',
shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv',
// Oeffnungszeiten live (Einstellungen, Erinnerungsmerker; scripts/zeiten-setup.mjs)
oeffnungszeiten: process.env.APPWRITE_COLLECTION_OEFFNUNGSZEITEN || 'oeffnungszeiten',
zeitenErinnerungen: process.env.APPWRITE_COLLECTION_ZEITEN_ERINNERUNGEN || 'zeitenErinnerungen',
@@ -503,6 +650,7 @@ export const config = {
mailStorage,
newsSetup,
termineSetup,
shopSetup,
zeitenSetup,
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
@@ -733,6 +881,40 @@ export const config = {
minAntwortMs: nonNegativeNumber(process.env.TERMINE_MIN_ANTWORT_MS, 300),
},
// Webshop (Funktionsschluessel 'shop'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur Laufzeit);
// Schalter siehe server/services/shop/schalter.js
shop: {
// off (Vorgabe) | pilot (nur SHOP_PILOT_EMAILS) | on (alle Kunden)
mode: ['pilot', 'on'].includes(shopEnabled) ? shopEnabled : 'off',
// Kunden-E-Mails fuer den Pilotbetrieb; '*' = alle
pilotEmails: (process.env.SHOP_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Fassung der Webshop-Bestellunterlagen (Leistungsbeschreibung, AVV, Bestelltexte): Berliner Kalendertag des
// Fassungs-Commits (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben
fassung: '2026-10-05',
// Artikelbilder der Shops: <uploadDir>/<shopId>/<datei>
uploadDir: process.env.SHOP_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'shop'),
// Signing-Secret des Stripe-Connect-Webhooks (Ereignisse der verbundenen Konten); leer = Webhook antwortet 503
connectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET || '',
// Eine unbezahlte Bestellung haelt den Bestand (und die Stripe-Checkout-Sitzung) so lange
bestellAblaufMinuten: 30,
maxArtikel: 500,
maxBilder: 4,
maxVarianten: 30,
// Bestellungen werden so viele Jahre nach Abschluss anonymisiert (Aufbewahrungsfrist), siehe Block J
anonymisierenJahre: 3,
// Mindestzeit (Sekunden) zwischen Laden und Absenden des Widerrufs-/Bestellformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.SHOP_FORM_MIN_SEKUNDEN, 3),
// Kuerzeste Antwortzeit (ms) der POST-Routen der oeffentlichen Schnittstelle (verwischt Zeitunterschiede). Tests setzen 0.
minAntwortMs: nonNegativeNumber(process.env.SHOP_MIN_ANTWORT_MS, 300),
// Tage nach Abschalten der Funktion bis zur Loeschung der Shop-Daten
loeschTageNachAbschalten: 90,
// Unbezahlte (offene/stornierte) Bestellungen werden nach so vielen Tagen geloescht
unbezahltLoeschTage: 30,
},
// Oeffnungszeiten live (Funktionsschluessel 'zeiten'); Geschwister von termine (termineSchalter.test.js pinnt
// config.termine). Wird bei jedem Aufruf gelesen; Schalter siehe server/services/zeiten/schalter.js
zeiten: {
+35
View File
@@ -17,6 +17,9 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js'
import shopWebhookRoutes from './routes/shopWebhook.js'
import { shopSeiteRouter } from './routes/shopSeite.js'
import zeitenRoutes from './routes/zeiten.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
@@ -26,6 +29,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js'
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
@@ -39,6 +43,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.js'
import publicShopRoutes from './routes/publicShop.js'
import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import { legalRouter } from './services/legalDocs.js'
@@ -62,6 +67,8 @@ app.use(kundenansichtGuard)
// Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und
// haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde
app.use('/api/public/termine/v1', publicTermineRoutes)
// Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser
app.use('/api/public/shop/v1', publicShopRoutes)
// Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit,
// muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung
// zuerst, damit niemand ohne Session grosse Bodies parsen laesst.
@@ -83,6 +90,9 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes)
// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers
app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/zeiten', zeitenRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
@@ -117,6 +127,8 @@ app.use('/embed/', (_req, res, next) => {
})
// Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static)
app.use('/rechtliches', legalRouter())
// Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static)
app.use(shopSeiteRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
@@ -225,6 +237,29 @@ const server = app.listen(config.port, '0.0.0.0', () => {
setTimeout(termineTagesJob, 180 * 1000).unref()
setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref()
// Webshop: jede Minute offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (75 s nach dem Start beginnend), stuendlich nie
// beanspruchte Artikelbilder aufraeumen (130 s), taeglich Kaeuferdaten anonymisieren, unbezahlte Bestellungen und lange abgeschaltete
// Shops loeschen (200 s). Bei SHOP_ENABLED=off kehren Minuten- und Stundenjob sofort zurueck; der Tagesjob laeuft dann nur, wenn es
// noch Shop-Dokumente gibt. Die Jobs melden Einzelfehler selbst (ohne Kaeuferdaten); hier faengt der Rahmen alles Unerwartete ab.
const shopMinuten = () => shopMinutenJob(new Date()).catch((err) => console.warn('[shop] Minutenjob fehlgeschlagen:', err.message))
setTimeout(() => {
shopMinuten()
setInterval(shopMinuten, 60 * 1000).unref()
}, 75 * 1000).unref()
const shopStunden = () => shopStuendlicherJob(new Date()).catch((err) => console.warn('[shop] Stundenjob fehlgeschlagen:', err.message))
setTimeout(() => {
shopStunden()
setInterval(shopStunden, 60 * 60 * 1000).unref()
}, 130 * 1000).unref()
const shopTages = () =>
shopTaeglicherJob(new Date())
.then((r) => r && !r.aus && !r.uebersprungen && console.log(`[shop] Tagesjob: ${JSON.stringify(r)}`))
.catch((err) => console.warn('[shop] Tagesjob fehlgeschlagen:', err.message))
setTimeout(() => {
shopTages()
setInterval(shopTages, 24 * 60 * 60 * 1000).unref()
}, 200 * 1000).unref()
// Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED,
// ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei
// Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G09 · Bestellbestätigung (Webshop, dauerhafter Datenträger nach § 312f Abs. 2 BGB)
Betreff: Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{datum}}, {{zahlungsdatum}}, {{positionen}}, {{zwischensumme}}, {{versand}}, {{gesamt}}, {{steuer_hinweis}}, {{lieferart_text}}, {{lieferadresse}}, {{abholort}}, {{lieferzeit}}, {{zahlungsart}}, {{ausschluss_text}}, {{agb}}, {{widerrufsbelehrung}}, {{widerrufsformular}}, {{gewaehrleistung}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Optionale Blöcke (vom Server gesetzt, versand und abholung schließen sich aus): versand, abholung, ausschluss
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
positionen = eine Zeile je Position „Menge × Name (Variante) – Summe“; lieferadresse mehrzeilig; agb, widerrufsbelehrung, widerrufsformular und gewaehrleistung sind die vollen Texte (Zeilenumbrüche werden zu <br>).
Block versand: Lieferadresse. Block abholung: Abholort. Block ausschluss: Hinweis auf Artikel ohne Widerrufsrecht (ausschluss_text).
datum = Bestelldatum (Zeitpunkt der Bestellung), zahlungsdatum = Tag des Zahlungseingangs. Mit dieser Mail nimmt der Händler die Bestellung an (Vertragsschluss). Ist die Mail zu lang für die Warteschlange, wird sie nicht versendet (der Marker an der Bestellung bleibt offen und der Versand wird wiederholt), sie wird nie durch einen Link ersetzt.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Vielen Dank für Ihre Bestellung</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>wir haben Ihre Bestellung bei {{betrieb_name}} erhalten und Ihre Zahlung ist eingegangen. Mit dieser E-Mail nehmen wir Ihre Bestellung an. Der Kaufvertrag ist damit geschlossen. Bitte bewahren Sie diese E-Mail auf: Sie enthält alle Angaben zu Ihrem Vertrag.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestelldatum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zahlungsdatum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zahlungsdatum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Anbieter</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{anbieter_zeile}}</td></tr>
</table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Ihre Artikel</td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{positionen}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zwischensumme</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zwischensumme}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Versand</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{versand}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamt</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Steuer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{steuer_hinweis}}</td></tr>
</table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Lieferung und Zahlung</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferart</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferart_text}}</td></tr>
<!-- WENN versand -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferadresse</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferadresse}}</td></tr>
<!-- ENDE versand -->
<!-- WENN abholung -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abholort</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abholort}}</td></tr>
<!-- ENDE abholung -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferzeit}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zahlungsart</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zahlungsart}}</td></tr>
</table></td></tr>
<!-- WENN ausschluss -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Hinweis zum Widerrufsrecht:</strong><br>{{ausschluss_text}}</td></tr></table></td></tr>
<!-- ENDE ausschluss -->
<tr><td class="px ink" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Hinweis zur gesetzlichen Gewährleistung</td></tr>
<tr><td class="px" style="padding:10px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="mut" style="padding:14px 18px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;color:#4a4843;">{{gewaehrleistung}}</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Widerrufsbelehrung</td></tr>
<tr><td class="px" style="padding:10px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="mut" style="padding:14px 18px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;color:#4a4843;">{{widerrufsbelehrung}}</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Muster-Widerrufsformular</td></tr>
<tr><td class="px" style="padding:10px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="mut" style="padding:14px 18px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;color:#4a4843;">{{widerrufsformular}}</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:30px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Allgemeine Geschäftsbedingungen</td></tr>
<tr><td class="px" style="padding:10px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="mut" style="padding:14px 18px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;color:#4a4843;">{{agb}}</td></tr></table></td></tr>
<tr><td class="px mut" style="padding:26px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Sie können Ihren Vertrag jederzeit über die Schaltfläche „Vertrag widerrufen“ am Ende dieser E-Mail widerrufen, solange Ihnen ein Widerrufsrecht zusteht.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G10 · Bestellung versendet (Webshop)
Betreff: Ihre Bestellung {{bestellnummer}} wurde versendet
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{sendungsnummer}}, {{versanddienst}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Optionale Blöcke (vom Server gesetzt, nur wenn Versanddienst oder Sendungsnummer bekannt ist): sendung
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
Block sendung: Zeilen Versanddienst und Sendungsnummer (der Server setzt beide Werte, fehlende als „–“).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestellung {{bestellnummer}} wurde versendet</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Bestellung ist unterwegs</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>{{betrieb_name}} hat Ihre Bestellung versendet.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<!-- WENN sendung -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Versanddienst</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{versanddienst}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Sendungsnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{sendungsnummer}}</td></tr>
<!-- ENDE sendung -->
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Bei Fragen zu Ihrer Bestellung antworten Sie einfach auf diese E-Mail.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G11 · Bestellung abholbereit (Webshop)
Betreff: Ihre Bestellung {{bestellnummer}} ist abholbereit
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{abholort}}, {{hinweis}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
abholort = Adresse der Abholung; hinweis = Hinweis des Händlers zur Abholung (der Server setzt einen Standardtext, wenn der Händler keinen hinterlegt hat).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestellung {{bestellnummer}} ist abholbereit</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Bestellung ist abholbereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>Ihre Bestellung liegt bei {{betrieb_name}} zur Abholung bereit.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Abholort</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abholort}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Hinweis</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{hinweis}}</td></tr>
</table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G12 · Eingang Widerruf (Eingangsbestätigung nach § 356a BGB, Webshop)
Betreff: Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{name}}, {{bestellnummer}}, {{eingang}}, {{nachricht}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Optionale Blöcke (vom Server gesetzt, nur wenn der Käufer eine Erklärung eingegeben hat): nachricht
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
Empfänger ist die bei der Erklärung angegebene Adresse (ungeprüft). name, bestellnummer und nachricht sind Eingaben des Käufers. eingang = „TT.MM.JJJJ, HH:MM Uhr“ (Berliner Zeit).
Die Mail enthält den Inhalt der Erklärung, Datum und Uhrzeit des Eingangs und gilt als Eingangsbestätigung.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Eingangsbestätigung Ihres Widerrufs</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir bestätigen den Eingang Ihrer Widerrufserklärung bei {{betrieb_name}}.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{bestellnummer}}</td></tr>
<!-- WENN nachricht -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Ihre Erklärung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nachricht}}</td></tr>
<!-- ENDE nachricht -->
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">{{betrieb_name}} prüft Ihre Erklärung und meldet sich bei Ihnen.</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Sie haben diese E-Mail erhalten, weil mit Ihrer E-Mail-Adresse ein Widerruf erklärt wurde. Waren Sie das nicht, können Sie diese Nachricht ignorieren.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G13 · Erstattung (Webshop)
Betreff: Erstattung zu Ihrer Bestellung {{bestellnummer}}
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{betrag}}, {{grund}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Optionale Blöcke (vom Server gesetzt, nur wenn der Händler einen Grund angegeben hat): grund
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
betrag = erstatteter Betrag, z. B. „12,50 €“.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Erstattung zu Ihrer Bestellung {{bestellnummer}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Erstattung</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>{{betrieb_name}} hat Ihnen einen Betrag zu Ihrer Bestellung erstattet.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Erstatteter Betrag</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong></td></tr>
<!-- WENN grund -->
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Grund</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{grund}}</td></tr>
<!-- ENDE grund -->
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Die Gutschrift erfolgt über die ursprüngliche Zahlungsart. Je nach Zahlungsart kann es einige Tage dauern, bis der Betrag bei Ihnen sichtbar ist.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G14 · Zahlung fehlgeschlagen (Webshop)
Betreff: Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Bestellung wurde nicht abgeschlossen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>die Zahlung für Ihre Bestellung bei {{betrieb_name}} ist fehlgeschlagen. Ihre Bestellung wurde nicht abgeschlossen; es wurde nichts berechnet.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
</table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Sie können jederzeit erneut bestellen. Bei Fragen antworten Sie einfach auf diese E-Mail.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
G15 · Eingang der Bestellung (Webshop, SEPA-Lastschrift: Zahlung noch in Bearbeitung, noch keine Vertragsbestätigung)
Betreff: Wir haben Ihre Bestellung {{bestellnummer}} erhalten
Absender: „<Shopname>“ <Portal-Postfach> · Antworten an die E-Mail-Adresse des Händlers
Empfänger: Käufer (Adresse der Stripe-Sitzung) · ohne BCC
Platzhalter: {{bestellnummer}}, {{datum}}, {{positionen}}, {{gesamt}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}}
Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion.
Wird einmal gesendet, sobald Stripe die Sitzung abgeschlossen meldet, die Zahlung aber noch aussteht (Lastschrift). Der Kaufvertrag kommt erst mit der Bestellbestätigung (G09) nach Zahlungseingang zustande.
positionen = eine Zeile je Position „Menge × Name (Variante) – Summe“.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Wir haben Ihre Bestellung {{bestellnummer}} erhalten</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{border-color:#f2f1ee!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}.pill{background:#1f1f1e!important;}.pill span{color:#f2f1ee!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" bgcolor="{{farbe}}" style="background:{{farbe}};border-radius:16px 16px 0 0;padding:18px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="pill" bgcolor="#ffffff" style="background:#ffffff;border-radius:8px;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:22px;font-weight:bold;color:#111111;"><span style="color:#111111;">{{betrieb_name}}</span></td></tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:0 0 16px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px ink" style="padding:36px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:26px;line-height:32px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Wir haben Ihre Bestellung erhalten</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag,<br>vielen Dank für Ihre Bestellung bei {{betrieb_name}}. Ihre Zahlung per Lastschrift ist noch in Bearbeitung. Sobald sie bei uns eingegangen ist, erhalten Sie die Bestellbestätigung; erst damit kommt der Kaufvertrag zustande.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestelldatum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamt</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
</table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:24px;font-weight:bold;color:#111111;">Ihre Artikel</td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{positionen}}</td></tr></table></td></tr>
<tr><td class="px mut" style="padding:26px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Bitte bewahren Sie diese E-Mail auf. Sie können Ihren Vertrag jederzeit über die Schaltfläche „Vertrag widerrufen“ am Ende dieser E-Mail widerrufen, sobald er zustande gekommen ist und solange Ihnen ein Widerrufsrecht zusteht.</td></tr>
<tr><td class="px ink" style="padding:28px 40px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>{{betrieb_name}}</strong></td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><div><strong>{{betrieb_name}}</strong></div><div>{{anbieter_zeile}}</div></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="{{impressum_url}}" style="color:#4a4843;text-decoration:underline;">Impressum</a> · <a class="mut" href="{{datenschutz_url}}" style="color:#4a4843;text-decoration:underline;">Datenschutz</a> · <a class="mut" href="{{widerruf_url}}" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Webshop von WEBklar versendet. Antworten gehen direkt an {{betrieb_name}}.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K24 · Webshop-Anfrage entschieden (Annahme oder Ablehnung)
Betreff: Deine Webshop-Anfrage für {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{ust_hinweis}}, {{begruendung}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angenommen, abgelehnt
Genau einer der Blöcke ist an. angenommen = Anfrage angenommen, der Kunde kann den Webshop jetzt bestellen; abgelehnt = Anfrage abgelehnt, mit Begründung des Teams.
preis = Preis der Einrichtung, z. B. „299 €“; ust_hinweis = Umsatzsteuerhinweis zum Preis; portal_url = Webshop-Seite des Projekts im Kundenportal.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Webshop-Anfrage für {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Webshop-Anfrage wurde bearbeitet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Webshop</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Webshop</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN angenommen -->Deine Webshop-Anfrage wurde angenommen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zu Deiner Webshop-Anfrage<!-- ENDE abgelehnt --></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN angenommen -->wir haben Deine Webshop-Anfrage für {{projekt}} angenommen. Du kannst den Webshop jetzt im Kundenportal bestellen; danach richten wir ihn ein.<!-- ENDE angenommen --><!-- WENN abgelehnt -->leider können wir Deine Webshop-Anfrage für {{projekt}} nicht annehmen. Den Grund findest Du unten. Wenn Du Fragen hast, antworte einfach auf diese E-Mail.<!-- ENDE abgelehnt --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{projekt}}</strong></td></tr><!-- WENN angenommen --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Preis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">einmalig {{preis}} ({{ust_hinweis}})</td></tr><!-- ENDE angenommen --><!-- WENN abgelehnt --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Begründung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">{{begruendung}}</td></tr><!-- ENDE abgelehnt --></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;"><!-- WENN angenommen -->Webshop bestellen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zum Kundenportal<!-- ENDE abgelehnt --> &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K25 · Neue Bestellung im Webshop (an den Händler)
Betreff: Neue Bestellung {{bestellnummer}} in Deinem Webshop
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · OHNE BCC (enthält Angaben von Käufern)
Platzhalter: {{kunde_name}}, {{shopname}}, {{bestellnummer}}, {{gesamt}}, {{positionen}}, {{lieferart_text}}, {{kaeufer_name}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): ueberverkauft, sepa
Block ueberverkauft: der Bestand reichte nicht (die Bestellung war schon bezahlt). Block sepa: Zahlung per Lastschrift.
positionen = eine Zeile je Position „Menge × Name (Variante) – Summe“; portal_url = Webshop-Seite des Projekts im Kundenportal.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Neue Bestellung {{bestellnummer}} in Deinem Webshop</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">In Deinem Webshop ist eine neue Bestellung eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Webshop</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Neue Bestellung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Bestellung {{bestellnummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>in Deinem Webshop „{{shopname}}“ ist eine bezahlte Bestellung eingegangen.</td></tr>
<!-- WENN ueberverkauft -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Achtung: Bestand reichte nicht.</strong> Die Bestellung ist bezahlt, aber von mindestens einem Artikel war nicht mehr genug da. Bitte melde Dich beim Käufer oder erstatte die Bestellung im Portal.</td></tr></table></td></tr>
<!-- ENDE ueberverkauft -->
<!-- WENN sepa -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Zahlung per Lastschrift:</strong> Stripe hat die Zahlung bestätigt. Eine Lastschrift kann aber noch einige Wochen zurückgebucht werden. Bedenke das vor dem Versand teurer Ware.</td></tr></table></td></tr>
<!-- ENDE sepa -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{bestellnummer}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Käufer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kaeufer_name}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferart</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferart_text}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt}}</strong></td></tr>
</table></td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">{{positionen}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Bestellung ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K26 · Widerruf im Webshop eingegangen (an den Händler)
Betreff: Widerruf in Deinem Webshop eingegangen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · OHNE BCC (enthält Angaben von Käufern)
Platzhalter: {{kunde_name}}, {{shopname}}, {{bestellnummer}}, {{kaeufer_name}}, {{kaeufer_email}}, {{eingang}}, {{nachricht}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): nachricht
Block nachricht: der Käufer hat eine Erklärung eingegeben. bestellnummer, kaeufer_name, kaeufer_email und nachricht sind Eingaben des Käufers (ungeprüft). eingang = „TT.MM.JJJJ, HH:MM Uhr“ (Berliner Zeit).
Der Käufer hat die Eingangsbestätigung (G12) schon automatisch bekommen.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Widerruf in Deinem Webshop eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ein Käufer hat in Deinem Webshop einen Vertrag widerrufen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Webshop</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Widerruf</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Widerruf in Deinem Webshop eingegangen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>ein Käufer hat in Deinem Webshop „{{shopname}}“ einen Vertrag widerrufen. Den Eingang haben wir ihm schon automatisch bestätigt.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestellnummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{bestellnummer}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Käufer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kaeufer_name}}</td></tr>
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kaeufer_email}}</td></tr>
<!-- WENN nachricht -->
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Erklärung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nachricht}}</td></tr>
<!-- ENDE nachricht -->
</table></td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><strong>Was jetzt zu tun ist:</strong> Bitte bestätige dem Käufer den Widerruf persönlich und erstatte den Kaufpreis innerhalb von 14 Tagen nach Eingang des Widerrufs, wenn der Widerruf wirksam ist. Die Erstattung machst Du im Portal bei der Bestellung. Die Rücksendung der Ware klärst Du mit dem Käufer.</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Widerrufe ansehen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T21 · Neue Webshop-Anfrage
Betreff: [Webshop] Neue Anfrage: {{kunde_name}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{warenart}}, {{beschreibung}}, {{artikelzahl}}, {{lieferung}}, {{nachricht}}, {{woms_url}}
Die Vorlage hat keine optionalen Blöcke. Ein Kunde hat im Portal eine Webshop-Anfrage gestellt („auf Anfrage“); das Team entscheidet im Ticketsystem (Kunde, Reiter Funktionen): annehmen oder ablehnen.
warenart, artikelzahl und lieferung sind die lesbaren Texte der Auswahl; nachricht steht „–“, wenn der Kunde keine geschrieben hat. woms_url führt zum Kunden im Ticketsystem (Reiter Funktionen).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Webshop] Neue Anfrage: {{kunde_name}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}).&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Webshop</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Webshop-Anfrage</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Warenart</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{warenart}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Artikelzahl</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{artikelzahl}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferung}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Beschreibung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{beschreibung}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Nachricht</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{nachricht}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem entscheiden &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+18 -4
View File
@@ -1,6 +1,20 @@
import { config } from '../config.js'
import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js'
export async function requireAppwriteStaff(req, res, next) {
/**
* Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`.
* requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...).
* requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen
* (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04.
*/
function requireAppwriteLabels(allowed, deniedMessage) {
return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage)
}
export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich')
export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter')
async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) {
const authHeader = req.get('Authorization') || ''
const match = authHeader.match(/^Bearer\s+(.+)$/i)
if (!match) {
@@ -29,11 +43,11 @@ export async function requireAppwriteStaff(req, res, next) {
}
if (!response.ok) {
return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' })
return res.status(401).json({ error: data?.message || 'Ungültiges JWT' })
}
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) {
return res.status(403).json({ error: deniedMessage })
}
req.appwriteUser = data
+1 -1
View File
@@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) {
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
+10
View File
@@ -24,6 +24,16 @@ export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
// Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese
// sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und
// Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/shop\/produkte\/?$/],
['POST', /^\/api\/shop\/produkte\/sortierung\/?$/],
['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['PUT', /^\/api\/shop\/einstellungen\/?$/],
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
['POST', /^\/api\/shop\/bild\/?$/],
// Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403).
// Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt.
['PUT', /^\/api\/zeiten\/[^/]+\/?$/],
+33 -10
View File
@@ -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
+3 -2
View File
@@ -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
+101 -5
View File
@@ -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)
+11 -2
View File
@@ -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 })
+3 -2
View File
@@ -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']
+3 -2
View File
@@ -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
+10 -3
View File
@@ -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 })
+2 -1
View File
@@ -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)) {
+8 -2
View File
@@ -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),
+5 -2
View File
@@ -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 || '',
+673
View File
@@ -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
+653
View File
@@ -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
+62
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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
+91
View File
@@ -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
+3
View File
@@ -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
+92
View File
@@ -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
+21 -4
View File
@@ -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
}
+32 -7
View File
@@ -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 () => {
+68 -23
View File
@@ -8,6 +8,18 @@ function buildQueries(queries = []) {
})
}
/**
* URL einer Anfrage: Pfad plus eine `queries[]` je Abfrage (JSON, URL-kodiert). Die einzige Stelle, die sie baut (adminFetch und
* dokumenteUrl), damit die Laenge, die der nginx vor Appwrite zaehlt (siehe AUSSCHLUSS_MAX in termine/outbox.js), pruefbar bleibt.
*/
function buildUrl(path, queries = []) {
const url = new URL(`${config.appwrite.endpoint}${path}`)
for (const q of buildQueries(queries)) {
url.searchParams.append('queries[]', q)
}
return url
}
function adminHeaders() {
return {
'Content-Type': 'application/json',
@@ -33,10 +45,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals
throw error
}
const url = new URL(`${config.appwrite.endpoint}${path}`)
for (const q of buildQueries(queries)) {
url.searchParams.append('queries[]', q)
}
const url = buildUrl(path, queries)
const useRawBody = raw || path.startsWith('/users')
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
@@ -93,12 +102,30 @@ export const Query = {
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
select: (attributes) => ({ method: 'select', values: attributes }),
/** Ein Wert oder eine Liste: kein Dokument mit einem der Werte ("keiner von") */
notEqual: (attribute, value) => ({
method: 'notEqual',
attribute,
values: Array.isArray(value) ? value : [value],
}),
/**
* Kein Dokument mit diesem Wert. Genau EIN Wert: Appwrite 1.8.1 lehnt eine Liste ab (HTTP 400 general_query_invalid
* "NotEqual queries require exactly one value.", gemessen 2026-10-05, M1). Fuer mehrere Werte keinerVon(attribute, werte).
* Zwei Eigenheiten der echten Datenbank, die auch der Nachbau der Tests hat: mehrere notEqual in einer Anfrage werden
* geschnitten (M2, bis 40 getestet), und ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-Semantik
* (M3): nur verwenden, wenn das Feld an jedem Dokument gesetzt ist.
*/
notEqual: (attribute, value) => {
if (Array.isArray(value)) throw new TypeError(`Query.notEqual('${attribute}') nimmt genau einen Wert (Appwrite lehnt eine Liste ab); fuer mehrere keinerVon('${attribute}', werte) benutzen`)
return { method: 'notEqual', attribute, values: [value] }
},
}
/**
* "Keiner von diesen Werten": je Wert eine notEqual-Abfrage mit genau einem Wert; Appwrite schneidet sie (M2). Ohne Werte
* keine Abfrage (leeres Array). Jede Abfrage steht als eigenes queries[] in der URL, die Zahl der Werte also begrenzen
* (M4: der nginx vor Appwrite antwortet mit 414, in der Abfrage der Mail-Warteschlange ab 46 Werten); fuer M1 bis M3
* siehe Query.notEqual.
* @param {string} attribute
* @param {Iterable<string|number|boolean>} werte
* @returns {object[]}
*/
export function keinerVon(attribute, werte) {
return [...(werte ?? [])].map((wert) => Query.notEqual(attribute, wert))
}
export const ID = {
@@ -109,6 +136,11 @@ function collectionPath(collectionId) {
return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents`
}
/** Die URL der Listenabfrage einer Collection (ohne Netz), so wie listDocuments sie sendet: fuer Tests der URL-Laenge. */
export function dokumenteUrl(collectionId, queries = []) {
return buildUrl(collectionPath(collectionId), queries).toString()
}
export async function getUserById(userId) {
return adminFetch(`/users/${userId}`)
}
@@ -317,19 +349,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
}
const now = new Date().toISOString()
return adminFetch(collectionPath(config.collections.websiteProjects), {
method: 'POST',
body: {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
status: payload.status ?? 'pending',
provisioningStatus: payload.provisioningStatus ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
},
})
try {
return await adminFetch(collectionPath(config.collections.websiteProjects), {
method: 'POST',
body: {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
status: payload.status ?? 'pending',
provisioningStatus: payload.provisioningStatus ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
},
})
} catch (err) {
// Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage
// angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument
// aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen.
if (err.status !== 409) throw err
const raced = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
if (!raced[0]) throw err
return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data))
}
}
export async function verifyDatabaseAccess() {
+17 -1
View File
@@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
portalPassword = normalizedPassword
}
} else if (normalizedPassword) {
const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern')
const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern')
error.status = 400
throw error
}
@@ -291,6 +291,22 @@ export async function deleteCustomerWithPortalAccess(customerId) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
// Webshop abschalten: Kaeufer koennen Bestellungen nicht mehr aufgeben, den Widerruf aber weiter erklaeren; die Shopdaten loescht der
// Tagesjob 90 Tage nach disabledAt. Den Shop selbst schliessen wir sofort (geoeffnet=false) und leeren den Speicher der oeffentlichen
// Schnittstelle. Jeder Teilschritt ist abgesichert: Fehler halten das Loeschen nicht auf (wie bei Analyse und Terminplaner).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'shop', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
try {
const { schliesseShopsVonKunde } = await import('./shop/kundenLoeschung.js')
await schliesseShopsVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message)
}
// Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind
// disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in
// try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID.
+1
View File
@@ -16,6 +16,7 @@ export const VIEW_ACTIONS = [
'termin_einstellungen',
'termin_aktion',
'termin_manuell',
'shop_aenderung',
'zeiten_gespeichert',
]
+26 -3
View File
@@ -1,10 +1,11 @@
import { config } from '../config.js'
import { termineFuerKunde } from './termine/schalter.js'
import { shopFuerKunde } from './shop/schalter.js'
import { zeitenFuerKunde } from './zeiten/schalter.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Oeffnungszeiten live; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
// termine/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop, Oeffnungszeiten live; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen:
// termine/schalter.js, shop/schalter.js und zeiten/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
// innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen, dazu nachFreischaltung. */
@@ -89,6 +90,21 @@ async function termineBelehrung() {
return widerrufsbelehrungFunktion()
}
/**
* Bestellpruefung des Webshops im Checkout: die Anfrage des Projekts muss vom Team angenommen sein (shop/anfrage.js,
* Block B). Geladen werden die Dienste erst beim Aufruf.
*/
async function shopBestellpruefung({ customerId, projectId }) {
const { anfrageOffenePunkte } = await import('./shop/anfrage.js')
return anfrageOffenePunkte({ customerId, projectId })
}
/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */
async function shopUnterlagenBereit() {
const { featureLegalReady } = await import('./legalDocs.js')
return featureLegalReady('shop')
}
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
@@ -104,6 +120,13 @@ const STANDARD_HAKEN = {
belehrung: termineBelehrung,
freischaltbar: termineFreischaltbar,
},
// Webshop: kein freischaltbar (ein geschlossenes Ticket genuegt); Belehrung aus derselben Quelle wie beim Terminplaner
shop: {
verfuegbar: (email) => shopFuerKunde(email),
bestellpruefung: shopBestellpruefung,
unterlagenBereit: shopUnterlagenBereit,
belehrung: termineBelehrung,
},
// Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung wie beim Terminplaner,
// unterlagenBereit immer (die Oeffnungszeiten haben nur die Leistungsbeschreibung als Seite, keine PDFs); freischaltbar: erst mit
// eingetragenen Zeiten; nachFreischaltung: Ersatz-Hash merken
+210 -73
View File
@@ -5,6 +5,7 @@ import {
Query,
getDocument,
updateDocument,
deleteDocument,
} from './appwriteAdmin.js'
import {
runDeploy,
@@ -17,7 +18,8 @@ import {
import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.js'
import { repoLockKey, isUnlinkedProject } from './repoMove.js'
import { isKind } from './projectKinds.js'
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
@@ -49,12 +51,16 @@ export async function listAllAccessibleRepos() {
/**
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
* jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) -
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem
* jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte
* als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei
* vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt
* archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
* Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach).
* `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails).
*/
export async function importNewRepos() {
return withLocks(['import'], async () => {
@@ -64,39 +70,75 @@ export async function importNewRepos() {
])
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const existingNames = new Set(
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
)
let created = 0
const createdRepos = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName || repo.archived) continue
if (existingNames.has(fullName.toLowerCase())) continue
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: false,
status: 'synced',
giteaSyncedAt: new Date().toISOString(),
})
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
const docsByName = new Map()
for (const d of docs) {
const key = String(d.repoFullName || '').toLowerCase()
if (key && !docsByName.has(key)) docsByName.set(key, d)
}
return { created, createdRepos }
let created = 0
let archivedImported = 0
let archiveSynced = 0
const createdRepos = []
const failed = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName) continue
const repoArchived = Boolean(repo.archived)
try {
const existing = docsByName.get(fullName.toLowerCase())
if (existing) {
if (Boolean(existing.archived) === repoArchived) continue
await withLocks([repoLockKey(fullName)], async () => {
// Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter
// sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem.
const fresh = await getDocument(config.collections.websiteProjects, existing.$id)
const live = await giteaFetch(`/repos/${fullName}`)
const liveArchived = Boolean(live?.archived)
if (!fresh || Boolean(fresh.archived) === liveArchived) return
await updateDocument(config.collections.websiteProjects, fresh.$id, {
archived: liveArchived,
...(liveArchived
? { status: 'archived' }
: fresh.status === 'archived' ? { status: 'synced' } : {}),
updatedAt: new Date().toISOString(),
})
archiveSynced += 1
}, { waitMs: 10000 })
continue
}
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: repoArchived,
status: repoArchived ? 'archived' : 'synced',
giteaSyncedAt: new Date().toISOString(),
})
if (repoArchived) {
archivedImported += 1
return
}
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
} catch (err) {
failed.push(fullName)
console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message)
}
}
return { created, createdRepos, archivedImported, archiveSynced, failed }
})
}
@@ -104,6 +146,7 @@ export async function syncReposToAppwrite() {
const repos = await listAllAccessibleRepos()
let created = 0
let updated = 0
const failed = []
for (const repo of repos) {
const repoFullName = repo.full_name
@@ -115,25 +158,38 @@ export async function syncReposToAppwrite() {
])
const payload = {
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || repoFullName.split('/').pop(),
repoFullName,
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
archived: Boolean(repo.archived),
}
const repoTitle = repo.description || repo.name || repoFullName.split('/').pop()
if (!existing[0]) {
payload.status = 'synced'
payload.projectName = repoTitle
payload.status = repo.archived ? 'archived' : 'synced'
created += 1
} else {
// Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der
// Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation").
if (!existing[0].projectName) payload.projectName = repoTitle
// Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt
if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived'
if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced'
updated += 1
}
await upsertWebsiteProjectByRepo(repoFullName, payload)
try {
await upsertWebsiteProjectByRepo(repoFullName, payload)
} catch (err) {
// Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen
failed.push(repoFullName)
console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message)
}
}
return { synced: repos.length, created, updated }
return { synced: repos.length, created, updated, failed }
}
export async function ensureSubdomainAvailable(subdomain) {
@@ -148,7 +204,7 @@ export async function ensureSubdomainAvailable(subdomain) {
}
}
export async function generateRepoFromTemplate({ owner, repoName }) {
export async function generateRepoFromTemplate({ owner, repoName, description }) {
const templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner
@@ -162,8 +218,11 @@ export async function generateRepoFromTemplate({ owner, repoName }) {
body: JSON.stringify({
owner: targetOwner,
name: repoName,
private: false,
description: `Website-Projekt ${repoName}`,
// Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner
// lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners.
private: true,
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
description: description || `Website-Projekt ${repoName}`,
git_content: true,
}),
})
@@ -271,6 +330,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
return previewConfig
}
/**
* Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem
* Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview-
* Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein
* verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht.
*/
async function releaseLeftoverProjectName(fullName) {
try {
await giteaFetch(`/repos/${fullName}`)
return // Repo existiert: generate meldet gleich "existiert bereits"
} catch (err) {
if (err.status !== 404) return
}
const leftovers = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(10),
])
for (const old of leftovers) {
if (isUnlinkedProject(old)) {
await deleteDocument(config.collections.websiteProjects, old.$id)
continue
}
const error = new Error(
`Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` +
`${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.`
)
error.status = 409
throw error
}
}
export async function createProjectFromTemplate({
repoName,
displayName,
@@ -278,6 +368,8 @@ export async function createProjectFromTemplate({
customerId = '',
ticketId = '',
owner,
kind = '',
staffName = '',
}) {
const targetOwner = owner || config.gitea.defaultOwner
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
@@ -289,14 +381,22 @@ export async function createProjectFromTemplate({
if (!safeRepoName) {
throw new Error('repoName oder displayName ist erforderlich')
}
if (kind && !isKind(kind)) {
const error = new Error('Unbekannte Projektart.')
error.status = 400
throw error
}
if (hasWebsite) {
await ensureSubdomainAvailable(safeSubdomain)
}
await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`)
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
description: displayName || safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
@@ -324,7 +424,11 @@ export async function createProjectFromTemplate({
}
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
const project = await upsertWebsiteProjectByRepo(repoFullName, {
// Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config).
// Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide
// gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with
// the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten.
const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, {
customerId,
ticketId,
projectName: previewConfig.displayName,
@@ -332,9 +436,24 @@ export async function createProjectFromTemplate({
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName,
repoFullName,
subdomain: safeSubdomain,
// Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites
// Projekt mit '' scheiterte mit "Document with the requested ID already exists".
subdomain: safeSubdomain || null,
previewUrl,
projectType: hasWebsite ? 'website' : 'repo',
// Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem
projectType: hasWebsite ? 'preview' : 'repo',
archived: false,
// Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf
// "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment.
...(kind
? {
kind,
forCustomer: Boolean(customerId),
kindSetBy: staffName || '',
kindSetAt: new Date().toISOString(),
previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off',
}
: {}),
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
@@ -343,14 +462,14 @@ export async function createProjectFromTemplate({
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
}),
})
}), { waitMs: 10000 })
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
projectType: hasWebsite ? 'website' : 'repo',
projectType: hasWebsite ? 'preview' : 'repo',
deploy: deployResult,
}
}
@@ -494,7 +613,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
projectName: nextName,
subdomain: nextSubdomain,
subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate)
previewUrl,
projectType: nextType,
isPublic,
@@ -550,30 +669,48 @@ export async function archiveRepo(projectId, archived = true) {
const repoFullName = doc.repoFullName || ''
let repoMissing = false
if (repoFullName.includes('/')) {
try {
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
} catch (err) {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
if (err.status === 404) {
repoMissing = true
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
} else {
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
error.status = err.status || 500
throw error
// Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den
// Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben.
const locks = repoFullName ? [repoLockKey(repoFullName)] : []
let updated = await withLocks(locks, async () => {
if (repoFullName.includes('/')) {
try {
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
} catch (err) {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
if (err.status === 404) {
repoMissing = true
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
} else {
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
error.status = err.status || 500
throw error
}
}
}
}
if (archived && doc.subdomain) {
try { await removeDeploy(doc.subdomain) } catch (err) {
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
// Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
// setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
// Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an.
return updateDocument(config.collections.websiteProjects, projectId, {
archived: Boolean(archived),
status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'),
updatedAt: new Date().toISOString(),
})
}, { waitMs: 10000 })
if (archived) {
if (doc.subdomain) {
try { await removeDeploy(doc.subdomain) } catch (err) {
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
}
}
return { project: updated, archived: true }
}
if (!archived && !repoMissing && doc.subdomain) {
if (!repoMissing && doc.subdomain) {
let after = null
try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
@@ -583,19 +720,19 @@ export async function archiveRepo(projectId, archived = true) {
subdomain: doc.subdomain,
displayName: doc.projectName,
}
await runDeploy(repoFullName, branch, cfg, { project: doc })
const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc })
if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' }
else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet
else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' }
// queued: der Runner-Verfolger setzt den Status nach dem Bau
} catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
after = { provisioningStatus: 'deploy_failed' }
}
if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after)
}
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
archived: Boolean(archived),
...(archived ? { status: 'archived' } : {}),
updatedAt: new Date().toISOString(),
})
return { project: updated, archived: Boolean(archived) }
return { project: updated, archived: false }
}
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
+1 -1
View File
@@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) {
if (!response.ok) {
let message = data?.message || `Gitea ${response.status}`
if (response.status === 409 && /same name already exists/i.test(message)) {
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.'
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.'
}
const error = new Error(message)
error.status = response.status
+23 -6
View File
@@ -1,6 +1,6 @@
// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/):
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum.
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse), 'termine' und 'shop' (Leistungsbeschreibung, AVV und
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners bzw. Webshops). analyseLegal.js ist ein duenner Wrapper darum.
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
import { access, open, readFile, stat } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
@@ -32,13 +32,22 @@ const SAETZE = {
avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' },
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
},
shop: {
lb: { slug: 'leistungsbeschreibung-webshop', pdf: 'Leistungsbeschreibung-Webshop' },
avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' },
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
},
}
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
const FUNKTION_SATZ = { news: null, termine: 'termine' }
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' }
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion)
const aktuelleFassung = (satz) => {
if (satz === 'analyse') return config.analyse.avvVersion
if (satz === 'shop') return config.shop.fassung
return config.termine.avvVersion
}
function satzDokumente(satz) {
if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz')
@@ -66,9 +75,13 @@ export function legalPdfFiles(satz, version) {
)
}
/** Ein Anhang ist ein PDF: nicht leer und mit "%PDF-" am Anfang. */
const istPdf = (inhalt) => inhalt.length >= 5 && inhalt.subarray(0, 5).toString('latin1') === '%PDF-'
/**
* PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine
* Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein).
* Datei fehlt, nicht lesbar, leer oder kein PDF ist (nie ein leerer oder falscher Anhang). ohne: Dokumente, die nicht
* angehaengt werden (sie muessen nicht vorhanden sein).
*/
export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) {
const dateien = legalPdfFiles(satz, version)
@@ -78,7 +91,11 @@ export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) {
return Promise.all(
Object.entries(dateien)
.filter(([doc]) => !ohne.includes(doc))
.map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' }))
.map(async ([, f]) => {
const content = await readFile(f.path)
if (!istPdf(content)) throw new Error(`Rechtstexte: ${f.filename} ist leer oder kein PDF`)
return { filename: f.filename, content, contentType: 'application/pdf' }
})
)
}
+68
View File
@@ -804,7 +804,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
}
}
/**
* Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage)
* bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster
* Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes
* oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter.
* Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls.
* @returns {Promise<string>} Repo-Name bzw. Projekt des fremden Besitzers, sonst ''
*/
export async function foreignSubdomainOwner(repoFullName, subdomain) {
const own = String(repoFullName || '').toLowerCase()
// ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst
if (!subdomain || !own) return ''
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(5),
])
for (const doc of docs) {
const other = String(doc.repoFullName || '')
if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)`
if (other.toLowerCase() === own) continue
let resolved = ''
try {
resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '')
} catch {
// geloescht oder Gitea nicht erreichbar: im Zweifel fremd
}
if (resolved.toLowerCase() !== own) return other
}
return ''
}
/**
* Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht
* moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher
* streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite.
*/
export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) {
let owner
try {
owner = await foreignSubdomainOwner(repoFullName, subdomain)
} catch (err) {
if (!failOpen) throw err
console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message)
return null
}
if (!owner) return null
console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`)
return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner }
}
/**
* Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat
* (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen
* Projekts selbst ist.
*/
function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) {
if (subdomainChecked) return false
const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName))
}
export async function runDeploy(repoFullName, branch, previewConfig, options = {}) {
// Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den
// Verfolger eines laufenden Baus desselben Repos in Ruhe.
const target = previewConfig.subdomain || repoFullName.split('/').pop()
if (needsOwnerCheck(repoFullName, target, options)) {
const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true })
if (blocked) return blocked
}
if (config.previewRunner.enabled) {
return runDeployViaRunner(repoFullName, branch, previewConfig, options)
}
+4 -1
View File
@@ -149,7 +149,10 @@ export async function runRepoAssignJob({
const docs = await listDocuments(collection(), [Query.limit(5000)])
for (const p of docs) {
if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue
if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue
// Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern
// koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet".
if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) continue
// Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
const attempts = Number(p.assignMailAttempts || 0)
if (!p.assignMailSentAt && attempts < 3) {
+2
View File
@@ -59,6 +59,8 @@ function sameRepoFullName(a, b) {
export async function isInternalRepo(repoFullName) {
const lower = repoFullName.toLowerCase()
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true
// Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr
if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true
const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
const portalUrl = portalWebhookUrl()
+140 -34
View File
@@ -1,8 +1,9 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../config.js'
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js'
import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { deleteNewsImage } from './newsImages.js'
import { loescheShopDaten } from './shop/loeschen.js'
import { disable } from './siteFeatures.js'
import { getStripe } from './stripeBilling.js'
import { filesOf, STATUS } from './takeoverCredits.js'
@@ -21,6 +22,17 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
* - Funktionsbestellungen: Die Zustimmungsnachweise zu bestellten Funktionen (featureConsents, Datenschutzerklaerung Abschnitt 7:
* "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet") loescht derselbe Lauf fuer faellige
* Kunden mit (plan.news[].consentIds). Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig; ihre Nachweise bleiben, wie ihre
* Beitraege, ausserhalb dieser Frist.
* - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument
* (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren
* Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey).
* - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Archiv der bestaetigten Rechtstexte, Artikelbilder, shopConfig;
* plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der
* Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem
* Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js).
* - Oeffnungszeiten live: Zeigt der Plan Funktionen 'zeiten' (plan.news[].zeitenIds, Teilmenge von featureIds), loescht der Lauf vor dem
* Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures).
* Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter
@@ -36,6 +48,14 @@ const EVENTS = () => config.collections.contractEvents
const TAKEOVER = () => config.collections.takeoverCredits
const NEWS_POSTS = () => config.collections.newsPosts
const FEATURES = () => config.collections.portalFeatures
const CONSENTS = () => config.collections.featureConsents
const SHOP_CONFIG = () => config.collections.shopConfig
const SHOP_LOESCH_DECKEL = 2000
/** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */
const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt']
const BLANK_FEATURE_FIELDS = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }
const hasConsentFields = (doc) => FEATURE_CONSENT_FIELDS.some((key) => doc[key])
export const RETENTION_YEARS = 3
export const RECEIPT_YEARS = 8
@@ -69,9 +89,22 @@ function endOf(doc) {
return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd)
}
/** Shops (shopConfig) eines Kunden als { id, siteKey }. Gibt es die Collection noch nicht (Setup-Skript nicht gelaufen), gibt es keine Shops. */
async function shopsOf(customerId, list) {
try {
const docs = await list(SHOP_CONFIG(), [Query.equal('customerId', customerId)])
return docs.map((d) => ({ id: d.$id, siteKey: d.siteKey || '' }))
} catch (err) {
if (err?.type === 'collection_not_found' || err?.code === 'collection_not_found') return []
throw err
}
}
/**
* @param {Date} [now]
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
* news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[],
* featureFieldIds: string[] }[],
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
*/
@@ -115,12 +148,20 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
const news = []
for (const { customerId } of customers) {
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
if (!posts.length && !features.length) continue
const allFeatures = await list(FEATURES(), [Query.equal('customerId', customerId)])
const features = allFeatures.filter((f) => f.state !== 'disabled')
// Bestellangaben (IP, Browser, Text, Zeit) leeren wir an jedem Dokument des Kunden, auch am schon abgeschalteten
const withFields = allFeatures.filter(hasConsentFields)
const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)])
const shops = await shopsOf(customerId, list)
if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length) continue
news.push({
customerId,
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
featureIds: features.map((f) => f.$id),
consentIds: consents.map((c) => c.$id),
featureFieldIds: withFields.map((f) => f.$id),
...(shops.length ? { shops } : {}),
zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id),
})
}
@@ -166,18 +207,24 @@ async function deleteQuietly(collection, id) {
}
/**
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* Faellige Daten loeschen. Reihenfolge je Kunde: erst die abhaengigen Daten (News-Beitraege samt Fotos, Funktionen abschalten,
* Zustimmungsnachweise der Funktionsbestellungen), dann die Stripe-Metadaten (Abo-ID steht noch im Plan) und zuletzt die
* Appwrite-Dokumente der Abos (Vertragsereignisse, dann subscriptions). Grund: findDueDeletions leitet faellige Kunden allein aus den
* Abo-Dokumenten ab. Schlaegt ein Loeschen abhaengiger Daten fehl (oder das eines Vertragsereignisses), bleiben die Abo-Dokumente des
* Kunden stehen (result.held), der naechste Lauf findet den Kunden wieder und versucht es erneut; sonst blieben die uebrig gebliebenen
* Daten fuer immer liegen. Lehnt Stripe die Aenderung ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
* newsPosts: number, newsImages: number, features: number, zeiten: number },
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number,
* zeiten: number },
* manual: { customerId: string, subscriptionId: string, reason: string }[],
* failed: { what: string, id: string, reason: string }[] }>}
* failed: { what: string, id: string, reason: string }[], held: string[] }>}
*/
export async function applyDeletions(plan) {
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, zeiten: 0 }
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0 }
const manual = []
const failed = []
const held = []
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
const attempt = async (what, id, fn) => {
try {
@@ -188,30 +235,10 @@ export async function applyDeletions(plan) {
}
}
for (const customer of plan.customers || []) {
for (const id of customer.subscriptionIds) {
try {
await getStripe().subscriptions.update(id, { metadata: clear })
deleted.stripeCleared += 1
} catch (err) {
manual.push({
customerId: customer.customerId,
subscriptionId: id,
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
})
}
}
for (const id of customer.eventIds) {
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
}
for (const id of customer.subscriptionIds) {
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
}
}
for (const entry of plan.news || []) {
// News-Beitraege, Funktionen und Zustimmungsnachweise eines Kunden (Eintrag aus plan.news)
const deleteDependents = async (entry) => {
for (const post of entry.posts) {
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
// erst das Bild (Dokument bleibt bei Fehler), dann der Beitrag
if (post.imageId) {
const removed = await attempt('newsImages', post.imageId, async () => {
await deleteNewsImage(post.projectId, post.imageId)
@@ -242,8 +269,76 @@ export async function applyDeletions(plan) {
})
if (off) deleted.features += 1
}
// IP, Browser, Text und Zeit der Bestellung am Funktionsdokument (auch am abgeschalteten): das Dokument bleibt, die Angaben gehen
for (const id of entry.featureFieldIds || []) {
const blanked = await attempt('portalFeatures', id, async () => {
try {
await updateDocument(FEATURES(), id, BLANK_FEATURE_FIELDS)
return true
} catch (err) {
if (isGone(err)) return false
throw err
}
})
if (blanked) deleted.featureFields += 1
}
// Zustimmungsnachweise der Funktionsbestellungen: ein Fehler stoppt den Rest nicht
for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1
}
// Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten)
let shopRest = SHOP_LOESCH_DECKEL
for (const shop of entry.shops || []) {
await attempt('shopConfig', shop.id, async () => {
const weg = await loescheShopDaten({ shopId: shop.id, siteKey: shop.siteKey, deckel: shopRest })
shopRest -= weg.geloescht
deleted.shopDocs += weg.geloescht
if (!weg.vollstaendig) throw new Error('Shopdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter')
deleted.shops += 1
return true
})
}
}
const newsOf = new Map((plan.news || []).map((entry) => [entry.customerId, entry]))
for (const customer of plan.customers || []) {
const failedBefore = failed.length
const entry = newsOf.get(customer.customerId)
if (entry) {
newsOf.delete(customer.customerId)
await deleteDependents(entry)
}
if (failed.length > failedBefore) {
held.push(customer.customerId)
continue
}
for (const id of customer.subscriptionIds) {
try {
await getStripe().subscriptions.update(id, { metadata: clear })
deleted.stripeCleared += 1
} catch (err) {
manual.push({
customerId: customer.customerId,
subscriptionId: id,
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
})
}
}
for (const id of customer.eventIds) {
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
}
if (failed.length > failedBefore) {
// ein Vertragsereignis ist stehen geblieben: das Abo-Dokument bleibt, sonst wuerde das Ereignis zur Waise
held.push(customer.customerId)
continue
}
for (const id of customer.subscriptionIds) {
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
}
}
// Eintraege ohne Kunden-Eintrag im Plan (kein Abo-Dokument, das zu halten waere)
for (const entry of newsOf.values()) await deleteDependents(entry)
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
const removed = await attempt('takeoverCredits', item.id, async () => {
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
@@ -252,7 +347,7 @@ export async function applyDeletions(plan) {
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
}
return { deleted, manual, failed }
return { deleted, manual, failed, held }
}
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
@@ -268,8 +363,13 @@ export function formatReport(plan, result = null) {
}
for (const n of plan.news || []) {
const images = n.posts.filter((p) => p.imageId).length
const consents = n.consentIds?.length || 0
const fields = n.featureFieldIds?.length || 0
const shops = n.shops?.length || 0
const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : ''
lines.push(`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten${zeiten}`)
lines.push(
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}`
)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen')
const report = plan.takeover.filter((t) => t.action === 'melden')
@@ -286,12 +386,18 @@ export function formatReport(plan, result = null) {
lines.push(
'',
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`,
`Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`,
`Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`,
...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : [])
)
if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`)
if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
}
if (result.held?.length) {
lines.push('', 'Abo-Daten bleiben stehen, bis die abhängigen Daten gelöscht sind (der nächste Lauf findet den Kunden wieder):', ...result.held.map((id) => ` Kunde ${id}`))
}
if (result.manual.length) {
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
}
+307
View File
@@ -0,0 +1,307 @@
import { config } from '../../config.js'
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
import { shopFuerKunde } from './schalter.js'
/**
* Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team
* entscheidet (B2: annehmen oder ablehnen); erst eine angenommene Anfrage laesst die Bestellung der Funktion zu
* (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des
* portalFeatures-Dokuments des Projekts ist. anfrageStatus: '' (keine) | offen | angenommen | abgelehnt | zurueckgezogen.
* Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code.
*/
export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.'
export const WARENARTEN = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' }
export const ARTIKELZAHLEN = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
const BESCHREIBUNG_MIN = 10
const BESCHREIBUNG_MAX = 1500
const NACHRICHT_MAX = 1000
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.',
vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.',
laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.',
nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.',
nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.',
entscheidung: 'Bitte wähle annehmen oder ablehnen.',
begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).',
ungueltig: 'Ungültige Eingabe.',
}
const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) })
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/**
* Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen
* ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen
* Status ist die Anfrage nicht freigegeben. Lesefehler werden weitergereicht (der Checkout bestellt dann nicht).
*/
export async function anfrageOffenePunkte({ customerId, projectId }) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return [ANFRAGE_OFFEN_TEXT]
const shop = await leseShopConfig(doc.$id)
return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT]
}
/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */
export async function leseShopConfig(id) {
try {
return await getDocument(config.collections.shopConfig, id)
} catch (err) {
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null
throw err
}
}
/**
* shopConfig-Dokument zum portalFeatures-Dokument holen oder anlegen (ID gleich, siteKey kopiert, Anfrage leer, Version 0).
* Zwei gleichzeitige Anlagen: die zweite bekommt 409 und liest das Dokument der ersten.
*/
export async function ensureShopConfig(featureDoc) {
const vorhanden = await leseShopConfig(featureDoc.$id)
if (vorhanden) return vorhanden
try {
return await createDocument(
config.collections.shopConfig,
{
customerId: featureDoc.customerId,
projectId: featureDoc.projectId,
siteKey: featureDoc.siteKey,
anfrageStatus: '',
version: 0,
},
featureDoc.$id
)
} catch (err) {
if (err?.status === 409) {
const nochmal = await leseShopConfig(featureDoc.$id)
if (nochmal) return nochmal
}
throw err
}
}
/**
* Eingabe der Anfrage pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }.
* warenart, beschreibung (10-1500 Zeichen), artikelzahl, versand/abholung (mindestens eins), unternehmer und keineAusgeschlossenen
* muessen true sein, nachricht (optional, bis 1000 Zeichen).
*/
export function normalisiereAnfrage(eingabe) {
const fehler = {}
const e = istObjekt(eingabe) ? eingabe : {}
const text = (wert) => (typeof wert === 'string' ? wert.toWellFormed().trim() : '')
const warenart = typeof e.warenart === 'string' && Object.hasOwn(WARENARTEN, e.warenart) ? e.warenart : ''
if (!warenart) fehler.warenart = 'Bitte wähle, was du verkaufen möchtest.'
const beschreibung = text(e.beschreibung)
if (beschreibung.length < BESCHREIBUNG_MIN) fehler.beschreibung = `Bitte beschreibe kurz, was du verkaufen möchtest (mindestens ${BESCHREIBUNG_MIN} Zeichen).`
else if (beschreibung.length > BESCHREIBUNG_MAX) fehler.beschreibung = `Die Beschreibung darf höchstens ${BESCHREIBUNG_MAX} Zeichen lang sein.`
const artikelzahl = typeof e.artikelzahl === 'string' && Object.hasOwn(ARTIKELZAHLEN, e.artikelzahl) ? e.artikelzahl : ''
if (!artikelzahl) fehler.artikelzahl = 'Bitte wähle, wie viele Artikel du etwa anbieten möchtest.'
if (e.versand !== undefined && typeof e.versand !== 'boolean') fehler.versand = MELDUNG.ungueltig
if (e.abholung !== undefined && typeof e.abholung !== 'boolean') fehler.abholung = MELDUNG.ungueltig
const versand = e.versand === true
const abholung = e.abholung === true
if (!fehler.versand && !fehler.abholung && !versand && !abholung) fehler.versand = 'Bitte wähle Versand, Abholung oder beides.'
if (e.unternehmer !== true) fehler.unternehmer = 'Bitte bestätige, dass du als Unternehmer verkaufst.'
if (e.keineAusgeschlossenen !== true) fehler.keineAusgeschlossenen = 'Bitte bestätige, dass du keine ausgeschlossenen Waren verkaufst.'
let nachricht = ''
if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') {
if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig
else {
nachricht = text(e.nachricht)
if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.`
}
}
if (Object.keys(fehler).length) return { fehler }
return { anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, unternehmer: true, keineAusgeschlossenen: true, nachricht } }
}
/** Anfrage fuer den Browser: { status, anfrage, at, entschiedenAt, begruendung } oder null, solange es keine gab. Unlesbares JSON => anfrage null. */
export function anfrageAnsicht(shopDoc) {
const status = shopDoc?.anfrageStatus || ''
if (!status) return null
let anfrage = null
try {
const roh = typeof shopDoc.anfrage === 'string' && shopDoc.anfrage ? JSON.parse(shopDoc.anfrage) : null
anfrage = istObjekt(roh) ? roh : null
} catch {
anfrage = null
}
return {
status,
anfrage,
at: shopDoc.anfrageAt || null,
entschiedenAt: shopDoc.anfrageEntschiedenAt || null,
begruendung: shopDoc.anfrageBegruendung || '',
}
}
/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */
async function pruefeZugang({ customerId, projectId, person, neu }) {
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
let project
try {
project = await assertOwnProject(customerId, projectId, { forNewOrder: neu })
} catch (err) {
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
}
if (!shopFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
return project
}
/**
* Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre
* je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen`
* und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt).
* @returns {Promise<object>} anfrageAnsicht des neuen Standes
*/
export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) {
const project = await pruefeZugang({ customerId, projectId, person, neu: true })
const { anfrage, fehler } = normalisiereAnfrage(eingabe)
if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler })
const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => {
const feature = await ensureFeatureDoc(customerId, projectId, 'shop')
if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden')
const shop = await ensureShopConfig(feature)
if (!['', 'abgelehnt', 'zurueckgezogen'].includes(shop.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft')
return updateDocument(config.collections.shopConfig, shop.$id, {
anfrage: JSON.stringify(anfrage),
anfrageStatus: 'offen',
anfrageAt: now.toISOString(),
anfrageEntschiedenAt: null,
anfrageEntschiedenVon: '',
anfrageBegruendung: '',
version: (Number.isInteger(shop.version) ? shop.version : 0) + 1,
})
})
await meldeTeam({ customerId, projectId, project, anfrage, person })
return anfrageAnsicht(gespeichert)
}
/** T21 "Neue Webshop-Anfrage" an das Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log (die Anfrage ist gespeichert). */
async function meldeTeam({ customerId, projectId, project, anfrage, person }) {
try {
let kundeName = String(person?.name || '').trim()
if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim()
const lieferung = [anfrage.versand ? 'Versand' : '', anfrage.abholung ? 'Abholung' : ''].filter(Boolean).join(' und ')
const result = await sendAdminNotice({
template: 'T21',
values: {
kunde_name: kundeName || 'Kunde',
email: person?.email || '',
projekt: project?.projectName || projectId,
warenart: WARENARTEN[anfrage.warenart],
beschreibung: anfrage.beschreibung,
artikelzahl: ARTIKELZAHLEN[anfrage.artikelzahl],
lieferung,
nachricht: anfrage.nachricht || '–',
woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`,
},
})
if (!result?.sent) console.warn(`[shop] Team-Mail T21 nicht gesendet: ${result?.reason || 'unbekannt'}`)
} catch (err) {
console.warn(`[shop] Team-Mail T21 nicht gesendet: ${err?.message}`)
}
}
/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise<object>} anfrageAnsicht */
export async function zieheZurueck({ customerId, projectId, person = {} }) {
await pruefeZugang({ customerId, projectId, person, neu: false })
const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => {
const feature = await getFeatureDoc(customerId, projectId, 'shop')
const shop = feature ? await leseShopConfig(feature.$id) : null
if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen')
return updateDocument(config.collections.shopConfig, shop.$id, {
anfrageStatus: 'zurueckgezogen',
version: (Number.isInteger(shop.version) ? shop.version : 0) + 1,
})
})
return anfrageAnsicht(gespeichert)
}
const BEGRUENDUNG_MAX = 1000
/** 'Name <email>' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */
const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin')
/**
* Das Team entscheidet eine offene Anfrage (B2): annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je
* Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Speichert Status, Zeit und Person
* (anfrageEntschiedenVon), bei annehmen eine leere Begruendung. Danach geht K24 an den Kunden (BCC Team); scheitert die Mail, bleibt
* die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`.
* @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes
*/
export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) {
if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung)
let grund = ''
if (entscheidung === 'ablehnen') {
grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : ''
if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung)
}
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
const { gespeichert, project } = await withLocks([`shop-anfrage:${projectId}`], async () => {
let besitz
try {
besitz = await assertOwnProject(customerId, projectId)
} catch (err) {
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
}
const feature = await getFeatureDoc(customerId, projectId, 'shop')
const shop = feature ? await leseShopConfig(feature.$id) : null
if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen')
const neu = await updateDocument(config.collections.shopConfig, shop.$id, {
anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt',
anfrageEntschiedenAt: now.toISOString(),
anfrageEntschiedenVon: personLabel(person).slice(0, 255),
anfrageBegruendung: grund,
version: (Number.isInteger(shop.version) ? shop.version : 0) + 1,
})
return { gespeichert: neu, project: besitz }
})
const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund }))
return { anfrage: anfrageAnsicht(gespeichert), mailFehler }
}
/** K24 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */
async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) {
try {
const kunde = await customerOf(customerId)
if (!kunde?.email) {
console.warn(`[shop] K24 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`)
return false
}
await sendCustomerMail(kunde.email, {
template: 'K24',
values: {
kunde_name: String(kunde.name || '').trim() || 'Kunde',
projekt: project?.projectName || projectId,
preis: config.features.shop.priceLabel,
ust_hinweis: config.ustHinweis,
begruendung,
portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(projectId)}`,
},
blocks: { angenommen, abgelehnt: !angenommen },
})
return true
} catch (err) {
console.warn(`[shop] K24 nicht gesendet: ${err?.message}`)
return false
}
}
+60
View File
@@ -0,0 +1,60 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { createDocument, getDocument } from '../appwriteAdmin.js'
/**
* Archiv der bestaetigten Rechtstexte eines Shops (Review B, I-6). Beim Bestaetigen wird die Fassung samt Hash abgelegt; G09 zeigt dem
* Kaeufer die Fassung zum Hash seiner Bestellung (bestellung.rechtstexteHash), nicht die beim Mailversand aktuelle. So passen Mail und
* Bestellung zusammen, und der Haendler kann spaeter belegen, welche Belehrung ein Kaeufer bekam (§ 356 Abs. 3 BGB).
* Eine Fassung je Dokument, ID = 'r' + die ersten 35 Hex-Zeichen von sha256(shopId + hash); dieselbe Fassung ein zweites Mal
* (409) ist kein Fehler. Inhalt (texte, JSON): agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl,
* versandUndZahlung, gewaehrleistung.
*/
const col = () => config.collections.shopRechtstexteArchiv
/** Dokument-ID der Fassung (hoechstens 36 Zeichen, nur Hex nach dem 'r'). */
export const archivId = (shopId, hash) => `r${createHash('sha256').update(`${String(shopId)}${String(hash)}`).digest('hex').slice(0, 35)}`
/**
* Legt die Fassung an, falls es sie noch nicht gibt.
* @param {{ shopId: string, customerId: string, hash: string, texte: object, at: string, von: string }} p
* @returns {Promise<{ angelegt: boolean }>}
*/
export async function archiviereRechtstexte({ shopId, customerId, hash, texte, at, von }) {
if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) throw new TypeError('archiviereRechtstexte: shopId oder hash fehlt')
try {
await createDocument(
col(),
{ shopId, customerId: String(customerId ?? ''), hash, texte: JSON.stringify(texte), bestaetigtAt: at, bestaetigtVon: String(von ?? '').slice(0, 255) },
archivId(shopId, hash),
)
return { angelegt: true }
} catch (err) {
if (err?.status === 409) return { angelegt: false }
throw err
}
}
/**
* Die archivierte Fassung zum Hash oder null, wenn es sie nicht gibt (zum Beispiel bei Bestellungen vor der Einfuehrung des Archivs).
* Andere Fehler (Datenbank) werfen, damit G09 nicht mit falschen Texten rausgeht, sondern wiederholt wird.
* @returns {Promise<object|null>}
*/
export async function holeArchivierteRechtstexte(shopId, hash) {
if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || hash === '') return null
let doc
try {
doc = await getDocument(col(), archivId(shopId, hash))
} catch (err) {
if (err?.status === 404) return null
throw err
}
if (doc?.shopId !== shopId || doc?.hash !== hash) return null
try {
const texte = JSON.parse(doc.texte)
return texte !== null && typeof texte === 'object' && !Array.isArray(texte) ? texte : null
} catch {
return null
}
}
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import sharp from 'sharp'
import { config } from '../../config.js'
/**
* Artikelbilder des Webshops (Plan 2026-10-05-webshop.md, Task C7; verallgemeinert aus services/newsImages.js, das unveraendert
* bleibt). Jedes Bild wird neu kodiert (WebP, laengste Kante 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS,
* Kamera, Drehung) weg. Nur JPEG, PNG und WebP, hoechstens 8 MB.
*
* Ablage: <config.shop.uploadDir>/<siteKey>/<id>-l.webp, <id>-s.webp und Sidecar <id>.json
* { customerId, shopId, createdAt, claimed, w, h }. Der Sidecar wird zuletzt geschrieben: ohne ihn gilt ein Bild als nicht
* vorhanden. Hochgeladene, nie an einen Artikel gebundene Bilder loescht raeumeUnbeanspruchteAuf() nach 24 h.
* Fehler tragen status (400/413/415) und einen deutschen Text.
*/
export const BILD_MAX_BYTES = 8 * 1024 * 1024
const UNBEANSPRUCHT_MS = 24 * 60 * 60 * 1000
const MAX_UPLOADS_PRO_STUNDE = 40
const ID_RE = /^[a-z0-9-]{1,64}$/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen
const MAX_INPUT_PIXELS = 40_000_000
// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern
const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i
const FORMATE = new Set(['jpeg', 'png', 'webp'])
const fehler = (status, text) => Object.assign(new Error(text), { status })
const idOk = (id) => typeof id === 'string' && ID_RE.test(id)
const keyOk = (siteKey) => typeof siteKey === 'string' && SITE_KEY_RE.test(siteKey)
const wurzel = () => path.resolve(config.shop.uploadDir)
const ordner = (siteKey) => path.join(wurzel(), siteKey)
const sidecar = (siteKey, id) => path.join(ordner(siteKey), `${id}.json`)
/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberem siteKey, unsauberer ID oder Groesse. */
export function bildPfad(siteKey, imageId, groesse) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
if (groesse !== 'l' && groesse !== 's') return null
return path.join(ordner(siteKey), `${imageId}-${groesse}.webp`)
}
/**
* Bild kodieren und ablegen.
* @returns {Promise<{ imageId: string, w: number, h: number }>}
*/
export async function speichereBild({ customerId, shopId, siteKey, buffer }) {
if (!keyOk(siteKey)) throw fehler(400, 'Ungültiger Shop.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw fehler(415, 'Bitte lade ein Foto hoch (JPEG, PNG oder WebP).')
if (buffer.length > BILD_MAX_BYTES) throw fehler(413, 'Das Bild ist größer als 8 MB.')
const nichtUnterstuetzt = () => fehler(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.')
let gross, klein
try {
const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }
const meta = await sharp(buffer, opts).metadata()
if (!FORMATE.has(meta.format)) throw nichtUnterstuetzt()
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
gross = await sharp(buffer, opts).rotate()
.resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
// Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals)
klein = await sharp(gross.data)
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 75 }).toBuffer()
} catch (err) {
if (err.status) throw err
if (DECODE_ERROR_RE.test(String(err && err.message))) throw nichtUnterstuetzt()
console.error(`[shop] Bild-Verarbeitung fehlgeschlagen: ${String(err?.message ?? '').slice(0, 200)}`)
throw err
}
const imageId = randomUUID()
const { width: w, height: h } = gross.info
await mkdir(ordner(siteKey), { recursive: true })
await writeFile(bildPfad(siteKey, imageId, 'l'), gross.data)
await writeFile(bildPfad(siteKey, imageId, 's'), klein)
await schreibeSidecar(siteKey, imageId, { customerId, shopId, createdAt: new Date().toISOString(), claimed: false, w, h })
return { imageId, w, h }
}
async function schreibeSidecar(siteKey, imageId, meta) {
const datei = sidecar(siteKey, imageId)
await writeFile(`${datei}.tmp`, JSON.stringify(meta))
await rename(`${datei}.tmp`, datei)
}
/** Sidecar eines Bildes { customerId, shopId, createdAt, claimed, w, h } oder null, wenn es das Bild nicht gibt. */
export async function bildMeta(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return null
try {
const meta = JSON.parse(await readFile(sidecar(siteKey, imageId), 'utf8'))
return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : null
} catch {
return null
}
}
/** Markiert Bilder als an einen Artikel gebunden (werden nicht mehr aufgeraeumt). Unbekannte IDs werden uebergangen. */
export async function beanspruche(siteKey, ids) {
for (const id of Array.isArray(ids) ? ids : []) {
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
await schreibeSidecar(siteKey, id, { ...meta, claimed: true })
}
}
/** Loescht ein Bild (beide Groessen und Sidecar); fehlt es, passiert nichts. */
export async function loescheBild(siteKey, imageId) {
if (!keyOk(siteKey) || !idOk(imageId)) return
await Promise.all([
rm(bildPfad(siteKey, imageId, 'l'), { force: true }),
rm(bildPfad(siteKey, imageId, 's'), { force: true }),
rm(sidecar(siteKey, imageId), { force: true }),
rm(`${sidecar(siteKey, imageId)}.tmp`, { force: true }),
])
}
/** Loescht alle Bilder eines Shops (Ordner samt Inhalt), z. B. beim Entfernen der Shop-Daten. */
export async function loescheAlleBilder(siteKey) {
if (!keyOk(siteKey)) return
await rm(ordner(siteKey), { recursive: true, force: true })
}
/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind (Sidecar ohne Datum gilt als alt). Gibt die Anzahl zurueck. */
export async function raeumeUnbeanspruchteAuf(now = new Date()) {
const jetzt = now instanceof Date ? now.getTime() : Number(now)
const shops = await readdir(wurzel()).catch(() => [])
let entfernt = 0
for (const siteKey of shops) {
if (!keyOk(siteKey)) continue
const dateien = await readdir(ordner(siteKey)).catch(() => [])
for (const datei of dateien) {
if (!datei.endsWith('.json')) continue
const id = datei.slice(0, -5)
if (!idOk(id)) continue
const meta = await bildMeta(siteKey, id)
if (!meta || meta.claimed === true) continue
const erstellt = Date.parse(meta.createdAt)
if (Number.isFinite(erstellt) && jetzt - erstellt <= UNBEANSPRUCHT_MS) continue
await loescheBild(siteKey, id)
entfernt++
}
}
return entfernt
}
// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess). Jeder Aufruf zaehlt.
const uploads = new Map()
export function bildLimitErreicht(customerId) {
const jetzt = Date.now()
for (const [key, zeiten] of uploads) {
const frisch = zeiten.filter((t) => jetzt - t < 60 * 60 * 1000)
if (frisch.length === 0) uploads.delete(key)
else if (frisch.length !== zeiten.length) uploads.set(key, frisch)
}
const liste = uploads.get(customerId) || []
liste.push(jetzt)
uploads.set(customerId, liste)
return liste.length > MAX_UPLOADS_PRO_STUNDE
}
/** Setzt den Upload-Zaehler zurueck (Tests). */
export function setzeBildLimitZurueck() {
uploads.clear()
}
+194
View File
@@ -0,0 +1,194 @@
import { config } from '../../config.js'
import { getStripe } from '../stripeBilling.js'
/**
* Stripe Connect fuer den Webshop (Plan 2026-10-05-webshop.md, Task D1). Der Haendler verkauft ueber ein eigenes
* Stripe-Konto (Accounts v1 mit controller-Eigenschaften = Standard-aehnlich: eigenes Voll-Dashboard, Stripe-Gebuehren
* beim Haendler, Stripe haftet fuer Negativsalden). Zahlungen sind Direct Charges: alle Aufrufe auf Ressourcen des Kontos
* (Checkout, Erstattung) tragen { stripeAccount: accountId }. Konto selbst und Kontolinks sind Plattform-Aufrufe.
* Fehler: Error mit status (400 = ungueltige Eingabe/Signatur, 503 = nicht eingerichtet), Texte deutsch und ohne Kundendaten.
*/
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
const SESSION_RE = /^cs_[A-Za-z0-9_]+$/
const PAYMENT_INTENT_RE = /^pi_[A-Za-z0-9_]+$/
const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
function fehler(status, text) {
const err = new Error(text)
err.status = status
return err
}
function pruefeKonto(accountId) {
if (typeof accountId !== 'string' || !ACCOUNT_RE.test(accountId)) throw fehler(400, 'Ungültige Stripe-Konto-ID.')
return accountId
}
function pruefeSession(id) {
if (typeof id !== 'string' || !SESSION_RE.test(id)) throw fehler(400, 'Ungültige Checkout-Sitzung.')
return id
}
/** https-Adresse der Live-Domain des Projekts (ohne Pfad), sonst undefined. */
function liveUrl(project) {
const host = String(project?.liveDomain || '')
.trim()
.toLowerCase()
.replace(/^[a-z]+:\/\//, '')
.replace(/[/?#].*$/, '')
return HOST_RE.test(host) ? `https://${host}` : undefined
}
/** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */
export async function kontoAnlegen({ customer, project }) {
const customerId = String(customer?.$id || customer?.id || '')
const projectId = String(project?.$id || project?.id || '')
if (!projectId) throw fehler(400, 'Projekt fehlt.')
const url = liveUrl(project)
const name = String(project?.projectName || project?.subdomain || '').trim().slice(0, 100)
const businessProfile = { ...(name ? { name } : {}), ...(url ? { url } : {}) }
const account = await getStripe().accounts.create(
{
country: 'DE',
...(customer?.email ? { email: String(customer.email) } : {}),
...(Object.keys(businessProfile).length ? { business_profile: businessProfile } : {}),
controller: {
fees: { payer: 'account' },
losses: { payments: 'stripe' },
stripe_dashboard: { type: 'full' },
requirement_collection: 'stripe',
},
capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
metadata: { webklar: 'shop', customerId, projectId },
},
{ idempotencyKey: `shop-konto-${projectId}` }
)
return account.id
}
/** Einmaliger Onboarding-Link (laeuft nach wenigen Minuten ab, nie per Mail verschicken). */
export async function onboardingLink(accountId, { returnUrl, refreshUrl }) {
pruefeKonto(accountId)
const link = await getStripe().accountLinks.create({
account: accountId,
type: 'account_onboarding',
refresh_url: refreshUrl,
return_url: returnUrl,
collection_options: { fields: 'eventually_due' },
})
return link.url
}
const MAX_EINTRAEGE = 30
const MAX_EINTRAG_ZEICHEN = 100
/** Die Spalte stripeStatus fasst 4000 Zeichen; darunter bleibt Platz fuer den Hinweis zu Klarna/SEPA (merkeHinweis). */
const MAX_STATUS_JSON = 3500
const kapp = (liste) =>
(Array.isArray(liste) ? liste : []).filter((e) => typeof e === 'string').slice(0, MAX_EINTRAEGE).map((e) => e.slice(0, MAX_EINTRAG_ZEICHEN))
/**
* Bereitschaftsstatus aus einem Konto-Objekt (auch fuer den Webhook account.updated). Rein bis auf die Zeit. Die Listen werden
* auf 30 Eintraege zu je 100 Zeichen gekappt und notfalls weiter gekuerzt, damit das JSON in die Spalte stripeStatus (4000 Zeichen) passt.
*/
export function statusAusKonto(account, now = new Date()) {
const req = account?.requirements || {}
const status = {
charges_enabled: account?.charges_enabled === true,
payouts_enabled: account?.payouts_enabled === true,
details_submitted: account?.details_submitted === true,
currently_due: kapp(req.currently_due),
past_due: kapp(req.past_due),
disabled_reason: typeof req.disabled_reason === 'string' ? req.disabled_reason.slice(0, MAX_EINTRAG_ZEICHEN) : (req.disabled_reason ?? null),
geprueftAt: new Date(now).toISOString(),
}
while (JSON.stringify(status).length > MAX_STATUS_JSON) {
const laenger = status.currently_due.length >= status.past_due.length ? status.currently_due : status.past_due
if (laenger.length === 0) break
laenger.pop()
}
return status
}
export async function kontoStatus(accountId) {
pruefeKonto(accountId)
return statusAusKonto(await getStripe().accounts.retrieve(accountId))
}
/** Checkout-Sitzung auf dem Haendlerkonto (Direct Charge). */
export async function checkoutAnlegen(accountId, params, { idempotencyKey } = {}) {
pruefeKonto(accountId)
const session = await getStripe().checkout.sessions.create(params, {
stripeAccount: accountId,
...(idempotencyKey ? { idempotencyKey } : {}),
})
return { id: session.id, url: session.url }
}
export async function sessionHolen(accountId, id, { expand } = {}) {
pruefeKonto(accountId)
pruefeSession(id)
return getStripe().checkout.sessions.retrieve(id, expand?.length ? { expand } : {}, { stripeAccount: accountId })
}
/** Offene Sitzung beenden (Stripe lehnt ab, wenn sie nicht mehr offen ist). */
export async function sessionAblaufen(accountId, id) {
pruefeKonto(accountId)
pruefeSession(id)
return getStripe().checkout.sessions.expire(id, {}, { stripeAccount: accountId })
}
/** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */
export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) {
pruefeKonto(accountId)
if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.')
if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.')
const text = typeof grund === 'string' ? grund.trim().slice(0, 500) : ''
return getStripe().refunds.create(
{
payment_intent: paymentIntent,
...(betragCent !== undefined ? { amount: betragCent } : {}),
reason: 'requested_by_customer',
metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) },
},
{ stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) }
)
}
/**
* Prueft Signatur des Connect-Endpoints und liefert das Ereignis. 503 ohne Secret, 400 bei ungueltiger Signatur.
* Mit Live-Schluessel zaehlen nur Live-Ereignisse (Connect-Endpoints bekommen auch Test-Ereignisse): sonst null, der
* Aufrufer ignoriert sie (200 ohne Wirkung).
*/
export function connectEreignis(rawBody, signatur) {
const secret = config.shop.connectWebhookSecret
if (!secret) throw fehler(503, 'Der Connect-Webhook ist nicht eingerichtet.')
const stripe = getStripe()
let event
try {
event = stripe.webhooks.constructEvent(rawBody, signatur, secret)
} catch {
throw fehler(400, 'Ungültige Webhook-Signatur.')
}
if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null
return event
}
/**
* Neuer Statuswert fuer das Speichern: ein gemerkter Hinweis (Klarna/SEPA beim Haendler nicht freigeschaltet) bleibt, solange das
* Konto Zahlungen annimmt und der Shop Klarna/SEPA anbietet; sonst gilt der frisch gelesene Status. Gleiche Regel wie
* POST /api/shop/stripe/aktualisieren.
*/
export function statusMitHinweis(alt, status, zahlungsarten) {
const arten = Array.isArray(zahlungsarten) ? zahlungsarten : ['card']
const behalte = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && arten.some((z) => z === 'klarna' || z === 'sepa')
return behalte ? { ...status, hinweis: alt.hinweis } : status
}
/** Alle Erstattungen einer Zahlung des Haendlerkontos (bis 100; Stripe liefert sie im charge.refunded-Ereignis nicht immer mit). */
export async function erstattungenZurZahlung(accountId, paymentIntent) {
pruefeKonto(accountId)
if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.')
const liste = await getStripe().refunds.list({ payment_intent: paymentIntent, limit: 100 }, { stripeAccount: accountId })
return Array.isArray(liste?.data) ? liste.data : []
}
+334
View File
@@ -0,0 +1,334 @@
import { config } from '../../config.js'
import { getDocument, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
/**
* Shop-Einstellungen (Plan 2026-10-05-webshop.md, Task C2; shopConfig.einstellungen als JSON-Text).
* Normalisieren ist rein: Eingabe => { einstellungen } mit frischem, vollstaendigem Objekt oder { fehler: { 'feld.pfad': 'Text' } }
* (Texte in Du-Form, das Portal spricht den Haendler an). Leere Pflichtangaben sind beim Normalisieren erlaubt, damit der Haendler
* Schritt fuer Schritt speichern kann; ob alles da ist, sagt istVollstaendig() (u. a. muss kleinunternehmer true oder false sein;
* null = noch nicht gewaehlt, rechnet wie false). Geld ist immer ganze Cent.
*/
const ZAHLUNGSARTEN = ['card', 'klarna', 'sepa_debit']
const LAENDER = ['DE', 'AT']
const MAX_VERSAND_CENT = 100_000
const MAX_CENT = 10_000_000
const PLZ_RE = { DE: /^\d{5}$/, AT: /^\d{4}$/ }
const USTID_RE = /^(DE\d{9}|ATU\d{8})$/
const AKZENT_RE = /^#[0-9a-fA-F]{6}$/
const tief = (wert) => {
if (Array.isArray(wert)) return Object.freeze(wert.map(tief))
if (wert && typeof wert === 'object') return Object.freeze(Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tief(v)])))
return wert
}
export const STANDARD_EINSTELLUNGEN = tief({
shopname: '',
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
// null = noch nicht gewaehlt: der Haendler muss sich bewusst entscheiden (istVollstaendig verlangt true oder false)
kleinunternehmer: null,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
})
/** Tiefe, veraenderbare Kopie (JSON-tauglich). */
const kopie = (wert) => JSON.parse(JSON.stringify(wert))
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Nur eigene Schluessel lesen, nie aus der Prototypkette (zB __proto__ aus JSON.parse bleibt ein eigener Schluessel, wird aber nie gelesen). */
const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined)
// Steuerzeichen und unsichtbare Formatzeichen (Zero-Width, Richtungsmarken, BOM, Soft Hyphen) sowie Zeilen-/Absatztrenner
const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu
/** Bereinigter Text: ohne Steuerzeichen, getrimmt; einzeilig macht aus Umbruechen Leerzeichen, mehrzeilig behaelt einzelne Umbrueche. */
function bereinige(wert, { mehrzeilig = false } = {}) {
if (typeof wert !== 'string') return ''
let s = wert.replace(/\r\n?/g, '\n').replace(/\t/g, ' ')
s = mehrzeilig ? s.replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/[ ]*\n[ ]*/g, '\n').replace(/\n{3,}/g, '\n\n') : s.replace(/\n+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ')
return s.trim()
}
const laenge = (s) => [...s].length
/** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und mindestens ein Punkt in der Domain, hoechstens 254 Zeichen. */
function emailAdresse(wert) {
const s = bereinige(wert).toLowerCase()
if (s.length === 0 || s.length > 254) return null
return /^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s) ? s : null
}
/**
* Pruefen und Normalisieren. Fehlende Felder bekommen die Standardwerte, unbekannte Schluessel werden ignoriert,
* das Ergebnis teilt keine Referenzen mit der Eingabe.
* @returns {{einstellungen: object} | {fehler: Object<string, string>}}
*/
export function normalisiereEinstellungen(eingabe) {
if (!istObjekt(eingabe)) return { fehler: { einstellungen: 'Die Einstellungen fehlen oder sind unlesbar.' } }
const fehler = {}
const fehl = (pfad, text) => {
if (!(pfad in fehler)) fehler[pfad] = text
}
const std = STANDARD_EINSTELLUNGEN
/** Textfeld bis max Zeichen; leer erlaubt, ausser min > 0 (dann leer oder zu kurz = Fehler nur bei nicht leerem Text). */
const textFeld = (pfad, wert, { max, min = 0, mehrzeilig = false, vorgabe = '', name }) => {
if (wert === undefined || wert === null) return vorgabe
if (typeof wert !== 'string') {
fehl(pfad, `Bitte gib ${name} als Text an.`)
return vorgabe
}
const s = bereinige(wert, { mehrzeilig })
if (laenge(s) > max) {
fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`)
return vorgabe
}
if (s !== '' && laenge(s) < min) {
fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} muss mindestens ${min} Zeichen lang sein.`)
return vorgabe
}
return s
}
const wahrheit = (pfad, wert, vorgabe, name) => {
if (wert === undefined || wert === null) return vorgabe
if (typeof wert !== 'boolean') {
fehl(pfad, `Bitte wähle bei „${name}“ ja oder nein.`)
return vorgabe
}
return wert
}
/** null oder ganze Zahl 1..max (Cent). */
const centOderNull = (pfad, wert, name) => {
if (wert === undefined || wert === null) return null
if (!Number.isSafeInteger(wert) || wert <= 0 || wert > MAX_CENT) {
fehl(pfad, `${name} muss ein Betrag über 0 € (in ganzen Cent) sein – oder leer, wenn es keinen geben soll.`)
return null
}
return wert
}
const shopname = textFeld('shopname', hole(eingabe, 'shopname'), { max: 80, name: 'der Shopname' })
// Anbieter
const a = hole(eingabe, 'anbieter')
const land = (() => {
const w = hole(a, 'land')
if (w === undefined || w === null) return std.anbieter.land
if (!LAENDER.includes(w)) {
fehl('anbieter.land', 'Bitte wähle Deutschland oder Österreich als Land deines Unternehmens.')
return std.anbieter.land
}
return w
})()
const anbieter = {
name: textFeld('anbieter.name', hole(a, 'name'), { max: 120, name: 'der Name des Anbieters' }),
strasse: textFeld('anbieter.strasse', hole(a, 'strasse'), { max: 120, name: 'die Straße' }),
plz: '',
ort: textFeld('anbieter.ort', hole(a, 'ort'), { max: 80, name: 'der Ort' }),
land,
email: '',
telefon: textFeld('anbieter.telefon', hole(a, 'telefon'), { max: 40, name: 'die Telefonnummer' }),
ustId: '',
}
const plzRoh = hole(a, 'plz')
if (plzRoh !== undefined && plzRoh !== null) {
const plz = bereinige(plzRoh)
if (plz === '') anbieter.plz = ''
else if (PLZ_RE[land].test(plz)) anbieter.plz = plz
else fehl('anbieter.plz', land === 'AT' ? 'Bitte gib eine österreichische Postleitzahl mit 4 Ziffern an.' : 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.')
}
const emailRoh = hole(a, 'email')
if (emailRoh !== undefined && emailRoh !== null && bereinige(emailRoh) !== '') {
const mail = emailAdresse(emailRoh)
if (mail) anbieter.email = mail
else fehl('anbieter.email', 'Bitte gib genau eine gültige E-Mail-Adresse an (höchstens 254 Zeichen).')
}
const ustRoh = hole(a, 'ustId')
if (ustRoh !== undefined && ustRoh !== null) {
const ust = bereinige(ustRoh).replace(/\s+/g, '').toUpperCase()
if (ust === '') anbieter.ustId = ''
else if (USTID_RE.test(ust)) anbieter.ustId = ust
else fehl('anbieter.ustId', 'Die USt-IdNr. sieht so aus: DE123456789 oder ATU12345678. Lass das Feld leer, wenn du keine hast.')
}
const kleinunternehmer = wahrheit('kleinunternehmer', hole(eingabe, 'kleinunternehmer'), std.kleinunternehmer, 'Kleinunternehmer')
// Versand
const v = hole(eingabe, 'versand')
const versandAktiv = wahrheit('versand.aktiv', hole(v, 'aktiv'), std.versand.aktiv, 'Versand anbieten')
let laender = [...std.versand.laender]
const laenderRoh = hole(v, 'laender')
if (laenderRoh !== undefined && laenderRoh !== null) {
const menge = Array.isArray(laenderRoh) ? new Set(laenderRoh) : null
if (menge && menge.has('DE') && [...menge].every((l) => LAENDER.includes(l))) laender = LAENDER.filter((l) => menge.has(l))
else fehl('versand.laender', 'Du kannst nach Deutschland oder nach Deutschland und Österreich liefern. Deutschland ist immer dabei.')
}
const kostenRoh = hole(v, 'kostenCent')
const kostenCent = {}
for (const l of laender) {
const w = hole(kostenRoh, l)
if (w === undefined || w === null) {
if (versandAktiv && l !== 'DE') fehl(`versand.kostenCent.${l}`, `Bitte gib die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} an.`)
kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990
} else if (!Number.isSafeInteger(w) || w < 0 || w > MAX_VERSAND_CENT) {
fehl(`versand.kostenCent.${l}`, `Die Versandkosten für ${l === 'AT' ? 'Österreich' : 'Deutschland'} müssen zwischen 0 € und 1.000 € liegen (in ganzen Cent).`)
kostenCent[l] = l === 'DE' ? std.versand.kostenCent.DE : 990
} else kostenCent[l] = w
}
const freiAbCent = centOderNull('versand.freiAbCent', hole(v, 'freiAbCent'), 'Die Grenze für versandkostenfreie Lieferung')
// Abholung
const ab = hole(eingabe, 'abholung')
const abholungAktiv = wahrheit('abholung.aktiv', hole(ab, 'aktiv'), std.abholung.aktiv, 'Abholung anbieten')
const abholung = {
aktiv: abholungAktiv,
adresse: textFeld('abholung.adresse', hole(ab, 'adresse'), { max: 300, mehrzeilig: true, name: 'die Abholadresse' }),
hinweis: textFeld('abholung.hinweis', hole(ab, 'hinweis'), { max: 300, mehrzeilig: true, name: 'der Abholhinweis' }),
}
if (abholungAktiv && abholung.adresse === '' && !('abholung.adresse' in fehler)) fehl('abholung.adresse', 'Bitte gib an, wo die Ware abgeholt werden kann.')
if (!versandAktiv && !abholungAktiv && !('versand.aktiv' in fehler) && !('abholung.aktiv' in fehler)) {
fehl('versand.aktiv', 'Bitte biete Versand oder Abholung an – mindestens eins von beiden muss aktiv sein.')
}
const lieferzeit = textFeld('lieferzeit', hole(eingabe, 'lieferzeit'), { max: 80, min: 3, vorgabe: std.lieferzeit, name: 'die Lieferzeit' })
// Zahlungsarten: card immer dabei
let zahlungsarten = ['card']
const zahlRoh = hole(eingabe, 'zahlungsarten')
if (zahlRoh !== undefined && zahlRoh !== null) {
if (Array.isArray(zahlRoh) && zahlRoh.every((z) => ZAHLUNGSARTEN.includes(z))) {
const menge = new Set(zahlRoh)
zahlungsarten = ZAHLUNGSARTEN.filter((z) => z === 'card' || menge.has(z))
} else fehl('zahlungsarten', 'Bitte wähle nur Karte, Klarna oder SEPA-Lastschrift als Zahlungsarten. Die Kartenzahlung ist immer dabei.')
}
let akzent = std.akzent
const akzentRoh = hole(eingabe, 'akzent')
if (akzentRoh !== undefined && akzentRoh !== null) {
if (typeof akzentRoh === 'string' && AKZENT_RE.test(akzentRoh.trim())) akzent = akzentRoh.trim().toLowerCase()
else fehl('akzent', 'Bitte gib die Akzentfarbe als Hex-Wert an, zum Beispiel #111111.')
}
const mindestbestellCent = centOderNull('mindestbestellCent', hole(eingabe, 'mindestbestellCent'), 'Der Mindestbestellwert')
let ruecksendekosten = std.ruecksendekosten
const rueckRoh = hole(eingabe, 'ruecksendekosten')
if (rueckRoh !== undefined && rueckRoh !== null) {
if (rueckRoh === 'kaeufer' || rueckRoh === 'haendler') ruecksendekosten = rueckRoh
else fehl('ruecksendekosten', 'Bitte wähle, ob die Käuferin bzw. der Käufer oder du die Kosten der Rücksendung trägst.')
}
if (Object.keys(fehler).length) return { fehler }
return {
einstellungen: {
shopname,
anbieter,
kleinunternehmer,
versand: { aktiv: versandAktiv, laender, kostenCent, freiAbCent },
abholung,
lieferzeit,
zahlungsarten,
akzent,
mindestbestellCent,
ruecksendekosten,
},
}
}
/** Alles Noetige zum Verkaufen: Normalisierung ohne Fehler und Pflichtangaben vorhanden. */
export function istVollstaendig(einstellungen) {
const r = normalisiereEinstellungen(einstellungen)
if (r.fehler) return false
const e = r.einstellungen
// Die Steuerart muss bewusst gewaehlt sein (kein Standardwert): sonst stuenden unrichtige Steuerangaben im Shop
if (typeof e.kleinunternehmer !== 'boolean') return false
return [e.shopname, e.anbieter.name, e.anbieter.strasse, e.anbieter.plz, e.anbieter.ort, e.anbieter.email, e.lieferzeit].every((s) => s !== '')
}
/** Standardwerte, darueber die bekannten Felder aus gespeichertem Stand (eine Ebene tief fuer die Unterobjekte). */
function mitStandard(roh) {
const e = kopie(STANDARD_EINSTELLUNGEN)
if (!istObjekt(roh)) return e
for (const key of Object.keys(e)) {
const w = hole(roh, key)
if (w === undefined) continue
if (['anbieter', 'abholung'].includes(key)) {
if (istObjekt(w)) for (const k of Object.keys(e[key])) if (hole(w, k) !== undefined) e[key][k] = kopie(w[k])
} else if (key === 'versand') {
if (istObjekt(w)) {
for (const k of Object.keys(e.versand)) if (hole(w, k) !== undefined) e.versand[k] = kopie(w[k])
}
} else e[key] = kopie(w)
}
return e
}
/**
* Einstellungen aus einem shopConfig-Dokument. Fehlt der JSON-Text oder ist er kaputt, gelten die Standardwerte.
* Ein lesbarer, aber ungueltiger Stand wird nicht verworfen, sondern mit den Standardwerten aufgefuellt zurueckgegeben
* (istVollstaendig() / normalisiereEinstellungen() melden dann die Fehler).
*/
export function ladeEinstellungen(shopDoc) {
let roh = null
try {
roh = typeof shopDoc?.einstellungen === 'string' && shopDoc.einstellungen !== '' ? JSON.parse(shopDoc.einstellungen) : null
} catch {
roh = null
}
const gefuellt = mitStandard(roh)
const r = normalisiereEinstellungen(gefuellt)
return r.einstellungen || gefuellt
}
// ---------------------------------------------------------------- Speichern
const hooks = new Set()
/** Meldet eine Funktion an, die nach jedem Speichern mit der Shop-ID gerufen wird (zB Cache leeren). Rueckgabe: Abmelden. */
export function nachSpeichern(fn) {
if (typeof fn !== 'function') throw new TypeError('nachSpeichern: Funktion erwartet')
hooks.add(fn)
return () => hooks.delete(fn)
}
function rufeHooks(shopId) {
for (const fn of [...hooks]) {
try {
Promise.resolve(fn(shopId)).catch(() => {})
} catch {
/* Fehler im Hook duerfen das Speichern nicht kippen */
}
}
}
const veraltet = () =>
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.'), { status: 409, code: 'EINSTELLUNG_VERALTET' })
/**
* Speichert die Einstellungen eines Shops (Dokument-ID = shopId) und erhoeht `version`.
* Mit erwarteteVersion (ganze Zahl; 0 = noch nie gespeichert) wird gegen gleichzeitige Aenderungen geprueft: Abweichung => Fehler 409.
* Ungueltige Einstellungen => Fehler 400 mit `fehler` (Feld => Text), unbekannter Shop => 404 aus Appwrite.
* @returns {Promise<{einstellungen: object, version: number}>}
*/
export async function speichereEinstellungen(shopId, einstellungen, { erwarteteVersion } = {}) {
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('shopId fehlt')
const r = normalisiereEinstellungen(einstellungen)
if (r.fehler) throw Object.assign(new Error('Die Einstellungen sind nicht gültig.'), { status: 400, fehler: r.fehler })
const ergebnis = await withLocks([`shop-cfg:${shopId}`], async () => {
const col = config.collections.shopConfig
const doc = await getDocument(col, shopId)
const aktuell = Number.isInteger(doc?.version) && doc.version > 0 ? doc.version : 0
if (erwarteteVersion !== undefined && erwarteteVersion !== null && erwarteteVersion !== aktuell) throw veraltet()
await updateDocument(col, shopId, { einstellungen: JSON.stringify(r.einstellungen), version: aktuell + 1 })
return { einstellungen: r.einstellungen, version: aktuell + 1 }
})
rufeHooks(shopId)
return ergebnis
}
+86
View File
@@ -0,0 +1,86 @@
import { config } from '../../config.js'
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { bestellDaten, bestellungLesen } from './bestellungen.js'
import { erstattungenZurZahlung } from './connect.js'
/**
* Erstattungen, die der Haendler im Stripe-Dashboard ausloest (Webhook charge.refunded), in die Bestellung nachtragen
* (Plan 2026-10-05-webshop.md, Task D3). Ueber das Portal gebuchte Erstattungen stehen schon unter ihrer Refund-ID (rid) in
* erstattungen[] und werden nicht doppelt gebucht. Keine Mail an den Kaeufer: der Haendler hat die Erstattung selbst veranlasst.
*/
const ERSTATTBAR = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen', 'erstattet']
const MAX_JSON = 60000
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '')
async function shopDokument(shopId) {
try {
return await getDocument(config.collections.shopConfig, shopId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/** Bestellung zur Zahlung: erst ueber die Metadaten der Zahlung (orderId), sonst Suche in den bezahlten Bestellungen der Shops des Kontos. */
async function bestellungZurZahlung(accountId, paymentIntent, orderId) {
if (typeof orderId === 'string' && orderId) {
const order = await bestellungLesen(orderId)
if (order && order.paymentIntentId === paymentIntent) return order
}
const shops = await listAllDocuments(config.collections.shopConfig, [Query.equal('stripeAccountId', accountId)])
for (const shop of shops) {
for (const status of ERSTATTBAR) {
const docs = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shop.$id), Query.equal('status', status)])
const doc = docs.find((d) => d.paymentIntentId === paymentIntent)
if (doc) return { ...doc, daten: bestellDaten(doc) }
}
}
return null
}
/**
* Bucht noch unbekannte Erstattungen einer Zahlung nach (idempotent ueber die Refund-ID).
* @param {{ accountId: string, charge: object, now?: Date }} p charge = Stripe-Charge aus charge.refunded
* @returns {Promise<{ order: object|null, neu: number }>} neu = Anzahl nachgetragener Erstattungen
*/
export async function bucheStripeErstattung({ accountId, charge, now = new Date() }) {
const paymentIntent = stripeId(charge?.payment_intent)
if (!istObjekt(charge) || typeof accountId !== 'string' || !paymentIntent) return { order: null, neu: 0 }
const gefunden = await bestellungZurZahlung(accountId, paymentIntent, charge.metadata?.wkShop === '1' ? charge.metadata.orderId : undefined)
if (!gefunden) return { order: null, neu: 0 }
const shop = await shopDokument(gefunden.shopId)
if (!shop || shop.stripeAccountId !== accountId) return { order: null, neu: 0 }
// Refunds: aus dem Ereignis, sonst bei Stripe lesen (neuere API-Versionen liefern charge.refunds nicht mehr mit)
const refunds = Array.isArray(charge.refunds?.data) ? charge.refunds.data : await erstattungenZurZahlung(accountId, paymentIntent)
const at = new Date(now).toISOString()
return withLocks([`shop-order:${gefunden.$id}`], async () => {
const order = await bestellungLesen(gefunden.$id)
if (!order || order.paymentIntentId !== paymentIntent || !ERSTATTBAR.includes(order.status)) return { order, neu: 0 }
const daten = order.daten
const bisher = Array.isArray(daten.erstattungen) ? daten.erstattungen : []
const bekannt = new Set(bisher.map((e) => e?.rid))
const neue = []
for (const r of refunds) {
const rid = stripeId(r)
if (!rid || bekannt.has(rid) || (r.status && r.status !== 'succeeded' && r.status !== 'pending')) continue
if (!Number.isInteger(r.amount) || r.amount <= 0) continue
bekannt.add(rid)
neue.push({ rid, betragCent: r.amount, grund: 'Stripe-Dashboard', at, quelle: 'stripe' })
}
if (!neue.length) return { order, neu: 0 }
const erstattungen = [...bisher, ...neue]
const summe = erstattungen.reduce((s, e) => s + (Number.isInteger(e?.betragCent) ? e.betragCent : 0), 0)
const voll = Number.isInteger(order.summeCent) && summe >= order.summeCent
const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), ...neue.map((e) => ({ at, was: 'erstattet_stripe', betragCent: e.betragCent }))]
const felder = { bestellung: JSON.stringify({ ...daten, erstattungen, verlauf }), ...(voll ? { status: 'erstattet' } : {}) }
if (felder.bestellung.length > MAX_JSON) throw new Error('Bestellung zu gross für weitere Erstattungen')
const doc = await updateDocument(config.collections.shopOrders, order.$id, felder)
return { order: { ...doc, daten: bestellDaten(doc) }, neu: neue.length }
})
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Geld und Grundpreis fuer den Shop: reine Funktionen ohne Abhaengigkeiten.
* Geld ist immer eine ganze Zahl in Cent, nie Gleitkomma-Euro; Eingaben werden als Ziffernfolge gelesen, nicht gerechnet.
*/
const NBSP = ' '
/** Obergrenze fuer eingegebene Preise: 100.000 € in Cent. */
const MAX_CENT = 10_000_000
/**
* Formatiert ganze Cent als deutschen Eurobetrag: Tausenderpunkt, Dezimalkomma, immer zwei Nachkommastellen,
* geschuetztes Leerzeichen (U+00A0) vor dem Eurozeichen. Beispiel: 123456 => '1.234,56 €', -1250 => '-12,50 €'.
* Wirft einen TypeError, wenn cent keine ganze Zahl ist.
*/
export function euro(cent) {
if (!Number.isInteger(cent)) throw new TypeError('euro: cent muss eine ganze Zahl sein')
const neg = cent < 0
const abs = Math.abs(cent)
const euros = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
const rest = String(abs % 100).padStart(2, '0')
return `${neg ? '-' : ''}${euros},${rest}${NBSP}€`
}
/**
* Liest einen Eurobetrag und liefert ganze Cent, sonst null.
* Erlaubt: Zahl in Euro (12.5), Text wie '12', '12,5', '12,50 €', '1.234,56', '1234.56'.
* Mit Komma sind Punkte nur Tausendertrenner. Ohne Komma ist ein einzelner Punkt mit 1-2 Ziffern dahinter das Dezimaltrennzeichen,
* mit genau 3 Ziffern dahinter ein Tausenderpunkt ('1.234' = 1234 €).
* null bei leer, negativ, keine Zahl, mehr als zwei Nachkommastellen oder ueber 100.000 €.
*/
export function parseEuro(value) {
let s
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null
s = String(value)
// Zahlen nur als schlichte Dezimalzahl (kein Exponent, keine Tausendergruppen)
if (!/^\d+(\.\d{1,2})?$/.test(s)) return null
return zuCent(s, '.')
}
if (typeof value !== 'string') return null
s = value.replace(/€/g, '').trim() // trim entfernt auch geschuetzte Leerzeichen; Leerzeichen mitten in der Zahl sind ungueltig
if (s === '') return null
if (s.includes(',')) {
const m = /^(\d{1,3}(?:\.\d{3})+|\d+),(\d{1,2})$/.exec(s)
if (!m) return null
return zuCent(`${m[1].replace(/\./g, '')}.${m[2]}`, '.')
}
if (/^\d+$/.test(s)) return zuCent(s, '.')
if (/^\d+\.\d{1,2}$/.test(s)) return zuCent(s, '.')
if (/^\d{1,3}(?:\.\d{3})+$/.test(s)) return zuCent(s.replace(/\./g, ''), '.')
return null
}
/** Wandelt eine geprueft schlichte Dezimalzahl ('12.5') per Ziffernarithmetik in Cent; null ueber der Obergrenze. */
function zuCent(text, trenner) {
const [ganz, nach = ''] = text.split(trenner)
const cent = Number(ganz) * 100 + Number(nach.padEnd(2, '0'))
if (!Number.isSafeInteger(cent) || cent > MAX_CENT) return null
return cent
}
/** Unterstuetzte Einheiten: Anzeige-Einheit der Bezugsmenge und Faktor von der Eingabeeinheit auf diese. */
const EINHEITEN = Object.freeze({
g: { anzeige: 'kg', faktor: 1000 },
kg: { anzeige: 'kg', faktor: 1 },
ml: { anzeige: 'l', faktor: 1000 },
l: { anzeige: 'l', faktor: 1 },
m: { anzeige: 'm', faktor: 1 },
m2: { anzeige: 'm²', faktor: 1 },
})
/** Nachkommastellen der Menge, die exakt beruecksichtigt werden (0,75 l, 0,333 kg ...). */
const MENGE_STELLEN = 6n
/**
* Grundpreis nach PAngV, z. B. '(16,67 € / 1 kg)'. Bezug ist 1 kg, 1 l, 1 m oder 1 m² (g => kg, ml => l).
* einheit: g | kg | ml | l | m | m2; menge: positive Zahl (auch Dezimal, z. B. 0.75).
* Rundung kaufmaennisch (halb auf) auf ganze Cent, mit BigInt gerechnet, damit keine Gleitkommafehler entstehen.
* Fehlen Menge, Einheit oder Preis oder sind sie ungueltig, kommt '' zurueck.
*/
export function grundpreisText(angaben) {
const { preisCent, menge, einheit } = angaben ?? {}
const e = typeof einheit === 'string' && Object.hasOwn(EINHEITEN, einheit) ? EINHEITEN[einheit] : null
if (!e) return ''
if (!Number.isInteger(preisCent) || preisCent < 0) return ''
const m = mengeSkaliert(menge)
if (m === null) return ''
// Cent je Bezugsmenge = preisCent * faktor / menge, menge = m / 10^6
const zaehler = BigInt(preisCent) * BigInt(e.faktor) * 10n ** MENGE_STELLEN
const cent = (2n * zaehler + m) / (2n * m)
if (cent > BigInt(Number.MAX_SAFE_INTEGER)) return ''
return `(${euro(Number(cent))} / 1 ${e.anzeige})`
}
/** Menge (Zahl oder Dezimaltext) als BigInt mit 6 Nachkommastellen; null bei <= 0 oder ungueltig. */
function mengeSkaliert(menge) {
let n
if (typeof menge === 'number') n = menge
else if (typeof menge === 'string' && /^\d+(\.\d+)?$/.test(menge.trim())) n = Number(menge.trim())
else return null
if (!Number.isFinite(n) || n <= 0) return null
const skaliert = Math.round(n * 10 ** Number(MENGE_STELLEN))
if (!Number.isSafeInteger(skaliert) || skaliert <= 0) return null
return BigInt(skaliert)
}
+262
View File
@@ -0,0 +1,262 @@
import { config } from '../../config.js'
import { deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { heute, zuUtc } from '../termine/zeit.js'
import { zeitMs } from '../termine/util.js'
import { raeumeUnbeanspruchteAuf } from './bilder.js'
import { bestellDaten, pruefeOffene } from './bestellungen.js'
import { leereCache } from './kontext.js'
import { loescheShopDaten } from './loeschen.js'
import { shopModus } from './schalter.js'
/**
* Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3: Stundenjob, Tagesjob und die Registrierung in index.js).
* Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten Takt) und wirft nie.
*/
const laufend = new Set()
async function einzeln(name, arbeit) {
if (laufend.has(name)) {
console.warn(`[shop-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
return { uebersprungen: true }
}
laufend.add(name)
try {
return await arbeit()
} catch (err) {
console.error(`[shop-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`)
return { fehler: true }
} finally {
laufend.delete(name)
}
}
/** Minutenjob: offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (bezahlt abschliessen oder verwerfen). Bei SHOP_ENABLED=off aus. */
export async function minutenJob(now = new Date()) {
if (shopModus() === 'off') return { aus: true }
return einzeln('minutenJob', () => pruefeOffene(now))
}
/** Stundenjob: nie an einen Artikel gebundene, hochgeladene Bilder nach 24 h loeschen. Bei SHOP_ENABLED=off aus. */
export async function stuendlicherJob(now = new Date()) {
if (shopModus() === 'off') return { aus: true }
return einzeln('stuendlicherJob', async () => ({ bilderEntfernt: await raeumeUnbeanspruchteAuf(now) }))
}
// ---------------------------------------------------------------------------------------------------------------------
// Tagesjob (Spezifikation 9 / W11)
const TAG_MS = 24 * 60 * 60 * 1000
const STANDARD_DECKEL = 2000
const SEITE = 100
/** Marker in portalFeatures.adminNote nach der Loeschung der Shopdaten (hoechstens 300 Zeichen im Feld). */
const MARKER = 'Shopdaten gelöscht am '
const MARKER_RE = /^Shopdaten gelöscht am (\d{4}-\d{2}-\d{2})/
const deckel = () => (Number.isInteger(config.shop.loeschDeckel) && config.shop.loeschDeckel > 0 ? config.shop.loeschDeckel : STANDARD_DECKEL)
/** Fehlertext fuer das Log: nur Name, Status und gekuerzte Meldung (Appwrite-Meldungen nennen hoechstens Dokument-IDs, nie Kaeuferdaten). */
const fehlerText = (err) => `${err?.name}${err?.status ? ` ${err.status}` : ''}: ${String(err?.message ?? '').slice(0, 160)}`
/**
* Anfang des ersten Jahres, dessen Bestellungen noch NICHT anonymisiert werden: 1. Januar (Berlin, 00:00) von (aktuelles Berliner
* Jahr - anonymisierenJahre). Eine Bestellung aus dem Jahr Y wird anonymisiert, sobald now >= 1. Januar von Y + anonymisierenJahre + 1
* (3 Jahre nach dem Ende des Bestelljahres), also genau dann, wenn erstelltAt vor dieser Grenze liegt.
*/
export function anonymisierGrenze(now, jahre = config.shop.anonymisierenJahre) {
const jahr = Number(heute(now).slice(0, 4)) - jahre
return zuUtc(`${String(jahr).padStart(4, '0')}-01-01`, '00:00')
}
/** Gibt es Shop-Dokumente? Eine Abfrage mit limit 1, ohne Kunden- oder Kaeuferdaten. */
async function gibtShops() {
return (await listDocuments(config.collections.shopConfig, [Query.limit(1)])).length > 0
}
/** Ein Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht, wird gezaehlt und ohne Personendaten geloggt. */
async function schritt(lauf, name, arbeit) {
try {
await arbeit()
} catch (err) {
lauf.fehler += 1
console.warn(`[shop-jobs] taeglicherJob: ${name} fehlgeschlagen: ${fehlerText(err)}`)
}
}
/**
* Liest eine Abfrage seitenweise (nach $id, 100 je Seite, nur die genannten Felder; nur lesen, damit der Cursor gueltig bleibt) und
* sammelt die Treffer von `passt`, hoechstens max. => { treffer, gedeckelt } (gedeckelt: der Deckel wurde erreicht, es kann mehr geben)
*/
async function sammle(sammlung, filter, felder, passt, max) {
const treffer = []
let cursor = null
for (;;) {
const seite = await listDocuments(sammlung, [
...filter,
Query.select(['$id', ...felder]),
Query.orderAsc('$id'),
Query.limit(SEITE),
...(cursor ? [Query.cursorAfter(cursor)] : []),
])
for (const doc of seite) {
if (!passt(doc)) continue
if (treffer.length >= max) return { treffer, gedeckelt: true }
treffer.push(doc)
}
if (seite.length < SEITE) return { treffer, gedeckelt: false }
cursor = seite[seite.length - 1].$id
}
}
/** Wendet arbeit auf die gesammelten Treffer an; ein Fehler an einem Dokument wird gezaehlt und geloggt, die uebrigen kommen trotzdem dran. */
async function bearbeite(lauf, sammlung, { treffer, gedeckelt }, art, arbeit) {
if (gedeckelt) lauf.gedeckelt = true
for (const doc of treffer) {
try {
await arbeit(doc)
lauf[art] -= 1
} catch (err) {
lauf.fehler += 1
console.warn(`[shop-jobs] taeglicherJob: ${sammlung} ${doc.$id} fehlgeschlagen: ${fehlerText(err)}`)
}
}
}
/** 2. Unbezahlte Bestellungen (storniert, oder offen und laengst abgelaufen) nach unbezahltLoeschTage Tagen loeschen. */
async function loescheUnbezahlte(lauf, shopId, now) {
const sammlung = config.collections.shopOrders
const grenze = now.getTime() - config.shop.unbezahltLoeschTage * TAG_MS
const gefunden = await sammle(
sammlung,
[Query.equal('shopId', shopId), Query.equal('status', ['storniert', 'offen']), Query.lessThan('erstelltAt', new Date(grenze).toISOString())],
['bezahltAt', 'erstelltAt'],
// eine bezahlte Bestellung wird hier nie geloescht, auch wenn ihr Status nicht stimmt
(doc) => !doc.bezahltAt && zeitMs(doc.erstelltAt) < grenze,
lauf.loeschRest,
)
await bearbeite(lauf, sammlung, gefunden, 'loeschRest', async (doc) => {
try {
await deleteDocument(sammlung, doc.$id)
} catch (err) {
if (err?.status !== 404) throw err
}
lauf.zaehler.unbezahltGeloescht += 1
})
}
/** 1. Kaeuferdaten bezahlter Bestellungen 3 Jahre nach Ende des Bestelljahres entfernen; Positionen, Betraege, Nummer, Status bleiben. */
async function anonymisiereBestellungen(lauf, shopId, grenze) {
const sammlung = config.collections.shopOrders
const gefunden = await sammle(
sammlung,
[Query.equal('shopId', shopId), Query.lessThan('erstelltAt', grenze.toISOString())],
['anonymisiertAt', 'erstelltAt'],
(doc) => !doc.anonymisiertAt && zeitMs(doc.erstelltAt) < grenze.getTime(),
lauf.anonymRest,
)
await bearbeite(lauf, sammlung, gefunden, 'anonymRest', async (kopf) => {
const doc = await getDocument(sammlung, kopf.$id)
if (doc.anonymisiertAt) return
const daten = bestellDaten(doc)
delete daten.lieferadresse
delete daten.rechnungsadresse
delete daten.ipHash
daten.kaeufer = { name: '', email: '' }
await updateDocument(sammlung, doc.$id, {
kaeuferEmail: '',
bestellung: JSON.stringify(daten),
anonymisiertAt: lauf.jetzt.toISOString(),
})
lauf.zaehler.bestellungenAnonymisiert += 1
})
}
/** 3. Widerrufe: Name, E-Mail, Nachricht nach derselben Frist (eingangAt) leeren. Die Bestellnummer bleibt (keine Personenangabe). */
async function anonymisiereWiderrufe(lauf, shopId, grenze) {
const sammlung = config.collections.shopWiderrufe
const gefunden = await sammle(
sammlung,
[Query.equal('shopId', shopId), Query.lessThan('eingangAt', grenze.toISOString())],
['name', 'email', 'nachricht', 'eingangAt'],
(doc) => (doc.name || doc.email || doc.nachricht) && zeitMs(doc.eingangAt) < grenze.getTime(),
lauf.anonymRest,
)
await bearbeite(lauf, sammlung, gefunden, 'anonymRest', async (doc) => {
await updateDocument(sammlung, doc.$id, { name: '', email: '', nachricht: '' })
lauf.zaehler.widerrufeAnonymisiert += 1
})
}
/** 4. Shops seit mehr als loeschTageNachAbschalten Tagen `disabled`: alle Shopdaten samt Bildern und shopConfig loeschen. */
async function loescheAbgeschaltete(lauf, now) {
const grenze = now.getTime() - config.shop.loeschTageNachAbschalten * TAG_MS
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'shop'), Query.equal('state', 'disabled')])
for (const f of funktionen) {
if (!(zeitMs(f.disabledAt) < grenze)) continue
const marker = MARKER_RE.exec(String(f.adminNote ?? ''))
if (marker && marker[1] >= String(f.disabledAt).slice(0, 10)) continue // schon geloescht (ein spaeteres Abschalten setzt disabledAt neu)
if (lauf.loeschRest <= 0) {
lauf.gedeckelt = true
return
}
try {
// vor jeder Seite frisch lesen: das Team kann die Funktion zwischenzeitlich wieder einschalten
const nochAus = async () => {
const frisch = await getDocument(config.collections.portalFeatures, f.$id)
return frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze
}
const erg = await loescheShopDaten({ shopId: f.$id, siteKey: f.siteKey, deckel: lauf.loeschRest, nochGueltig: nochAus })
lauf.loeschRest -= erg.geloescht
lauf.zaehler.shopDokumenteGeloescht += erg.geloescht
if (f.siteKey) leereCache(f.siteKey)
if (erg.vollstaendig) {
lauf.zaehler.shopsGeloescht += 1
if (await nochAus()) {
await updateDocument(config.collections.portalFeatures, f.$id, { adminNote: `${MARKER}${heute(now)}`.slice(0, 300) })
}
} else if (!erg.abgebrochen) {
lauf.gedeckelt = true
}
} catch (err) {
lauf.fehler += 1
console.warn(`[shop-jobs] taeglicherJob: Shop ${f.$id} nicht vollständig gelöscht: ${fehlerText(err)}`)
}
}
}
/**
* Tagesjob: Kaeuferdaten bezahlter Bestellungen und Widerrufe 3 Jahre nach Ende des Bestelljahres anonymisieren, unbezahlte/
* stornierte Bestellungen nach 30 Tagen loeschen, Shops, die seit 90 Tagen abgeschaltet sind, samt Bildern loeschen. Je Lauf hoechstens
* config.shop.loeschDeckel (Standard 2000) Loeschungen und ebenso viele Anonymisierungen; der Rest folgt am naechsten Tag. Jeder Schritt
* und jeder Shop ist abgesichert (Fehler werden gezaehlt und ohne Personendaten geloggt). Bei SHOP_ENABLED=off laeuft der Job nur, wenn
* es noch Shop-Dokumente gibt (die Fristen gelten auch dann).
* @param {Date} [now]
* @returns {Promise<object>} { aus } | { uebersprungen } | { fehler: true } | Zaehler { bestellungenAnonymisiert, widerrufeAnonymisiert,
* unbezahltGeloescht, shopsGeloescht, shopDokumenteGeloescht, fehler, gedeckelt }
*/
export async function taeglicherJob(now = new Date()) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('taeglicherJob: now fehlt')
return einzeln('taeglicherJob', async () => {
if (shopModus() === 'off' && !(await gibtShops())) return { aus: true }
const lauf = {
jetzt: now,
loeschRest: deckel(),
anonymRest: deckel(),
gedeckelt: false,
fehler: 0,
zaehler: { bestellungenAnonymisiert: 0, widerrufeAnonymisiert: 0, unbezahltGeloescht: 0, shopsGeloescht: 0, shopDokumenteGeloescht: 0 },
}
const grenze = anonymisierGrenze(now)
let shops = []
await schritt(lauf, 'Shops lesen', async () => {
shops = await listAllDocuments(config.collections.shopConfig, [Query.select(['$id'])])
})
for (const shop of shops) {
await schritt(lauf, 'unbezahlte Bestellungen', () => loescheUnbezahlte(lauf, shop.$id, now))
await schritt(lauf, 'Bestellungen anonymisieren', () => anonymisiereBestellungen(lauf, shop.$id, grenze))
await schritt(lauf, 'Widerrufe anonymisieren', () => anonymisiereWiderrufe(lauf, shop.$id, grenze))
}
await schritt(lauf, 'abgeschaltete Shops', () => loescheAbgeschaltete(lauf, now))
return { ...lauf.zaehler, fehler: lauf.fehler, gedeckelt: lauf.gedeckelt }
})
}
+257
View File
@@ -0,0 +1,257 @@
import { config } from '../../config.js'
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { getFeatureDoc } from '../siteFeatures.js'
import { istVollstaendig, ladeEinstellungen, nachSpeichern } from './einstellungen.js'
import { listeProdukte, produktOeffentlich } from './produkte.js'
import { rechtstexteHash, vollstaendig } from './rechtstexte.js'
/**
* Kontext eines Shops (Plan 2026-10-05-webshop.md, Task C6; Spezifikation 4.6) und seine Bereitschaft zum Verkaufen.
*
* ctx = { doc, shop, project }
* doc portalFeatures-Dokument (featureKey 'shop')
* shop geparstes shopConfig-Dokument (ladeShop) oder null (noch keines angelegt)
* project websiteProjects-Dokument oder null
* Ein ctx aus shopPerSiteKey ist nur zum Lesen: innerhalb von 30 Sekunden kommt dasselbe Objekt zurueck.
*
* shopConfig.rechtstexte ist ein JSON-Text { entwurf: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl},
* bestaetigt: null | { texte: {dieselben fuenf}, hash, at, von } }. Der Entwurf gehoert dem Haendler, die Kaeufer sehen
* immer die zuletzt bestaetigte Fassung (oeffentlicheRechtstexte). Kaputt oder alt => leerer Entwurf, nichts bestaetigt.
*/
const FUNKTION = 'shop'
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const CACHE_MS = 30 * 1000
const CACHE_MAX = 10000
const TEXT_FELDER = ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl']
const cache = new Map()
/** Wird mit jedem Leeren erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
let generation = 0
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
if (typeof id !== 'string' || id === '') return null
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
const leereTexte = () => Object.fromEntries(TEXT_FELDER.map((k) => [k, '']))
const textFelder = (roh) => Object.fromEntries(TEXT_FELDER.map((k) => [k, istObjekt(roh) && Object.hasOwn(roh, k) && typeof roh[k] === 'string' ? roh[k] : '']))
/**
* Rechtstexte aus einem shopConfig-Dokument: { entwurf, bestaetigt }. Fehlt der JSON-Text, ist er kaputt oder hat er eine
* andere Form, ist das Ergebnis { entwurf: (leer), bestaetigt: null }. bestaetigt braucht texte (Objekt) und einen hash (Text).
*/
export function ladeRechtstexte(shopDoc) {
let roh = null
try {
roh = typeof shopDoc?.rechtstexte === 'string' && shopDoc.rechtstexte !== '' ? JSON.parse(shopDoc.rechtstexte) : null
} catch {
roh = null
}
if (!istObjekt(roh)) return { entwurf: leereTexte(), bestaetigt: null }
const b = Object.hasOwn(roh, 'bestaetigt') ? roh.bestaetigt : null
const bestaetigt =
istObjekt(b) && istObjekt(b.texte) && typeof b.hash === 'string' && b.hash !== ''
? { texte: textFelder(b.texte), hash: b.hash, at: typeof b.at === 'string' ? b.at : '', von: typeof b.von === 'string' ? b.von : '' }
: null
return { entwurf: textFelder(Object.hasOwn(roh, 'entwurf') ? roh.entwurf : null), bestaetigt }
}
function ladeStripeStatus(shopDoc) {
try {
const s = typeof shopDoc?.stripeStatus === 'string' && shopDoc.stripeStatus !== '' ? JSON.parse(shopDoc.stripeStatus) : null
return istObjekt(s) ? s : null
} catch {
return null
}
}
/** Geparster Shop aus einem shopConfig-Dokument. */
export function ladeShop(shopDoc) {
return {
id: shopDoc.$id,
customerId: shopDoc.customerId ?? '',
projectId: shopDoc.projectId ?? '',
siteKey: shopDoc.siteKey ?? '',
einstellungen: ladeEinstellungen(shopDoc),
rechtstexte: ladeRechtstexte(shopDoc),
stripeAccountId: typeof shopDoc.stripeAccountId === 'string' ? shopDoc.stripeAccountId : '',
stripeStatus: ladeStripeStatus(shopDoc),
geoeffnet: shopDoc.geoeffnet === true,
anfrageStatus: typeof shopDoc.anfrageStatus === 'string' ? shopDoc.anfrageStatus : '',
version: Number.isInteger(shopDoc.version) && shopDoc.version > 0 ? shopDoc.version : 0,
}
}
/**
* Kontext fuer das Portal: portalFeatures-Dokument des Projekts (Zustand egal), shopConfig dazu und das Projekt.
* doc null: keine Funktion; shop null: noch kein shopConfig-Dokument; project null: das Projekt gibt es nicht oder es gehoert
* einem anderen Kunden.
* @returns {Promise<{ doc: object|null, shop: object|null, project: object|null }>}
*/
export async function ladeShopFuerProjekt(customerId, projectId) {
const projekt = await holeOderNull(config.collections.websiteProjects, projectId)
const project = projekt && customerId && projekt.customerId === customerId ? projekt : null
const doc = project ? await getFeatureDoc(customerId, projectId, FUNKTION) : null
const shopDoc = doc ? await holeOderNull(config.collections.shopConfig, doc.$id) : null
return { doc, shop: shopDoc ? ladeShop(shopDoc) : null, project }
}
/**
* Kontext fuer die oeffentliche Schnittstelle: Funktion 'shop' mit diesem siteKey im Zustand active oder disabled (disabled,
* weil Kaeufer den Widerruf weiter erklaeren koennen muessen) samt shopConfig und Projekt. Treffer und Fehlschlaege werden 30 Sekunden
* je siteKey gemerkt; leereCache und ein Speichern der Einstellungen leeren den Speicher.
* @param {string} siteKey
* @param {{ now?: Date }} [opt] now: Zeitpunkt fuer den Speicher (Tests)
* @returns {Promise<{ doc: object, shop: object, project: object|null }|null>} null bei ungueltigem oder unbekanntem siteKey,
* anderem Zustand als active/disabled und fehlendem shopConfig
*/
export async function shopPerSiteKey(siteKey, { now = new Date() } = {}) {
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
const nowMs = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(nowMs)) throw new TypeError('shopPerSiteKey: now ist kein gueltiger Zeitpunkt')
const gemerkt = cache.get(siteKey)
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
const stand = generation
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', FUNKTION),
Query.equal('state', ['active', 'disabled']),
Query.limit(1),
])
let ctx = null
if (doc && ['active', 'disabled'].includes(doc.state)) {
const [shopDoc, project] = await Promise.all([
holeOderNull(config.collections.shopConfig, doc.$id),
doc.projectId ? holeOderNull(config.collections.websiteProjects, doc.projectId) : null,
])
if (shopDoc) ctx = { doc, shop: ladeShop(shopDoc), project }
}
if (stand === generation) merke(siteKey, ctx, nowMs)
return ctx
}
function merke(siteKey, ctx, nowMs) {
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
}
/** Leert den Speicher von shopPerSiteKey: fuer einen siteKey oder (ohne Angabe) ganz. Laufende Abfragen merken ihr Ergebnis nicht mehr. */
export function leereCache(siteKey) {
generation++
if (siteKey === undefined) cache.clear()
else cache.delete(siteKey)
}
// Nach dem Speichern der Einstellungen (Hook mit der Shop-ID) alle Eintraege dieses Shops verwerfen
nachSpeichern((shopId) => {
generation++
for (const [key, eintrag] of cache) if (eintrag.ctx?.shop?.id === shopId) cache.delete(key)
})
// ---------------------------------------------------------------------------------------------------------------------
// Bereitschaft
/** Mindestens ein aktiver, lesbarer und lieferbarer Artikel. */
function hatArtikel(produkte, shop) {
return (Array.isArray(produkte) ? produkte : []).some((p) => {
if (p?.aktiv === false) return false
const o = produktOeffentlich(p, { einstellungen: shop.einstellungen, siteKey: shop.siteKey })
return o !== null && o.verfuegbar === true
})
}
async function produkteFuer(ctx, produkte) {
return Array.isArray(produkte) ? produkte : listeProdukte(ctx.shop.id)
}
/** Stand der Rechtstexte: 'bestaetigt' (Hash passt), 'geaendert' (frueher bestaetigt, Entwurf weicht ab) oder 'offen'. */
function rechtstexteStand(shop) {
const { entwurf, bestaetigt } = shop.rechtstexte
if (!bestaetigt) return 'offen'
return bestaetigt.hash === rechtstexteHash(entwurf, shop.einstellungen) ? 'bestaetigt' : 'geaendert'
}
/**
* Die fuenf Punkte, die vor dem Oeffnen stimmen muessen, in dieser Reihenfolge: einbau, stripe, einstellungen, rechtstexte, artikel.
* @param {{ doc: object, shop: object }} ctx
* @param {{ produkte?: object[] }} [opt] bereits geladene Artikel-Dokumente; ohne Angabe werden sie geladen
* @returns {Promise<{ bereit: boolean, punkte: Array<{ id: string, ok: boolean, text: string }> }>}
*/
export async function bereitschaft(ctx, { produkte } = {}) {
const { doc, shop } = ctx
const liste = await produkteFuer(ctx, produkte)
const stand = rechtstexteStand(shop)
const punkte = [
{
id: 'einbau',
ok: doc?.state === 'active',
text: doc?.state === 'active' ? 'Der Shop ist eingebaut.' : 'Wir bauen den Shop gerade in deine Website ein.',
},
{
id: 'stripe',
ok: shop.stripeStatus?.charges_enabled === true,
text:
shop.stripeStatus?.charges_enabled === true
? 'Dein Zahlungskonto bei Stripe ist bereit.'
: 'Bitte richte dein Zahlungskonto bei Stripe ein, damit du Zahlungen empfangen kannst.',
},
{
id: 'einstellungen',
ok: istVollstaendig(shop.einstellungen),
text: istVollstaendig(shop.einstellungen)
? 'Deine Shop-Angaben sind vollständig.'
: 'Bitte vervollständige deine Shop-Angaben (Shopname, Anbieter mit Anschrift und E-Mail, Steuerart, Lieferzeit).',
},
{
id: 'rechtstexte',
ok: stand === 'bestaetigt',
text:
stand === 'bestaetigt'
? 'Deine Rechtstexte sind bestätigt.'
: stand === 'geaendert'
? 'Bitte bestätige deine geänderten Rechtstexte.'
: vollstaendig(shop.rechtstexte.entwurf)
? 'Bitte bestätige deine Rechtstexte.'
: 'Bitte ergänze deine Rechtstexte (AGB, Widerrufsbelehrung, Widerrufsformular, Impressum und Datenschutz) und bestätige sie.',
},
{
id: 'artikel',
ok: hatArtikel(liste, shop),
text: hatArtikel(liste, shop) ? 'Du hast mindestens einen lieferbaren Artikel.' : 'Bitte lege mindestens einen aktiven, lieferbaren Artikel an.',
},
]
return { bereit: punkte.every((p) => p.ok), punkte }
}
/**
* Verkauft der Shop gerade? Geoeffnet vom Haendler, Funktion active, Stripe bereit, Angaben vollstaendig, mindestens ein lieferbarer
* Artikel und die Rechtstexte irgendwann bestaetigt (auch wenn der Entwurf danach geaendert wurde: der Shop bleibt mit der zuletzt
* bestaetigten Fassung offen, Spezifikation 4.6).
* @returns {Promise<boolean>}
*/
export async function istGeoeffnet(ctx, { produkte } = {}) {
const { doc, shop } = ctx
if (shop.geoeffnet !== true || doc?.state !== 'active' || shop.stripeStatus?.charges_enabled !== true) return false
if (!istVollstaendig(shop.einstellungen) || !shop.rechtstexte.bestaetigt) return false
return hatArtikel(await produkteFuer(ctx, produkte), shop)
}
/** Die den Kaeufern gezeigten Rechtstexte (zuletzt bestaetigte Fassung) oder null, wenn noch nie bestaetigt. */
export function oeffentlicheRechtstexte(ctx) {
return ctx?.shop?.rechtstexte?.bestaetigt?.texte ?? null
}
+26
View File
@@ -0,0 +1,26 @@
import { config } from '../../config.js'
import { listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { leereCache } from './kontext.js'
/**
* Kundenloeschung (Plan 2026-10-05-webshop.md, J3): schliesst alle Shops des Kunden (shopConfig.geoeffnet=false) und leert den
* Speicher der oeffentlichen Schnittstelle. Die Funktion selbst schaltet disableCustomerFeatures ab; die Daten loescht der Tagesjob
* (shop/jobs.js) 90 Tage nach dem Abschalten. Scheitert ein Shop, werden die uebrigen trotzdem geschlossen und der erste Fehler weitergereicht.
* @returns {Promise<number>} Zahl der geschlossenen Shops
*/
export async function schliesseShopsVonKunde(customerId) {
const shops = await listAllDocuments(config.collections.shopConfig, [Query.equal('customerId', customerId)])
let geschlossen = 0
let erster = null
for (const shop of shops) {
try {
if (shop.geoeffnet !== false) await updateDocument(config.collections.shopConfig, shop.$id, { geoeffnet: false })
geschlossen += 1
} catch (err) {
erster ||= err
}
leereCache(shop.siteKey || undefined)
}
if (erster) throw erster
return geschlossen
}
+61
View File
@@ -0,0 +1,61 @@
import { config } from '../../config.js'
import { deleteDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { loescheAlleBilder } from './bilder.js'
/**
* Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, das Archiv der bestaetigten Rechtstexte, Artikelbilder und zuletzt das
* shopConfig-Dokument. Gemeinsamer Baustein des Tagesjobs (Shop seit 90 Tagen abgeschaltet, jobs.js) und der Loeschfristen
* (retention.js, Kunde faellig). Bewusst ohne Import von bestellungen.js/Stripe: retention.js darf nichts Schweres nachladen.
* Das Stripe-Konto gehoert dem Haendler und wird nie angefasst.
*
* Seitenweise (100 je Abfrage, immer die erste Seite neu, denn gelesene Dokumente sind danach weg). Am Deckel endet der Lauf
* (vollstaendig: false), der naechste setzt fort. Ein Fehler beim Loeschen wirft (sonst wuerde dieselbe Seite endlos neu gelesen).
*/
const SEITE = 100
const GRUPPEN = [
['shopOrders', 'bestellungen'],
['shopProducts', 'artikel'],
['shopWiderrufe', 'widerrufe'],
['shopRechtstexteArchiv', 'archiv'],
]
/**
* @param {{ shopId: string, siteKey?: string, deckel?: number, nochGueltig?: () => Promise<boolean> }} opt
* deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe + Archiv); nochGueltig wird vor jeder Seite gefragt (false = abbrechen)
* @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, archiv: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>}
* vollstaendig = alle Dokumente, Bilder und das shopConfig-Dokument sind weg
*/
export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) {
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('loescheShopDaten: shopId fehlt')
const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, archiv: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false }
for (const [name, feld] of GRUPPEN) {
const sammlung = config.collections[name]
for (;;) {
if (!(await nochGueltig())) return { ...erg, abgebrochen: true }
const rest = deckel - erg.geloescht
if (rest <= 0) return erg
const seite = await listDocuments(sammlung, [Query.equal('shopId', shopId), Query.limit(Math.min(SEITE, rest))])
if (seite.length === 0) break
for (const doc of seite) {
try {
await deleteDocument(sammlung, doc.$id)
} catch (err) {
if (err?.status !== 404) throw err
}
erg[feld] += 1
erg.geloescht += 1
}
}
}
if (!(await nochGueltig())) return { ...erg, abgebrochen: true }
if (siteKey) await loescheAlleBilder(siteKey)
try {
await deleteDocument(config.collections.shopConfig, shopId)
erg.konfig = true
} catch (err) {
if (err?.status !== 404) throw err
}
erg.vollstaendig = true
return erg
}
+404
View File
@@ -0,0 +1,404 @@
import { config } from '../../config.js'
import { renderMail, loadTemplate } from '../mailTemplates.js'
import { sendCustomerMail } from '../mailer.js'
import { customerOf } from '../siteFeatures.js'
import { einreihen } from '../termine/outbox.js'
import { pruefeNow, zeitMs } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { euro } from './geld.js'
import { oeffentlicheRechtstexte } from './kontext.js'
import { holeArchivierteRechtstexte } from './archiv.js'
import { gewaehrleistungsHinweis } from './rechtstexte.js'
/**
* Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4).
*
* Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung
* fehlgeschlagen, G15 Eingang der Bestellung) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname,
* Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des
* Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die
* Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht.
* Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team
* (die Mails enthalten Kaeuferdaten).
*
* Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne
* Adresse), die Bestellung bleibt unberuehrt.
*
* Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`,
* als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"):
* order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt
* snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') }
* snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } }
* snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT')
* snap.kaeufer { name, email } (oder snap.name)
* snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land }
* snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text
* snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops)
*/
const LEER = '–'
const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'])
/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */
const MAX_HTML = 95000
const MAX_TEXT = 47000
const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' }
const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert)
const WIDERRUF_AUSSCHLUSS = {
verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware',
individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde',
}
const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const text = (wert) => (typeof wert === 'string' ? wert.trim() : '')
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ')
// ---------------------------------------------------------------- Bausteine
/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */
function schnappschuss(order) {
for (const feld of ['bestellung', 'daten']) {
const roh = order?.[feld]
if (istObjekt(roh)) return roh
if (typeof roh === 'string' && roh !== '') {
try {
const geparst = JSON.parse(roh)
if (istObjekt(geparst)) return geparst
} catch {
// unlesbar: naechstes Feld
}
}
}
return {}
}
const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {}
const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) })
/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */
function shopName(ctx, snap = {}) {
return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop'
}
function anbieterZeile(a) {
const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ')
const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : ''
const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ')
return [text(a.name), anschrift, text(a.email), text(a.telefon) ? `Tel. ${einzeilig(a.telefon)}` : ''].filter(Boolean).join(', ') || LEER
}
const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || ''
const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}`
/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */
const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111')
/** Menge × Name (Variante) – Summe, eine Zeile je Position. */
function positionenText(positionen) {
const zeilen = (Array.isArray(positionen) ? positionen : [])
.filter(istObjekt)
.map((p) => {
const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : ''
const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER
return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}`
})
return zeilen.length ? zeilen.join('\n') : LEER
}
function adresseText(a, fallbackName) {
if (!istObjekt(a)) return LEER
const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText))
const land = feld('country', 'land')
const zeilen = [
feld('name') || text(fallbackName),
feld('line1', 'strasse'),
feld('line2'),
[feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '),
land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '',
].filter(Boolean)
return zeilen.length ? zeilen.join('\n') : LEER
}
function zahlungsartText(snap, e) {
const roh = text(snap.zahlungsart)
if (roh) return ZAHLUNGSARTEN[roh] ?? roh
return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung'
}
function ausschlussText(positionen) {
return (Array.isArray(positionen) ? positionen : [])
.filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf))
.map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`)
.join('\n')
}
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */
function eingangText(eingang) {
const ms = zeitMs(eingang)
if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt')
const d = new Date(ms)
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
}
/** Datum des ersten brauchbaren Zeitpunkts (Berliner Tag), sonst des Ersatzdatums. */
const datumVon = (now, ...zeitpunkte) => {
for (const wert of zeitpunkte) {
const ms = zeitMs(wert)
if (Number.isFinite(ms)) return datumKurz(new Date(ms))
}
return datumKurz(now)
}
// ---------------------------------------------------------------- Werte
/**
* Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name,
* anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen,
* Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die
* Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter.
* @param {{ doc?: object, shop: object }} ctx
* @param {object|null} order
* @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat
* @param {{ archiv?: object|null }} [optionen] archiv: archivierte Rechtstexte der Bestellung (archiv.js); ohne sie die aktuell bestaetigten
* @returns {Record<string, string>}
*/
export function kaeuferWerte(ctx, order, now = new Date(), { archiv = null } = {}) {
if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt')
const snap = schnappschuss(order)
const e = einstellungenVon(ctx)
const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {}
const anbieter = anbieterVon(ctx, snap)
const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) }
const rechtstexte = (istObjekt(archiv) ? archiv : oeffentlicheRechtstexte(ctx)) ?? {}
const summen = istObjekt(snap.summen) ? snap.summen : snap
const texte = istObjekt(summen.texte) ? summen.texte : {}
const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER)
const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : ''
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
const land = text(snap.land)
const shop = shopSeite(ctx)
return {
farbe: farbeVon(e),
betrieb_name: shopName(ctx, snap),
anbieter_zeile: anbieterZeile(anbieter),
widerruf_url: `${shop}/widerruf`,
impressum_url: text(rechtstexte.impressumUrl) || shop,
datenschutz_url: text(rechtstexte.datenschutzUrl) || shop,
bestellnummer: oderLeer(order?.bestellnummer),
datum: datumVon(now, order?.erstelltAt),
zahlungsdatum: datumVon(now, order?.bezahltAt, order?.erstelltAt),
positionen: positionenText(snap.positionen),
zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent),
versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)),
gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent),
steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER),
lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER,
lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name),
abholort: oderLeer(abholung.adresse),
lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit),
zahlungsart: zahlungsartText(snap, e),
ausschluss_text: ausschlussText(snap.positionen),
agb: oderLeer(rechtstexte.agb),
widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung),
widerrufsformular: oderLeer(rechtstexte.widerrufsformular),
gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(),
// fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat)
hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS,
}
}
/**
* Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die
* Platzhalter der Vorlage).
* extra je Art:
* G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist)
* G11 hinweis (ersetzt den Hinweis des Haendlers)
* G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht
* G13 betragCent (Pflicht, ganze Cent), grund
*/
function kaeuferMail(art, ctx, order, extra, now, archiv = null) {
const alle = kaeuferWerte(ctx, order, now, { archiv })
const blocks = {}
switch (art) {
case 'G09':
blocks.versand = schnappschuss(order).lieferart === 'versand'
blocks.abholung = schnappschuss(order).lieferart === 'abholung'
blocks.ausschluss = alle.ausschluss_text !== ''
break
case 'G10':
alle.versanddienst = oderLeer(extra.versanddienst)
alle.sendungsnummer = oderLeer(extra.sendungsnummer)
blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer)
break
case 'G11':
if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim()
break
case 'G12':
alle.name = text(extra.name)
if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim()
alle.eingang = eingangText(extra.eingang)
alle.nachricht = text(extra.nachricht)
blocks.nachricht = alle.nachricht !== ''
break
case 'G13':
if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein')
alle.betrag = euro(extra.betragCent)
alle.grund = text(extra.grund)
blocks.grund = alle.grund !== ''
break
default:
break
}
const vorlage = loadTemplate(art)
const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel)))
return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) }
}
/** Archivierte Rechtstexte zum Hash der Bestellung (G09); null (mit Warnung) fuer Bestellungen ohne Archiveintrag. Datenbankfehler werfen. */
async function archivFuer(ctx, order) {
const hash = text(schnappschuss(order).rechtstexteHash)
if (hash === '') return null
const shopId = ctx?.shop?.id || ctx?.doc?.$id
const archiv = await holeArchivierteRechtstexte(shopId, hash)
if (!archiv) console.warn(`[shop-mails] G09: Rechtstexte der Bestellung ${String(order?.$id).slice(0, 36)} fehlen im Archiv, die aktuell bestätigte Fassung wird verwendet`)
return archiv
}
const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT
// ---------------------------------------------------------------- Einreihen
/**
* Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist
* order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der
* Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die
* Mail zu lang fuer die Warteschlange, scheitert sie (ok: false), statt Texte durch einen Link zu ersetzen.
* @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js)
* @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null)
* @param {object} [extra] siehe kaeuferMail
* @param {Date} [now]
* @returns {Promise<{ ok: true, eintrag: object } | { ok: false, fehler: string }>}
*/
export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) {
try {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`)
pruefeNow(now, 'anKaeufer')
extra = extra ?? {}
const archiv = art === 'G09' ? await archivFuer(ctx, order) : null
const { values, blocks } = kaeuferMail(art, ctx, order, extra, now, archiv)
const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail)
if (an === '') throw new TypeError('Empfänger fehlt')
// Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht
// in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt.
const mail = renderMail(art, values, blocks)
if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange')
const e = einstellungenVon(ctx)
const eintrag = await einreihen(
{
featureId: ctx.doc.$id,
bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'),
art,
an,
fromName: shopName(ctx, schnappschuss(order)).slice(0, 120),
replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null,
template: art,
values,
blocks,
},
now,
)
return { ok: true, eintrag }
} catch (err) {
console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }
}
}
// ---------------------------------------------------------------- Haendler
/** Portal-Adresse der Webshop-Seite des Projekts. */
const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}`
/**
* Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung.
* @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft)
* @returns {{ values: Record<string, string>, blocks: Record<string, boolean> }}
*/
export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) {
const snap = schnappschuss(order)
const alle = kaeuferWerte(ctx, order)
const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {}
return {
values: {
shopname: alle.betrieb_name,
bestellnummer: alle.bestellnummer,
gesamt: alle.gesamt,
positionen: alle.positionen,
lieferart_text: alle.lieferart_text,
kaeufer_name: oderLeer(kaeufer.name ?? snap.name),
portal_url: portalUrl(ctx),
},
blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' },
}
}
/**
* Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder
* { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO.
*/
export function haendlerK26(ctx, widerruf, now = new Date()) {
const nachricht = text(widerruf?.nachricht)
return {
values: {
shopname: shopName(ctx),
bestellnummer: oderLeer(widerruf?.bestellnummer),
kaeufer_name: oderLeer(widerruf?.name),
kaeufer_email: oderLeer(widerruf?.email),
eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now),
nachricht,
portal_url: portalUrl(ctx),
},
blocks: { nachricht: nachricht !== '' },
}
}
/**
* Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url
* ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt.
* @param {string} template 'K25' | 'K26'
* @param {{ doc: object, shop: object }} ctx
* @param {Record<string, string>} values
* @param {Record<string, boolean>} [blocks]
* @returns {Promise<{ ok: true } | { ok: false, fehler: string }>}
*/
export async function anHaendler(template, ctx, values = {}, blocks = {}) {
try {
const kunde = await customerOf(ctx?.shop?.customerId)
if (!hatText(kunde?.email)) {
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`)
return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' }
}
const erlaubt = loadTemplate(template).placeholders
const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) }
const alle = { ...standard, ...values }
await sendCustomerMail(kunde.email, {
template,
values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))),
blocks,
bccTeam: false,
})
return { ok: true }
} catch (err) {
// Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten
console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim())
return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }
}
}
+205
View File
@@ -0,0 +1,205 @@
import { euro } from './geld.js'
/**
* Warenkorb-Berechnung fuer den Shop (Plan 2026-10-05-webshop.md, Task C4). Rein und deterministisch: kein I/O, kein Datum.
* Geld ist immer ganze Cent; Steuer und Versandanteile werden nur mit ganzen Zahlen gerechnet.
* Fehlertexte sind fuer Kaeufer geschrieben (Sie-Form).
*/
const MAX_POSITIONEN = 50
const MAX_MENGE = 99
const KLEINUNTERNEHMER_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Stripe nimmt Zahlungen in Euro erst ab 0,50 EUR an. */
const MINDESTBETRAG_CENT = 50
const fehler = (code, text, produktId) => ({ ok: false, fehler: produktId === undefined ? { code, text } : { code, text, produktId } })
/** Enthaltene USt aus Brutto, kaufmaennisch (halb auf) gerundet: brutto * satz / (100 + satz), nur mit ganzen Zahlen. */
function enthalteneSteuer(bruttoCent, satz) {
if (satz === 0 || bruttoCent === 0) return 0
const teiler = 100 + satz
return Math.floor((2 * bruttoCent * satz + teiler) / (2 * teiler))
}
/** Gesamtbetrag der Positionen je Steuersatz (Brutto), als Map satz => Cent in Einfuegereihenfolge. */
function nachSteuersatz(zeilen, wert) {
const summen = new Map()
for (const z of zeilen) summen.set(z.steuersatz, (summen.get(z.steuersatz) ?? 0) + wert(z))
return summen
}
/**
* @param {object} p
* @param {Map<string, object>} p.produkte id => { id, aktiv, ...normalisiertes Produkt }
* @param {Array<{produktId: string, varianteId?: string, menge: number}>} p.positionen
* @param {'versand'|'abholung'} p.lieferart
* @param {string} [p.land] 'DE' | 'AT' (nur bei Versand)
* @param {object} p.einstellungen normalisierte Shop-Einstellungen
* @param {Map<string, number>} [p.reserviert] Schluessel produktId bzw. `${produktId}:${varianteId}` => reservierte Menge
*/
export function berechne({ produkte, positionen, lieferart, land, einstellungen, reserviert = new Map() } = {}) {
const e = einstellungen ?? {}
// 1. Eingabe pruefen und gleiche Artikel (Produkt + Variante) zusammenfassen
if (!Array.isArray(positionen) || positionen.length === 0) return fehler('leer', 'Ihr Warenkorb ist leer.')
const zusammen = new Map()
for (const pos of positionen) {
const produktId = istObjekt(pos) && typeof pos.produktId === 'string' ? pos.produktId : ''
if (produktId === '') return fehler('nicht_verfuegbar', 'Ein Artikel in Ihrem Warenkorb ist leider nicht mehr verfügbar.')
const menge = pos.menge
if (!Number.isInteger(menge) || menge < 1 || menge > MAX_MENGE) {
return fehler('menge', `Bitte wählen Sie eine Menge von 1 bis ${MAX_MENGE}.`, produktId)
}
const varianteId = typeof pos.varianteId === 'string' && pos.varianteId !== '' ? pos.varianteId : null
const schluessel = `${produktId}\u0000${varianteId ?? ''}`
const vorhanden = zusammen.get(schluessel)
if (vorhanden) vorhanden.menge += menge
else zusammen.set(schluessel, { produktId, varianteId, menge })
}
if (zusammen.size > MAX_POSITIONEN) {
return fehler('zu_viele', `Ihr Warenkorb enthält zu viele verschiedene Artikel. Bitte bestellen Sie höchstens ${MAX_POSITIONEN} Positionen auf einmal.`)
}
// 2. Jede Position gegen Produkt, Variante und Bestand pruefen
const zeilen = []
for (const { produktId, varianteId, menge } of zusammen.values()) {
const produkt = produkte instanceof Map ? produkte.get(produktId) : undefined
if (!istObjekt(produkt) || produkt.aktiv !== true) {
const name = istObjekt(produkt) && typeof produkt.name === 'string' ? `„${produkt.name}“` : 'Ein Artikel in Ihrem Warenkorb'
return fehler('nicht_verfuegbar', `${name} ist leider nicht mehr verfügbar.`, produktId)
}
const hatVarianten = istObjekt(produkt.varianten) && Array.isArray(produkt.varianten.werte) && produkt.varianten.werte.length > 0
let wert = null
if (hatVarianten) {
wert = varianteId === null ? null : (produkt.varianten.werte.find((w) => w.id === varianteId) ?? null)
if (wert === null) {
const text = varianteId === null
? `Bitte wählen Sie für „${produkt.name}“ eine Variante (${produkt.varianten.name}) aus.`
: `Die gewählte Variante von „${produkt.name}“ gibt es leider nicht (mehr). Bitte wählen Sie eine andere (${produkt.varianten.name}).`
return fehler('variante', text, produktId)
}
} else if (varianteId !== null) {
return fehler('variante', `Für „${produkt.name}“ gibt es keine Varianten. Bitte entfernen Sie die Auswahl.`, produktId)
}
if (menge > MAX_MENGE) return fehler('menge', `Bitte wählen Sie von „${produkt.name}“ höchstens ${MAX_MENGE} Stück.`, produktId)
const einzelCent = wert ? wert.preisCent : produkt.preisCent
if (!Number.isSafeInteger(einzelCent) || einzelCent < 0) {
return fehler('nicht_verfuegbar', `„${produkt.name}“ ist leider nicht mehr verfügbar.`, produktId)
}
const varianteText = wert ? `${produkt.varianten.name}: ${wert.name}` : ''
const anzeige = varianteText ? `${produkt.name} (${varianteText})` : produkt.name
const bestand = wert ? wert.bestand : produkt.bestand
if (bestand !== null && bestand !== undefined) {
const reservierungKey = wert ? `${produktId}:${varianteId}` : produktId
const reservierteMenge = reserviert instanceof Map ? (reserviert.get(reservierungKey) ?? 0) : 0
const frei = Math.max(0, bestand - reservierteMenge)
if (frei < menge) {
const text = frei === 0
? `„${anzeige}“ ist leider ausverkauft.`
: `Von „${anzeige}“ sind leider nur noch ${frei} Stück verfügbar. Bitte verringern Sie die Menge.`
return fehler('ausverkauft', text, produktId)
}
}
zeilen.push({
produktId,
varianteId: wert ? varianteId : null,
name: produkt.name,
varianteText,
menge,
einzelCent,
summeCent: einzelCent * menge,
steuersatz: produkt.steuersatz ?? 0,
widerruf: produkt.widerruf ?? 'gesetzlich',
nurAbholung: produkt.lieferart === 'abholung',
anzeige,
})
}
// 3. Lieferart
const versand = e.versand ?? {}
const abholung = e.abholung ?? {}
if (lieferart === 'versand') {
if (versand.aktiv !== true) return fehler('lieferart', 'Versand ist in diesem Shop leider nicht möglich.')
if (typeof land !== 'string' || !Array.isArray(versand.laender) || !versand.laender.includes(land)) {
return fehler('land', 'Wir liefern leider nicht in das gewählte Land.')
}
const nur = zeilen.find((z) => z.nurAbholung)
if (nur) return fehler('nur_abholung', `„${nur.anzeige}“ kann nicht versendet werden, sondern nur abgeholt werden.`, nur.produktId)
} else if (lieferart === 'abholung') {
if (abholung.aktiv !== true) return fehler('lieferart', 'Abholung ist in diesem Shop leider nicht möglich.')
} else {
return fehler('lieferart', 'Bitte wählen Sie Versand oder Abholung.')
}
// 4. Summen und Mindestbestellwert
const zwischensummeCent = zeilen.reduce((s, z) => s + z.summeCent, 0)
if (Number.isInteger(e.mindestbestellCent) && zwischensummeCent < e.mindestbestellCent) {
return fehler('mindestbestellwert', `Der Mindestbestellwert beträgt ${euro(e.mindestbestellCent)}. Bitte legen Sie weitere Artikel in den Warenkorb.`)
}
let versandCent = 0
if (lieferart === 'versand') {
const kosten = versand.kostenCent && Object.hasOwn(versand.kostenCent, land) ? versand.kostenCent[land] : 0
const kostenlos = Number.isInteger(versand.freiAbCent) && zwischensummeCent >= versand.freiAbCent
versandCent = kostenlos || !Number.isInteger(kosten) || kosten < 0 ? 0 : kosten
}
const gesamtCent = zwischensummeCent + versandCent
if (gesamtCent < MINDESTBETRAG_CENT) return fehler('mindestbetrag', 'Der Bestellwert muss mindestens 0,50 € betragen.')
// 5. Steuer: je Satz ueber die Summe der Positionen, Versand anteilig nach Bruttowert (Rest auf den hoechsten Satz)
let steuer = []
let steuerHinweis
if (e.kleinunternehmer === true) {
steuerHinweis = KLEINUNTERNEHMER_HINWEIS
} else {
const brutto = nachSteuersatz(zeilen, (z) => z.summeCent)
const saetze = [...brutto.keys()].sort((a, b) => b - a)
if (versandCent > 0 && zwischensummeCent > 0) {
let verteilt = 0
for (const satz of saetze) {
const anteil = Math.floor((versandCent * brutto.get(satz)) / zwischensummeCent)
verteilt += anteil
brutto.set(satz, brutto.get(satz) + anteil)
}
brutto.set(saetze[0], brutto.get(saetze[0]) + (versandCent - verteilt))
}
steuer = saetze
.map((satz) => ({ satz, betragCent: enthalteneSteuer(brutto.get(satz), satz) }))
.filter((s) => s.betragCent > 0)
.map((s) => ({ ...s, text: `inkl. ${s.satz} % USt: ${euro(s.betragCent)}` }))
steuerHinweis = steuer.map((s) => s.text).join('; ')
}
return {
ok: true,
positionen: zeilen.map((z) => ({
produktId: z.produktId,
varianteId: z.varianteId,
name: z.name,
varianteText: z.varianteText,
menge: z.menge,
einzelCent: z.einzelCent,
summeCent: z.summeCent,
steuersatz: z.steuersatz,
widerruf: z.widerruf,
einzelText: euro(z.einzelCent),
summeText: euro(z.summeCent),
})),
zwischensummeCent,
versandCent,
gesamtCent,
steuer,
texte: {
zwischensumme: euro(zwischensummeCent),
versand: lieferart === 'abholung' ? 'Abholung – kostenlos' : versandCent === 0 ? 'kostenlos' : euro(versandCent),
gesamt: euro(gesamtCent),
steuerHinweis,
},
lieferart,
land: lieferart === 'versand' ? land : null,
}
}
+542
View File
@@ -0,0 +1,542 @@
import { randomInt } from 'node:crypto'
import { config } from '../../config.js'
import { createDocument, getDocument, updateDocument, deleteDocument, listAllDocuments, Query } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { euro, grundpreisText } from './geld.js'
/**
* Shop-Artikel (Plan 2026-10-05-webshop.md, Task C3; shopProducts.produkt als JSON-Text).
* Normalisieren ist rein: Eingabe => { produkt } mit frischem Objekt oder { fehler: { 'feld.pfad': 'Du-Text' } }.
* produktOeffentlich liefert nur Anzeigefelder fuer den Kaeufer (Sie-Form entsteht erst im Widget). Geld ist immer ganze Cent.
*/
const MAX_CENT = 10_000_000
const MAX_BESTAND = 1_000_000
const STEUERSAETZE = [0, 7, 19]
const EINHEITEN = ['g', 'kg', 'ml', 'l', 'm', 'm2']
const LIEFERARTEN = ['beides', 'abholung']
const WIDERRUFE = ['gesetzlich', 'verderblich', 'individuell', 'hygiene']
const WERT_ID_RE = /^[a-z0-9]{8}$/
const BILD_ID_RE = /^[a-z0-9-]{1,64}$/
const MAX_JSON = 30000
const WIDERRUF_TEXTE = Object.freeze({
verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware',
individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde',
})
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Nur eigene Schluessel lesen, nie aus der Prototypkette. */
const hole = (objekt, schluessel) => (istObjekt(objekt) && Object.hasOwn(objekt, schluessel) ? objekt[schluessel] : undefined)
const laenge = (s) => [...s].length
// Steuerzeichen und unsichtbare Formatzeichen (Zero-Width, Richtungsmarken, BOM, Soft Hyphen) sowie Zeilen-/Absatztrenner
const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu
/** Bereinigter Text: ohne Steuerzeichen, getrimmt; einzeilig macht aus Umbruechen Leerzeichen, mehrzeilig behaelt einzelne Umbrueche. */
function bereinige(wert, { mehrzeilig = false } = {}) {
if (typeof wert !== 'string') return ''
let s = wert.replace(/\r\n?/g, '\n').replace(/\t/g, ' ')
s = mehrzeilig
? s.replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/[ ]*\n[ ]*/g, '\n').replace(/\n{3,}/g, '\n\n')
: s.replace(/\n+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ')
return s.trim()
}
/** Zufaellige 8-stellige Wert-ID aus [a-z0-9] (kryptografisch, ohne Modulo-Verzerrung). */
function neueWertId() {
const zeichen = 'abcdefghijklmnopqrstuvwxyz0123456789'
let id = ''
for (let i = 0; i < 8; i += 1) id += zeichen[randomInt(zeichen.length)]
return id
}
/** Ganze Zahl aus Zahl oder reinem Ziffern-Text, sonst null. */
function ganzeZahl(wert) {
if (typeof wert === 'number') return Number.isSafeInteger(wert) ? wert : null
if (typeof wert === 'string' && /^\d{1,12}$/.test(wert.trim())) return Number(wert.trim())
return null
}
const leer = (wert) => wert === undefined || wert === null || (typeof wert === 'string' && wert.trim() === '')
/**
* Pruefen und Normalisieren eines Artikels. Unbekannte Schluessel werden ignoriert, das Ergebnis teilt keine Referenzen
* mit der Eingabe. einstellungen bestimmt nur die Steuer (Kleinunternehmer => immer 0 %).
* @returns {{produkt: object} | {fehler: Object<string, string>}}
*/
export function normalisiereProdukt(eingabe, { einstellungen } = {}) {
if (!istObjekt(eingabe)) return { fehler: { produkt: 'Der Artikel fehlt oder ist unlesbar.' } }
const fehler = {}
const fehl = (pfad, text) => {
if (!(pfad in fehler)) fehler[pfad] = text
}
const kleinunternehmer = einstellungen?.kleinunternehmer === true
const text = (pfad, wert, { max, min = 0, mehrzeilig = false, name }) => {
if (wert === undefined || wert === null) {
if (min > 0) fehl(pfad, `Bitte gib ${name} an.`)
return ''
}
if (typeof wert !== 'string') {
fehl(pfad, `Bitte gib ${name} als Text an.`)
return ''
}
const s = bereinige(wert, { mehrzeilig })
if (s.length === 0 && min > 0) fehl(pfad, `Bitte gib ${name} an.`)
else if (laenge(s) > max) fehl(pfad, `${name[0].toUpperCase()}${name.slice(1)} darf höchstens ${max} Zeichen lang sein.`)
return s
}
const cent = (pfad, wert, name) => {
const n = ganzeZahl(wert)
if (n === null || n <= 0 || n > MAX_CENT) {
fehl(pfad, `Bitte gib ${name} als Betrag über 0 € und höchstens 100.000 € an.`)
return null
}
return n
}
const bestandFeld = (pfad, wert) => {
if (leer(wert)) return null
const n = ganzeZahl(wert)
if (n === null || n < 0 || n > MAX_BESTAND) {
fehl(pfad, 'Bitte gib den Bestand als ganze Zahl ab 0 an oder lass das Feld leer für unbegrenzt.')
return null
}
return n
}
const name = text('name', hole(eingabe, 'name'), { max: 120, min: 1, name: 'einen Namen für den Artikel' })
const kategorie = text('kategorie', hole(eingabe, 'kategorie'), { max: 40, name: 'die Kategorie' })
const kurz = text('kurz', hole(eingabe, 'kurz'), { max: 300, name: 'die Kurzbeschreibung' })
const beschreibung = text('beschreibung', hole(eingabe, 'beschreibung'), { max: 5000, mehrzeilig: true, name: 'die Beschreibung' })
const aktivRoh = hole(eingabe, 'aktiv')
let aktiv = true
if (aktivRoh !== undefined && aktivRoh !== null) {
if (typeof aktivRoh !== 'boolean') fehl('aktiv', 'Bitte wähle bei „Aktiv“ ja oder nein.')
else aktiv = aktivRoh
}
// Steuer
let steuersatz
const steuerRoh = hole(eingabe, 'steuersatz')
if (kleinunternehmer) steuersatz = 0
else if (leer(steuerRoh)) steuersatz = 19
else {
const n = ganzeZahl(steuerRoh)
if (n === null || !STEUERSAETZE.includes(n)) {
fehl('steuersatz', 'Bitte wähle als Steuersatz 0 %, 7 % oder 19 %.')
steuersatz = 19
} else steuersatz = n
}
// Varianten (ohne Namen und ohne Werte = keine Varianten)
let varianten = null
const variantenRoh = hole(eingabe, 'varianten')
if (variantenRoh !== undefined && variantenRoh !== null) {
if (!istObjekt(variantenRoh)) fehl('varianten', 'Die Varianten sind unlesbar.')
else {
const wertRoh = hole(variantenRoh, 'werte')
const werteListe = Array.isArray(wertRoh) ? wertRoh : []
const leerVariante = leer(hole(variantenRoh, 'name')) && werteListe.length === 0
if (wertRoh !== undefined && wertRoh !== null && !Array.isArray(wertRoh)) fehl('varianten.werte', 'Die Werte der Variante sind unlesbar.')
else if (!leerVariante) {
const vName = text('varianten.name', hole(variantenRoh, 'name'), { max: 30, min: 1, name: 'einen Namen für die Variante (zum Beispiel „Größe“)' })
const max = config.shop.maxVarianten
if (werteListe.length === 0) fehl('varianten.werte', 'Bitte lege mindestens einen Wert für die Variante an.')
else if (werteListe.length > max) fehl('varianten.werte', `Du kannst höchstens ${max} Werte je Variante anlegen.`)
const benutzt = new Set()
const namen = new Set()
const werte = []
werteListe.slice(0, max).forEach((w, i) => {
const pf = `varianten.werte.${i}`
if (!istObjekt(w)) {
fehl(pf, 'Dieser Wert ist unlesbar.')
return
}
let id = hole(w, 'id')
if (typeof id !== 'string' || !WERT_ID_RE.test(id) || benutzt.has(id)) {
do id = neueWertId()
while (benutzt.has(id))
}
benutzt.add(id)
const wName = text(`${pf}.name`, hole(w, 'name'), { max: 40, min: 1, name: 'einen Namen für den Wert' })
if (wName && namen.has(wName.toLowerCase())) fehl(`${pf}.name`, `Den Wert „${wName}“ gibt es schon, bitte wähle einen anderen Namen.`)
namen.add(wName.toLowerCase())
werte.push({
id,
name: wName,
preisCent: cent(`${pf}.preisCent`, hole(w, 'preisCent'), 'den Preis des Werts'),
bestand: bestandFeld(`${pf}.bestand`, hole(w, 'bestand')),
})
})
varianten = { name: vName, werte }
}
}
}
// Preis und Bestand: mit Varianten zaehlen nur die Werte
let preisCent = null
let bestand = null
if (!varianten) {
preisCent = cent('preisCent', hole(eingabe, 'preisCent'), 'den Preis')
bestand = bestandFeld('bestand', hole(eingabe, 'bestand'))
}
// Grundpreis
let grundpreis = null
const gpRoh = hole(eingabe, 'grundpreis')
if (gpRoh !== undefined && gpRoh !== null) {
if (!istObjekt(gpRoh)) fehl('grundpreis', 'Der Grundpreis ist unlesbar.')
else if (!(leer(hole(gpRoh, 'menge')) && leer(hole(gpRoh, 'einheit')))) {
let mengeRoh = hole(gpRoh, 'menge')
if (typeof mengeRoh === 'string') mengeRoh = /^\d+([.,]\d+)?$/.test(mengeRoh.trim()) ? Number(mengeRoh.trim().replace(',', '.')) : NaN
const einheit = hole(gpRoh, 'einheit')
const mengeOk = typeof mengeRoh === 'number' && Number.isFinite(mengeRoh) && mengeRoh > 0 && mengeRoh <= 1_000_000
const einheitOk = typeof einheit === 'string' && EINHEITEN.includes(einheit)
if (!mengeOk) fehl('grundpreis.menge', 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.')
if (!einheitOk) fehl('grundpreis.einheit', 'Bitte wähle als Einheit für den Grundpreis g, kg, ml, l, m oder m².')
if (mengeOk && einheitOk) {
if (grundpreisText({ preisCent: 100, menge: mengeRoh, einheit }) === '') fehl('grundpreis.menge', 'Diese Menge ist für den Grundpreis zu klein oder zu groß.')
else grundpreis = { menge: mengeRoh, einheit }
}
}
}
// Bilder
const bilder = []
const bilderRoh = hole(eingabe, 'bilder')
if (bilderRoh !== undefined && bilderRoh !== null) {
if (!Array.isArray(bilderRoh)) fehl('bilder', 'Die Bilder sind unlesbar.')
else if (bilderRoh.length > config.shop.maxBilder) fehl('bilder', `Du kannst höchstens ${config.shop.maxBilder} Bilder je Artikel anlegen.`)
else {
for (const id of bilderRoh) {
if (typeof id !== 'string' || !BILD_ID_RE.test(id)) {
fehl('bilder', 'Eines der Bilder ist ungültig, bitte lade es neu hoch.')
break
}
if (bilder.includes(id)) {
fehl('bilder', 'Du hast dasselbe Bild mehrfach hinzugefügt.')
break
}
bilder.push(id)
}
}
}
// Lieferart und Widerruf
let lieferart = 'beides'
const lieferRoh = hole(eingabe, 'lieferart')
if (!leer(lieferRoh)) {
if (typeof lieferRoh === 'string' && LIEFERARTEN.includes(lieferRoh)) lieferart = lieferRoh
else fehl('lieferart', 'Bitte wähle als Lieferart „Versand und Abholung“ oder „Nur Abholung“.')
}
let widerruf = 'gesetzlich'
const widerrufRoh = hole(eingabe, 'widerruf')
if (!leer(widerrufRoh)) {
if (typeof widerrufRoh === 'string' && WIDERRUFE.includes(widerrufRoh)) widerruf = widerrufRoh
else fehl('widerruf', 'Bitte wähle eine der angebotenen Möglichkeiten beim Widerrufsrecht.')
}
// Produktsicherheit (GPSR)
const siRoh = hole(eingabe, 'sicherheit')
const adresse = (pfad, wert, { pflicht, bezeichnung }) => {
if (!istObjekt(wert)) {
if (pflicht) fehl(pfad, `Bitte gib ${bezeichnung} mit Name, Anschrift und Kontakt an.`)
return null
}
const felder = { name: 'den Namen', anschrift: 'die Anschrift', kontakt: 'einen Kontakt (E-Mail oder Telefon)' }
const roh = Object.fromEntries(Object.keys(felder).map((k) => [k, hole(wert, k)]))
if (!pflicht && Object.values(roh).every(leer)) return null
const r = {}
for (const [k, was] of Object.entries(felder)) r[k] = text(`${pfad}.${k}`, roh[k], { max: k === 'anschrift' ? 300 : 120, min: 1, name: `${was} bei ${bezeichnung}` })
return r
}
// Fehlt der Hersteller ganz, gilt „selbst“ (Standard des Formulars); ein Objekt braucht Name, Anschrift und Kontakt
let hersteller = 'selbst'
const hRoh = hole(siRoh, 'hersteller')
if (hRoh !== undefined && hRoh !== null && hRoh !== 'selbst') {
if (istObjekt(hRoh)) hersteller = adresse('sicherheit.hersteller', hRoh, { pflicht: true, bezeichnung: 'den Hersteller' })
else fehl('sicherheit.hersteller', 'Bitte gib den Hersteller mit Name, Anschrift und Kontakt an oder wähle „Ich bin der Hersteller“.')
}
const euVerantwortlicher = adresse('sicherheit.euVerantwortlicher', hole(siRoh, 'euVerantwortlicher'), { pflicht: false, bezeichnung: 'den EU-Verantwortlichen' })
const hinweise = text('sicherheit.hinweise', hole(siRoh, 'hinweise'), { max: 2000, mehrzeilig: true, name: 'die Sicherheitshinweise' })
// Lebensmittel
const lmRoh = hole(eingabe, 'lebensmittel')
const lmAktivRoh = hole(lmRoh, 'aktiv')
if (lmAktivRoh !== undefined && lmAktivRoh !== null && typeof lmAktivRoh !== 'boolean') fehl('lebensmittel.aktiv', 'Bitte wähle bei „Lebensmittel“ ja oder nein.')
const lmAktiv = lmAktivRoh === true
const pflichtangaben = text('lebensmittel.pflichtangaben', hole(lmRoh, 'pflichtangaben'), { max: 4000, mehrzeilig: true, min: lmAktiv ? 1 : 0, name: 'die Pflichtangaben für Lebensmittel (Zutaten, Allergene, Füllmenge)' })
if (Object.keys(fehler).length > 0) return { fehler }
return {
produkt: {
name,
kategorie,
kurz,
beschreibung,
preisCent,
steuersatz,
grundpreis,
bestand,
aktiv,
varianten,
bilder,
lieferart,
widerruf,
sicherheit: { hersteller, euVerantwortlicher, hinweise },
lebensmittel: { aktiv: lmAktiv, pflichtangaben },
},
}
}
/** Gespeicherten JSON-Text sicher lesen; null bei kaputten Daten. */
export function parseProdukt(doc) {
try {
const p = JSON.parse(doc?.produkt)
return istObjekt(p) ? p : null
} catch {
return null
}
}
/** Menge, die noch bestellbar ist: null = unbegrenzt. */
function frei(bestand, reserviert, schluessel) {
if (!Number.isInteger(bestand)) return null
const r = reserviert instanceof Map ? Number(reserviert.get(schluessel)) || 0 : 0
return Math.max(0, bestand - r)
}
/**
* Sicht des Kaeufers auf einen Artikel (Anzeigefelder, fertige Texte). Liefert null bei unlesbarem Datensatz.
* Inaktive Artikel filtert der Aufrufer (doc.aktiv / produkt.aktiv).
* reserviert: Map<produktId | produktId:variantId, Menge> offener Bestellungen.
*/
export function produktOeffentlich(produktDoc, { einstellungen, reserviert = new Map(), siteKey } = {}) {
void einstellungen
const p = parseProdukt(produktDoc)
if (!p) return null
const id = produktDoc.$id
const gp = p.grundpreis && typeof p.grundpreis === 'object' ? p.grundpreis : null
const grundpreisFuer = (preisCent) => (gp && Number.isInteger(preisCent) ? grundpreisText({ preisCent, menge: gp.menge, einheit: gp.einheit }) : '')
let preisText = ''
let preisAbText
let gpText = ''
let variantenOeffentlich = null
let verfuegbar
const v = p.varianten && typeof p.varianten === 'object' && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0 ? p.varianten : null
if (v) {
const werte = v.werte.map((w) => {
const rest = frei(w.bestand, reserviert, `${id}:${w.id}`)
return {
id: w.id,
name: w.name,
preisText: Number.isInteger(w.preisCent) ? euro(w.preisCent) : '',
grundpreisText: grundpreisFuer(w.preisCent),
verfuegbar: rest === null || rest > 0,
}
})
const preise = v.werte.map((w) => w.preisCent).filter(Number.isInteger)
const min = preise.length ? Math.min(...preise) : null
const max = preise.length ? Math.max(...preise) : null
if (min !== null) {
preisText = euro(min)
if (min !== max) preisAbText = `ab ${euro(min)}`
gpText = min === max ? grundpreisFuer(min) : ''
}
variantenOeffentlich = { name: v.name, werte }
verfuegbar = werte.some((w) => w.verfuegbar)
} else {
if (Number.isInteger(p.preisCent)) {
preisText = euro(p.preisCent)
gpText = grundpreisFuer(p.preisCent)
}
const rest = frei(p.bestand, reserviert, id)
verfuegbar = rest === null || rest > 0
}
const si = istObjekt(p.sicherheit) ? p.sicherheit : {}
const adr = (a) => (istObjekt(a) ? { name: a.name ?? '', anschrift: a.anschrift ?? '', kontakt: a.kontakt ?? '' } : null)
const key = encodeURIComponent(String(siteKey ?? ''))
const bilder = (Array.isArray(p.bilder) ? p.bilder : [])
.filter((b) => typeof b === 'string' && BILD_ID_RE.test(b))
.map((b) => ({ klein: `/api/public/shop/v1/${key}/bild/${b}-s.webp`, gross: `/api/public/shop/v1/${key}/bild/${b}-l.webp` }))
const lm = istObjekt(p.lebensmittel) && p.lebensmittel.aktiv === true ? { pflichtangaben: p.lebensmittel.pflichtangaben ?? '' } : null
const ergebnis = {
id,
name: p.name ?? '',
kategorie: p.kategorie ?? '',
kurz: p.kurz ?? '',
beschreibung: p.beschreibung ?? '',
preisText,
grundpreisText: gpText,
steuersatz: p.steuersatz ?? 0,
varianten: variantenOeffentlich,
bilder,
lieferart: p.lieferart === 'abholung' ? 'abholung' : 'beides',
lieferHinweis: p.lieferart === 'abholung' ? 'Nur Abholung' : '',
widerruf: p.widerruf ?? 'gesetzlich',
widerrufText: WIDERRUF_TEXTE[p.widerruf] ?? '',
sicherheit: {
hersteller: si.hersteller === 'selbst' ? null : adr(si.hersteller),
euVerantwortlicher: adr(si.euVerantwortlicher),
hinweise: si.hinweise ?? '',
},
lebensmittel: lm,
verfuegbar,
ausverkauft: !verfuegbar,
}
if (preisAbText) ergebnis.preisAbText = preisAbText
return ergebnis
}
// ---------------------------------------------------------------------------------------------------------------------
// Speichern (shopProducts)
const fehlerMit = (status, message) => Object.assign(new Error(message), { status })
const sperre = (shopId) => `shop-produkte:${shopId}`
const col = () => config.collections.shopProducts
const reihenfolge = (a, b) => (a.sort ?? 0) - (b.sort ?? 0) || String(a.$createdAt ?? '').localeCompare(String(b.$createdAt ?? '')) || String(a.$id).localeCompare(String(b.$id))
/** Alle Artikel-Dokumente eines Shops, sortiert nach sort, dann Anlegezeit. */
export async function listeProdukte(shopId) {
const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId)])
return docs.sort(reihenfolge)
}
/** Ein Artikel-Dokument oder null (nicht vorhanden oder gehoert zu einem anderen Shop). */
export async function holeProdukt(shopId, id) {
if (typeof id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id)) return null
let doc
try {
doc = await getDocument(col(), id)
} catch (error) {
if (error?.status === 404) return null
throw error
}
return doc && doc.shopId === shopId ? doc : null
}
const bestandRoh = (wert) => {
if (wert === null || wert === undefined || (typeof wert === 'string' && wert.trim() === '')) return { ok: true, wert: null }
const n = ganzeZahl(wert)
return n !== null && n >= 0 ? { ok: true, wert: n } : { ok: false }
}
/**
* Liest aus dem rohen Artikel des Browsers, welchen Bestand er beim Laden gesehen hat (bestandGeladen am Artikel, je Variantenwert
* mit id am Wert). hat: false, wenn der Browser nichts (Brauchbares) gesendet hat. Das Ergebnis geht an speichereProdukt.
* @returns {{ hat: boolean, bestand?: number|null, werte: Map<string, number|null> }}
*/
export function leseBestandGeladen(roh) {
const werte = new Map()
const liste = hole(hole(roh, 'varianten'), 'werte')
if (Array.isArray(liste)) {
for (const w of liste) {
const id = hole(w, 'id')
if (typeof id !== 'string' || !WERT_ID_RE.test(id) || !Object.hasOwn(w, 'bestandGeladen')) continue
const b = bestandRoh(w.bestandGeladen)
if (b.ok) werte.set(id, b.wert)
}
}
if (!istObjekt(roh) || !Object.hasOwn(roh, 'bestandGeladen')) return { hat: false, werte }
const b = bestandRoh(roh.bestandGeladen)
return b.ok ? { hat: true, bestand: b.wert, werte } : { hat: false, werte }
}
/** Bestand uebernehmen: hat der Haendler den Wert nicht angefasst (gesendet == geladen), bleibt der aktuell gespeicherte Stand. */
function uebernehmeBestand(neu, alt, geladen) {
if (!istObjekt(alt) || !istObjekt(geladen)) return neu
const kopie = { ...neu }
if (geladen.hat && !neu.varianten && !alt.varianten && (neu.bestand ?? null) === geladen.bestand) kopie.bestand = alt.bestand ?? null
if (neu.varianten && Array.isArray(neu.varianten.werte) && alt.varianten && Array.isArray(alt.varianten.werte) && geladen.werte instanceof Map) {
const aktuell = new Map(alt.varianten.werte.map((w) => [w.id, w]))
kopie.varianten = {
...neu.varianten,
werte: neu.varianten.werte.map((w) => {
const vorher = aktuell.get(w.id)
return vorher && geladen.werte.has(w.id) && (w.bestand ?? null) === geladen.werte.get(w.id) ? { ...w, bestand: vorher.bestand ?? null } : w
}),
}
}
return kopie
}
/**
* Legt einen Artikel an (id = null, ans Ende sortiert, hoechstens config.shop.maxArtikel) oder aktualisiert ihn (Sortierung bleibt).
* produkt ist das Ergebnis von normalisiereProdukt. Fehler tragen .status (400/404/409) und einen deutschen Text.
* opt.geladen (leseBestandGeladen): Beim Aendern behalten Bestaende, die der Haendler nicht angefasst hat (gesendet == geladen),
* den aktuell gespeicherten Stand, damit zwischenzeitliche Verkaeufe nicht ueberschrieben werden. Ohne geladen gilt der gesendete Bestand.
*/
export async function speichereProdukt({ shopId, customerId }, id, produkt, { geladen } = {}) {
if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.')
const pruefeGroesse = (obj) => {
const json = JSON.stringify(obj)
if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.')
return json
}
const json = pruefeGroesse(produkt)
const daten = { name: produkt.name, aktiv: produkt.aktiv !== false, produkt: json }
return withLocks([sperre(shopId)], async () => {
if (id) {
const vorhanden = await holeProdukt(shopId, id)
if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).')
if (geladen) {
const zusammen = uebernehmeBestand(produkt, parseProdukt(vorhanden), geladen)
return updateDocument(col(), id, { ...daten, produkt: pruefeGroesse(zusammen) })
}
return updateDocument(col(), id, daten)
}
const alle = await listeProdukte(shopId)
const max = config.shop.maxArtikel
if (alle.length >= max) throw fehlerMit(409, `Du hast die Höchstzahl von ${max} Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.`)
const sort = alle.reduce((m, d) => Math.max(m, Number.isInteger(d.sort) ? d.sort : -1), -1) + 1
return createDocument(col(), { shopId, customerId, sort, ...daten })
})
}
/** Loescht einen Artikel; 404, wenn er nicht (mehr) zu diesem Shop gehoert. */
export async function loescheProdukt(shopId, id) {
return withLocks([sperre(shopId)], async () => {
const vorhanden = await holeProdukt(shopId, id)
if (!vorhanden) throw fehlerMit(404, 'Diesen Artikel gibt es nicht (mehr).')
await deleteDocument(col(), id)
return true
})
}
/** Setzt die Reihenfolge: ids muessen genau die Artikel des Shops enthalten (jeder einmal), sonst 400. Schreibt sort 0..n-1. */
export async function sortiere(shopId, ids) {
if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw fehlerMit(400, 'Die Reihenfolge ist ungültig.')
return withLocks([sperre(shopId)], async () => {
const alle = await listeProdukte(shopId)
const vorhanden = new Set(alle.map((d) => d.$id))
if (new Set(ids).size !== ids.length || ids.length !== vorhanden.size || ids.some((i) => !vorhanden.has(i))) {
throw fehlerMit(400, 'Die Reihenfolge passt nicht zu deinen Artikeln. Bitte lade die Seite neu und versuche es noch einmal.')
}
const nachId = new Map(alle.map((d) => [d.$id, d]))
const neu = []
for (const [index, docId] of ids.entries()) {
const doc = nachId.get(docId)
neu.push(doc.sort === index ? doc : await updateDocument(col(), docId, { sort: index }))
}
return neu
})
}
/** Alle Bild-IDs, die Artikel des Shops verwenden (Set), damit Aufraeumjobs keine benutzten Bilder loeschen. */
export async function bildIdsInGebrauch(shopId) {
const ids = new Set()
for (const doc of await listeProdukte(shopId)) {
const p = parseProdukt(doc)
if (Array.isArray(p?.bilder)) for (const b of p.bilder) if (typeof b === 'string') ids.add(b)
}
return ids
}
+391
View File
@@ -0,0 +1,391 @@
import { createHash } from 'node:crypto'
import { euro } from './geld.js'
import { STANDARD_EINSTELLUNGEN } from './einstellungen.js'
/**
* Muster-Rechtstexte des Haendlers (Plan 2026-10-05-webshop.md, Task C5; Spec 4.6).
* Alles rein: Einstellungen rein, Klartext raus (Absaetze mit '\n\n' getrennt), Sie-Form fuer Verbraucher.
* Das sind MUSTER, keine Rechtsberatung. Der Haendler prueft, passt an und bestaetigt selbst (BESTAETIGUNG_TEXT).
* Quellen: Anlage 1 und 2 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB (Stand 2026, mit Baustein zur Widerrufsfunktion, § 356a BGB),
* § 312g Abs. 2 BGB, § 36 VSBG, VO (EU) 2025/1960 Anhang I; siehe /root/reports/webshop-2026-10-05/recherche-recht-stripe.md
* (B.2, B.3, B.3a, B.8). Dieselben Texte mit Platzhaltern: docs/funktionen/shop-muster-rechtstexte.md.
*/
export const BESTAETIGUNG_TEXT =
'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.'
// Grenzen so gewaehlt, dass die Bestaetigungs-E-Mail G09 mit allen drei Texten in die Warteschlange passt (html 100000, text 50000;
// die Vorlage braucht rund 12000 Zeichen) und der Volltext nie durch einen Link ersetzt werden muss (§ 312f Abs. 2 BGB).
export const MAX_AGB = 20000
export const MAX_WIDERRUFSBELEHRUNG = 12000
export const MAX_WIDERRUFSFORMULAR = 3000
/** So viele Zeichen duerfen die drei Texte zusammen im HTML der Mail belegen (maskierte Zeichen und Zeilenumbrueche zaehlen mehrfach). */
export const MAX_MAIL_HTML_TEXTE = 60000
const MAX_URL = 500
/** Erste Zeile des vorbefuellten Muster-AGB: der Haendler muss sie nach der Pruefung loeschen. */
export const MUSTER_KOPFZEILE = 'Muster – bitte prüfen und anpassen'
const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' }
const ZAHLUNGSARTEN_REIHENFOLGE = ['card', 'klarna', 'sepa_debit']
const ZAHLUNGSARTEN_TEXT = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' }
/** Einstellungen mit Standardwerten auffuellen (Unterobjekte eine Ebene tief), damit Teilobjekte nicht werfen. */
function mitStandard(e) {
const std = STANDARD_EINSTELLUNGEN
const roh = e && typeof e === 'object' ? e : {}
const sub = (k) => ({ ...std[k], ...(roh[k] && typeof roh[k] === 'object' && !Array.isArray(roh[k]) ? roh[k] : {}) })
return { ...std, ...roh, anbieter: sub('anbieter'), versand: sub('versand'), abholung: sub('abholung') }
}
const text = (s) => (typeof s === 'string' ? s.trim() : '')
function anschrift(a) {
const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ')
const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : ''
return [text(a.strasse), ort, land].filter(Boolean).join(', ') || '[Anschrift]'
}
function anbieterZeile(a) {
return [text(a.name) || '[Name]', anschrift(a), text(a.telefon) ? `Telefon: ${text(a.telefon)}` : '', `E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}`]
.filter(Boolean)
.join(', ')
}
function laenderListe(e) {
const laender = Array.isArray(e.versand.laender) ? e.versand.laender : []
return laender.map((l) => LANDNAMEN[l] ?? l).join(', ')
}
function steuerSatz(e) {
return e.kleinunternehmer
? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.'
: 'Alle Preise enthalten die gesetzliche Umsatzsteuer.'
}
/** 'Kreditkarte, Apple Pay, Google Pay, Klarna': aktive Zahlungsarten in fester Reihenfolge. Auch fuer die Widget-Konfiguration. */
export function zahlungsartenText(e) {
const arten = Array.isArray(e?.zahlungsarten) ? e.zahlungsarten : []
return ZAHLUNGSARTEN_REIHENFOLGE.filter((a) => arten.includes(a))
.map((a) => ZAHLUNGSARTEN_TEXT[a])
.join(', ')
}
/**
* Widerrufsbelehrung fuer Waren: gesetzliches Muster (Anlage 1 zu Art. 246a § 1 Abs. 2 S. 2 EGBGB, Stand 2026) mit Baustein
* zur Widerrufsfunktion (§ 356a BGB).
* Gestaltungshinweise, so gewaehlt: Fristbeginn = Alternative "Kaufvertrag" (Waren in Besitz genommen) UND Alternative "mehrere
* Waren einer einheitlichen Bestellung, getrennt geliefert" (letzte Ware), beide als eigene Saetze, weil der Warenkorb beides
* erzeugen kann. Teilsendungen/regelmaessige Lieferung entfallen (kommt im Shop nicht vor). Nicht abgeholt: "Wir holen die Waren ab"
* entfaellt, dafuer die Rueckgabe-Alternative (zurueckzusenden oder zu uebergeben) samt Rueckzahlungsverweigerungs-Satz.
* Rueckkosten nach e.ruecksendekosten ('kaeufer' = Standard, 'haendler').
* @param {object} e Einstellungen
* @param {{ widerrufUrl?: string }} [opts] Adresse der Widerrufsfunktion im Shop
*/
export function musterWiderrufsbelehrung(e, { widerrufUrl = '' } = {}) {
e = mitStandard(e)
const a = e.anbieter
const kontakt = anbieterZeile(a)
const url = text(widerrufUrl) || '[Internetadresse der Widerrufsfunktion]'
const rueckadresse = `${text(a.name) || '[Name]'}, ${anschrift(a)}`
const kosten = e.ruecksendekosten === 'haendler' ? 'Wir tragen die Kosten der Rücksendung der Waren.' : 'Sie tragen die unmittelbaren Kosten der Rücksendung der Waren.'
const abholung = e.abholung.aktiv
? `Haben Sie die Waren bei uns abgeholt, übergeben Sie diese zur Rückgabe an unserer Anschrift: ${text(e.abholung.adresse) || rueckadresse}.`
: ''
return [
'Widerrufsbelehrung',
'Widerrufsrecht',
'Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
'Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen haben bzw. hat.',
'Haben Sie mehrere Waren im Rahmen einer einheitlichen Bestellung bestellt und werden diese getrennt geliefert, beträgt die Widerrufsfrist vierzehn Tage ab dem Tag, an dem Sie oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist, die letzte Ware in Besitz genommen haben bzw. hat.',
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.`,
`Sie können Ihr Widerrufsrecht auch online unter ${url} ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.`,
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
'Folgen des Widerrufs',
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.',
'Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis Sie den Nachweis erbracht haben, dass Sie die Waren zurückgesandt haben, je nachdem, welches der frühere Zeitpunkt ist.',
`Sie haben die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns (${rueckadresse}) zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden.`,
...(abholung ? [abholung] : []),
kosten,
'Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.',
'Ausschluss bzw. vorzeitiges Erlöschen des Widerrufsrechts',
[
'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Verbrauchers zugeschnitten sind (§ 312g Abs. 2 Nr. 1 BGB), z. B. Waren mit Wunschaufdruck oder Maßanfertigungen.',
'Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde (§ 312g Abs. 2 Nr. 2 BGB), z. B. frische Lebensmittel.',
'Das Widerrufsrecht erlischt vorzeitig bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB).',
].join('\n'),
'Ende der Widerrufsbelehrung',
].join('\n\n')
}
/** Muster-Widerrufsformular (Anlage 2 zu Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB), Unternehmer eingesetzt. */
export function musterWiderrufsformular(e) {
e = mitStandard(e)
const a = e.anbieter
return [
'Muster-Widerrufsformular',
'(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)',
`An ${text(a.name) || '[Name]'}, ${anschrift(a)}, E-Mail: ${text(a.email) || '[E-Mail-Adresse]'}:`,
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
['– Bestellt am (*)/erhalten am (*)', '– Name des/der Verbraucher(s)', '– Anschrift des/der Verbraucher(s)', '– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)', '– Datum'].join('\n'),
'(*) Unzutreffendes streichen.',
].join('\n\n')
}
/** Kurze Muster-AGB fuer kleine Shops. Kopfzeile kennzeichnet sie als Muster; der Haendler loescht sie nach der Pruefung. */
export function musterAgb(e) {
e = mitStandard(e)
const a = e.anbieter
const name = text(a.name) || '[Name]'
const arten = Array.isArray(e.zahlungsarten) ? e.zahlungsarten : []
const zahlung = zahlungsartenText(e)
const abs = []
abs.push(MUSTER_KOPFZEILE)
abs.push('Allgemeine Geschäftsbedingungen')
abs.push(
`§ 1 Geltung und Anbieter\n\nDiese Allgemeinen Geschäftsbedingungen gelten für alle Bestellungen von Verbrauchern in unserem Online-Shop. Anbieter und Vertragspartner ist ${anbieterZeile(a)}${text(a.ustId) ? `, USt-IdNr.: ${text(a.ustId)}` : ''}.`
)
const sofortArten = arten.filter((a) => a !== 'sepa_debit')
const sofort = sofortArten.length || !arten.includes('sepa_debit')
const bestaetigung = [
'Den Eingang Ihrer Bestellung bestätigen wir Ihnen sofort auf einer Bestätigungsseite im Shop.',
...(sofort
? [
`Bei ${sofortArten.length ? `den Zahlungsarten ${zahlungsartenText({ zahlungsarten: sofortArten })}` : 'der Zahlung'} erhalten Sie die Bestellbestätigung per E-Mail unmittelbar nach erfolgreicher Zahlung; mit dieser E-Mail nehmen wir Ihre Bestellung an und der Vertrag kommt zustande.`,
]
: []),
...(arten.includes('sepa_debit')
? ['Bei SEPA-Lastschrift senden wir Ihnen zunächst eine E-Mail über den Eingang Ihrer Bestellung; der Vertrag kommt in diesem Fall erst zustande, wenn wir Ihnen nach Eingang der Zahlung die Bestellbestätigung per E-Mail senden.']
: []),
].join(' ')
abs.push(
`§ 2 Vertragsschluss\n\nDie Darstellung der Waren im Shop ist kein verbindliches Angebot. Mit dem Klick auf die Schaltfläche „Zahlungspflichtig bestellen“ geben Sie eine verbindliche Bestellung ab. ${bestaetigung} Bestellungen, die nicht bezahlt werden, werden nach Ablauf der Zahlungsfrist verworfen.`
)
abs.push(
`§ 3 Preise und Versandkosten\n\nEs gelten die zum Zeitpunkt der Bestellung im Shop angegebenen Preise in Euro. ${steuerSatz(e)} Versandkosten und etwaige weitere Kosten werden vor Abgabe der Bestellung gesondert ausgewiesen und richten sich nach dem Zielland. Einzelheiten finden Sie unter „Versand & Zahlung“.`
)
let z = `§ 4 Zahlung\n\nDie Zahlung erfolgt über den Zahlungsdienstleister Stripe. Es stehen folgende Zahlungsarten zur Verfügung: ${zahlung || '[Zahlungsarten]'}.`
if (arten.includes('sepa_debit')) z += ' Bei SEPA-Lastschrift versenden wir die Ware erst nach Eingang der Zahlung.'
abs.push(z)
let l = '§ 5 Lieferung und Abholung\n\n'
const teile = []
if (e.versand.aktiv) teile.push(`Wir liefern in folgende Länder: ${laenderListe(e) || '[Länder]'}.`)
if (e.abholung.aktiv) teile.push(`Sie können die Waren auch bei uns abholen: ${text(e.abholung.adresse) || '[Abholadresse]'}.${text(e.abholung.hinweis) ? ` ${text(e.abholung.hinweis)}` : ''}`)
if (!teile.length) teile.push('Angaben zur Lieferung und Abholung: [bitte ergänzen].')
l += teile.join(' ')
l += ` Die Lieferzeit beträgt ${text(e.lieferzeit) || '[Lieferzeit]'}, gerechnet ab Zahlungseingang. Ist eine Ware nicht verfügbar, informieren wir Sie unverzüglich.`
abs.push(l)
abs.push(`§ 6 Eigentumsvorbehalt\n\nDie Waren bleiben bis zur vollständigen Bezahlung unser Eigentum.`)
abs.push('§ 7 Widerrufsrecht\n\nVerbrauchern steht ein gesetzliches Widerrufsrecht zu. Einzelheiten ergeben sich aus der Widerrufsbelehrung, die Sie unter „Widerrufsbelehrung“ im Shop finden.')
abs.push(
'§ 8 Gewährleistung\n\nEs gelten die gesetzlichen Mängelhaftungsrechte. Der Hinweis auf das gesetzliche Gewährleistungsrecht steht vor Abgabe der Bestellung im Shop.'
)
abs.push(
'§ 9 Technische Schritte, Vertragstext und Sprache\n\nZum Vertragsschluss führen folgende Schritte: Sie legen die gewünschten Waren in den Warenkorb, prüfen die Bestellübersicht mit allen Angaben und klicken auf „Zahlungspflichtig bestellen“. Danach zahlen Sie bei unserem Zahlungsdienstleister Stripe. Der Vertrag kommt nach § 2 mit unserer Bestellbestätigung per E-Mail zustande. Vor dem Klick auf „Zahlungspflichtig bestellen“ können Sie Eingabefehler erkennen und berichtigen: Im Warenkorb ändern Sie Mengen oder entfernen Waren, aus der Bestellübersicht gelangen Sie über „Zurück zum Warenkorb“ dorthin zurück, und Ihre Angaben zu Adresse und Zahlung prüfen und korrigieren Sie bei Stripe, bevor Sie die Zahlung abschließen. Wir speichern den Vertragstext nach Vertragsschluss nicht für Sie abrufbar. Ihre Bestelldaten, diese Allgemeinen Geschäftsbedingungen und die Widerrufsbelehrung erhalten Sie mit der Bestellbestätigung per E-Mail; bitte bewahren Sie diese E-Mail auf. Die Vertragssprache ist Deutsch. Wir unterwerfen uns keinen besonderen Verhaltenskodizes.'
)
abs.push(
`§ 10 Streitbeilegung\n\n${name} ist nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).`
)
return abs.join('\n\n')
}
/** Aus den Einstellungen erzeugter Text zu Versand und Zahlung (nicht frei editierbar, damit er stimmt). */
export function versandUndZahlung(e) {
e = mitStandard(e)
const abs = ['Versand und Zahlung']
if (e.versand.aktiv) {
abs.push(`Liefergebiet: ${laenderListe(e) || '–'}`)
const laender = Array.isArray(e.versand.laender) ? e.versand.laender : []
const zeilen = laender
.filter((l) => Number.isInteger(e.versand.kostenCent?.[l]))
.map((l) => `${LANDNAMEN[l] ?? l}: ${euro(e.versand.kostenCent[l])}`)
if (zeilen.length) abs.push(`Versandkosten:\n${zeilen.join('\n')}`)
if (Number.isInteger(e.versand.freiAbCent)) abs.push(`Versandkostenfrei ab einem Bestellwert von ${euro(e.versand.freiAbCent)}.`)
} else {
abs.push('Versand: Wir versenden derzeit nicht.')
}
if (e.abholung.aktiv) {
abs.push(`Abholung: ${text(e.abholung.adresse) || '–'}${text(e.abholung.hinweis) ? `\n${text(e.abholung.hinweis)}` : ''}`)
}
abs.push(`Lieferzeit: ${text(e.lieferzeit) || '–'}`)
abs.push(`Zahlungsarten: ${zahlungsartenText(e) || '–'}`)
if (Number.isInteger(e.mindestbestellCent)) abs.push(`Mindestbestellwert: ${euro(e.mindestbestellCent)}`)
abs.push(e.kleinunternehmer ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.' : 'Alle Preise inkl. gesetzlicher USt.')
return abs.join('\n\n')
}
/**
* Harmonisierte Mitteilung ueber das gesetzliche Gewaehrleistungsrecht (Art. 246a § 1 Abs. 1 S. 1 Nr. 11 EGBGB,
* Durchfuehrungsverordnung (EU) 2025/1960 Anhang I, ABl. L vom 2.10.2025, seit 27.09.2026). Die Mitteilung ist eine feste Grafik
* (keines der Elemente editierbar, online farbig RGB): public/img/gewaehrleistung-eu.webp, aus dem Amtsblatt ausgeschnitten.
* Dieser Text ist der woertliche Text der Grafik (Reihenfolge der Grafik), fuer Alternativtext und Bestellbestaetigung.
*/
export const GEWAEHRLEISTUNG_BILD = '/img/gewaehrleistung-eu.webp'
export function gewaehrleistungsHinweis() {
return [
'GESETZLICHE GEWÄHRLEISTUNG',
'Mindestens zwei Jahre gesetzliche Gewährleistung der Vertragsmäßigkeit für Waren, die in der Europäischen Union verkauft werden.',
'Verbraucherinnen und Verbraucher können ihre Rechte im Rahmen des gesetzlichen Gewährleistungsrechts geltend machen, z. B. wenn die Waren nicht der Beschreibung entsprechen, nicht bestimmungsgemäß funktionieren.',
'Verkäufer haften für jede Vertragswidrigkeit, die zum Zeitpunkt der Lieferung der Waren bestand und innerhalb des Zeitraums der gesetzlichen Gewährleistung erkennbar wird. Verkäufer müssen in solchen Fällen Folgendes anbieten: kostenlose Nachbesserung oder kostenlose Ersatzlieferung, in bestimmten Fällen eine Preisminderung oder eine vollständige Erstattung des Kaufpreises.',
'In einigen Ländern gilt ein längerer Zeitraum für die gesetzliche Gewährleistung. Für gebrauchte Waren kann ein kürzerer Zeitraum gelten, jedoch nicht weniger als ein Jahr.',
'Für weitere Informationen zu Ihren Rechten in einem bestimmten Land scannen Sie den nachstehenden QR-Code oder fragen Sie den Verkäufer. europa.eu/youreurope/garantien',
'Was ist zu tun, wenn Sie vertragswidrige Waren erhalten? 1. Melden Sie dem Verkäufer das Problem so bald wie möglich. 2. Legen Sie einen Kaufnachweis vor, z. B. die Quittung, Rechnung oder einen Kontoauszug.',
'Verkäufer und Hersteller können auch gewerbliche Garantien gewähren, die unabhängig von der gesetzlichen Gewährleistung gelten. Diese GARAN-Kennzeichnung zeigt beispielsweise, dass der Hersteller eine gewerbliche Haltbarkeitsgarantie ohne zusätzliche Kosten gewährt, die die gesamte Ware abdeckt.',
].join('\n\n')
}
/** Laenge eines Textes im HTML der Mail (wie mailTemplates.renderMail: & < > \" ' maskiert, jeder Umbruch wird <br>). */
export function maskierteLaenge(wert) {
let n = 0
for (const z of String(wert)) {
if (z === '&') n += 5
else if (z === '<' || z === '>') n += 4
else if (z === '"') n += 6
else if (z === "'") n += 5
else if (z === '\n') n += 4
else if (z !== '\r') n += 1
}
return n
}
// --- Normalisierung --------------------------------------------------------------------------------------------------------
// Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus
const STEUER = /[\p{Cc}\p{Cf}\u2028\u2029]/gu
function bereinigeText(s) {
return s
.replace(/\r\n?/g, '\n')
.replace(/\t/g, ' ')
.replace(STEUER, (z) => (z === '\n' ? z : ''))
.replace(/[ ]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function pruefeUrl(wert) {
if (wert === undefined || wert === null) return { wert: '' }
if (typeof wert !== 'string') return { fehler: 'Bitte gib die Adresse als Text an.' }
const s = wert.trim()
if (s === '') return { wert: '' }
if (s.length > MAX_URL) return { fehler: `Die Adresse darf höchstens ${MAX_URL} Zeichen lang sein.` }
if (/[\s\p{Cc}\p{Cf}]/u.test(s)) return { fehler: 'Die Adresse darf keine Leerzeichen oder Sonderzeichen enthalten.' }
let u
try {
u = new URL(s)
} catch {
return { fehler: 'Bitte gib eine vollständige Adresse an, die mit https:// beginnt.' }
}
if (u.protocol !== 'https:' || !u.hostname || !/^https:\/\//i.test(s)) return { fehler: 'Die Adresse muss mit https:// beginnen.' }
if (u.username || u.password) return { fehler: 'Die Adresse darf keine Zugangsdaten enthalten.' }
return { wert: s }
}
/**
* Pruefen und Normalisieren der Haendler-Rechtstexte. Texte duerfen im Entwurf leer sein; URLs muessen https sein oder leer.
* @returns {{rechtstexte: {agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl}} | {fehler: Object<string,string>}}
*/
export function normalisiereRechtstexte(eingabe) {
if (!eingabe || typeof eingabe !== 'object' || Array.isArray(eingabe)) return { fehler: { rechtstexte: 'Die Rechtstexte fehlen oder sind unlesbar.' } }
const fehler = {}
const out = {}
const texte = [
['agb', 'AGB', MAX_AGB],
['widerrufsbelehrung', 'Widerrufsbelehrung', MAX_WIDERRUFSBELEHRUNG],
['widerrufsformular', 'Widerrufsformular', MAX_WIDERRUFSFORMULAR],
]
for (const [key, name, max] of texte) {
const w = Object.hasOwn(eingabe, key) ? eingabe[key] : undefined
if (w === undefined || w === null) {
out[key] = ''
continue
}
if (typeof w !== 'string') {
fehler[key] = `Bitte gib die ${name} als Text an.`
out[key] = ''
continue
}
const s = bereinigeText(w)
if ([...s].length > max) {
fehler[key] = `Die ${name} darf höchstens ${max} Zeichen lang sein.`
out[key] = ''
} else out[key] = s
}
for (const [key, name] of [['impressumUrl', 'Impressum'], ['datenschutzUrl', 'Datenschutzerklärung']]) {
const r = pruefeUrl(Object.hasOwn(eingabe, key) ? eingabe[key] : undefined)
if (r.fehler) {
fehler[key] = `${name}: ${r.fehler}`
out[key] = ''
} else out[key] = r.wert
}
// Passt die Bestaetigungs-E-Mail mit allen Texten noch in die Warteschlange? (& < > " ' werden mehrfach lang, jeder Umbruch wird <br>)
if (Object.keys(fehler).length === 0 && ['agb', 'widerrufsbelehrung', 'widerrufsformular'].reduce((summe, k) => summe + maskierteLaenge(out[k]), 0) > MAX_MAIL_HTML_TEXTE) {
fehler.rechtstexte = 'AGB, Widerrufsbelehrung und Widerrufsformular sind zusammen zu lang für die Bestätigungs-E-Mail an deine Kunden (viele Sonderzeichen und Zeilenumbrüche zählen mehrfach). Bitte kürze die Texte.'
}
if (Object.keys(fehler).length) return { fehler }
return {
rechtstexte: {
agb: out.agb,
widerrufsbelehrung: out.widerrufsbelehrung,
widerrufsformular: out.widerrufsformular,
impressumUrl: out.impressumUrl,
datenschutzUrl: out.datenschutzUrl,
},
}
}
/** Alle drei Texte gefuellt und beide Adressen gesetzt: Voraussetzung fuer die Bestaetigung. */
export function vollstaendig(rechtstexte) {
const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {}
return ['agb', 'widerrufsbelehrung', 'widerrufsformular', 'impressumUrl', 'datenschutzUrl'].every((k) => typeof r[k] === 'string' && r[k].trim() !== '')
}
const PLATZHALTER_RE = /\[[^\]\n]{2,}\]/g
const TEXT_IN = { agb: 'den AGB', widerrufsbelehrung: 'der Widerrufsbelehrung', widerrufsformular: 'dem Muster-Widerrufsformular' }
/**
* Kann der Haendler diese Texte bestaetigen? Liefert die Probleme in Du-Form (leere Liste = ja): Platzhalter aus dem Muster
* (`[Name]`, `[Anschrift]`, `[E-Mail-Adresse]`, `[Internetadresse der Widerrufsfunktion]` oder jede andere Angabe in eckigen Klammern),
* die Kopfzeile "Muster – bitte prüfen und anpassen" und eine Widerrufsbelehrung ohne die Adresse der Widerrufsfunktion des Shops
* (Art. 246a § 1 Abs. 2 S. 1 Nr. 1 EGBGB: ohne ordnungsgemaesse Belehrung beginnt die Widerrufsfrist nicht).
* @param {object} rechtstexte Entwurf { agb, widerrufsbelehrung, widerrufsformular, ... }
* @param {{ widerrufUrl?: string }} [opts] Adresse der Widerrufsfunktion des Shops
* @returns {string[]}
*/
export function pruefeBestaetigbar(rechtstexte, { widerrufUrl = '' } = {}) {
const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {}
const probleme = []
for (const [key, wo] of Object.entries(TEXT_IN)) {
const t = typeof r[key] === 'string' ? r[key] : ''
const marken = [...new Set(t.match(PLATZHALTER_RE) ?? [])]
if (marken.length) {
const zeigen = marken.slice(0, 5).map((m) => `„${m}“`).join(', ')
probleme.push(`In ${wo} stehen noch Platzhalter in eckigen Klammern: ${zeigen}${marken.length > 5 ? ' und weitere' : ''}. Ersetze sie durch deine Angaben. Fehlende Angaben ergänzt du in den Einstellungen, danach setzt du das Muster neu ein.`)
}
if (t.includes(MUSTER_KOPFZEILE)) {
probleme.push(`In ${wo} steht noch die Zeile „${MUSTER_KOPFZEILE}“. Prüfe den Text, passe ihn an und lösche die Zeile.`)
}
}
const url = text(widerrufUrl)
// Eine leere Belehrung meldet vollstaendig(); hier nur eine vorhandene ohne die Adresse
if (url && text(r.widerrufsbelehrung) !== '' && !r.widerrufsbelehrung.includes(url)) {
probleme.push(`Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten: ${url}. Setze das Muster neu ein oder ergänze den Satz „Sie können Ihr Widerrufsrecht auch online unter ${url} ausüben.“`)
}
return probleme
}
/** sha256 (hex) ueber kanonisches JSON der fuenf Felder plus erzeugtem Versand-/Zahlungstext und Gewaehrleistungshinweis. */
export function rechtstexteHash(rechtstexte, einstellungen) {
const r = rechtstexte && typeof rechtstexte === 'object' ? rechtstexte : {}
const s = (k) => (typeof r[k] === 'string' ? r[k] : '')
const kanonisch = JSON.stringify([
['agb', s('agb')],
['widerrufsbelehrung', s('widerrufsbelehrung')],
['widerrufsformular', s('widerrufsformular')],
['impressumUrl', s('impressumUrl')],
['datenschutzUrl', s('datenschutzUrl')],
['versandUndZahlung', versandUndZahlung(einstellungen)],
['gewaehrleistungsHinweis', gewaehrleistungsHinweis()],
])
return createHash('sha256').update(kanonisch).digest('hex')
}
+39
View File
@@ -0,0 +1,39 @@
import { config } from '../../config.js'
/**
* Webshop-Schalter (Funktionsschluessel 'shop'): SHOP_ENABLED = off (Vorgabe) | pilot | on.
* Liest config.shop bei jedem Aufruf. Bei off laufen keine Shop-Jobs und keine Shop-Abfragen.
*/
/** 'off' | 'pilot' | 'on'; alles andere (leer, unbekannt) gilt als off. */
export function shopModus() {
const mode = config.shop.mode
return mode === 'pilot' || mode === 'on' ? mode : 'off'
}
/**
* Darf dieser Kunde den Webshop nutzen? pilot = nur Adressen aus SHOP_PILOT_EMAILS ('*' = alle; eine leere
* Liste laesst niemanden durch); on = alle Kunden; off = niemand.
*/
export function shopFuerKunde(email) {
const modus = shopModus()
if (modus === 'off') return false
if (modus === 'on') return true
const erlaubt = config.shop.pilotEmails
if (erlaubt.includes('*')) return true
const adresse = String(email || '').trim().toLowerCase()
return adresse !== '' && erlaubt.includes(adresse)
}
/**
* Verlangt die Aktivierung durch das Team (WOMS, ohne Bestellung) die Zustimmung des Kunden in Textform (AVV Webshop,
* Vorbemerkung Abs. 2)? Wie beim Terminplaner (termineTextformNoetig): nur im Modus on und nur, wenn die Adresse des Kunden nicht
* in SHOP_PILOT_EMAILS steht. '*' ist keine Pilotadresse und befreit niemanden. In off und pilot sprechen Mitarbeiter und Kunde
* direkt miteinander (Pilotbetrieb).
*/
export function shopTextformNoetig(email) {
if (shopModus() !== 'on') return false
const adresse = String(email || '').trim().toLowerCase()
if (adresse === '' || adresse === '*') return true
return !config.shop.pilotEmails.includes(adresse)
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Webshop: Appwrite-Schema der fuenf Collections (Spezifikation 2026-10-05-webshop-design.md).
* Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(),
* ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in
* config.collections.<Name>; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody
* haelt Bodies mit data fuer fertig verpackt), daher heissen die JSON-Felder einstellungen, rechtstexte, produkt, bestellung.
*
* Attribut: { key, type: 'string' | 'integer' | 'boolean' | 'datetime', size (nur Text), required: false }
* Index: { key, type: 'key', attributes: [...] }
* Datumswerte sind UTC (toISOString()); Appwrite liefert sie als "+00:00" zurueck, daher immer mit new Date() lesen.
*/
const str = (key, size) => ({ key, type: 'string', size, required: false })
const int = (key) => ({ key, type: 'integer', required: false })
const bool = (key) => ({ key, type: 'boolean', required: false })
const dt = (key) => ({ key, type: 'datetime', required: false })
const index = (key, ...attributes) => ({ key, type: 'key', attributes })
export const SHOP_COLLECTIONS = {
// Ein Shop je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. Anfrage, Stripe-Konto, Einstellungen und Rechtstexte
// als JSON-Text; die grossen Spalten werden in Appwrite Textspalten und zaehlen nicht zur Zeilenbreite.
shopConfig: {
attributes: [
str('customerId', 36),
str('projectId', 36),
str('siteKey', 16),
str('anfrage', 4000),
str('anfrageStatus', 16),
dt('anfrageAt'),
dt('anfrageEntschiedenAt'),
str('anfrageEntschiedenVon', 255),
str('anfrageBegruendung', 1000),
str('stripeAccountId', 64),
str('stripeStatus', 4000),
str('einstellungen', 20000),
str('rechtstexte', 100000),
dt('rechtstexteBestaetigtAt'),
str('rechtstexteBestaetigtVon', 255),
str('rechtstexteHash', 64),
bool('geoeffnet'),
dt('geoeffnetAt'),
int('version'),
],
indexes: [
index('idx_customer', 'customerId'),
index('idx_sitekey', 'siteKey'),
index('idx_anfrage', 'anfrageStatus'),
index('idx_account', 'stripeAccountId'),
],
},
// Ein Artikel je Dokument (ID randomUUID); Name und Sortierung stehen zusaetzlich als Spalten, der Rest im JSON-Text produkt
shopProducts: {
attributes: [str('shopId', 36), str('customerId', 36), bool('aktiv'), int('sort'), str('name', 120), str('produkt', 30000)],
indexes: [index('idx_shop', 'shopId', 'sort')],
},
// Eine Bestellung je Dokument; enthaelt personenbezogene Daten des Kaeufers (Aufbewahrung und Anonymisierung: Block J)
shopOrders: {
attributes: [
str('shopId', 36),
str('customerId', 36),
str('bestellnummer', 16),
str('status', 16),
str('tokenHash', 64),
str('stripeSessionId', 128),
str('paymentIntentId', 64),
int('summeCent'),
dt('erstelltAt'),
dt('ablaufAt'),
dt('bezahltAt'),
str('kaeuferEmail', 320),
str('bestellung', 60000),
dt('anonymisiertAt'),
],
indexes: [
index('idx_shop_status', 'shopId', 'status'),
index('idx_nummer', 'shopId', 'bestellnummer'),
index('idx_session', 'stripeSessionId'),
index('idx_erstellt', 'shopId', 'erstelltAt'),
],
},
// Widerruf von Vertraegen (§ 356a BGB) ueber die oeffentliche Widerrufsseite
shopWiderrufe: {
attributes: [
str('shopId', 36),
str('customerId', 36),
str('bestellnummer', 32),
str('name', 200),
str('email', 320),
str('nachricht', 2000),
dt('eingangAt'),
dt('bestaetigungAt'),
dt('bearbeitetAt'),
str('orderId', 36),
],
indexes: [index('idx_shop', 'shopId', 'eingangAt')],
},
// Archiv der bestaetigten Rechtstexte (eine Fassung je Dokument, ID 'r' + 35 Hex-Zeichen aus sha256(shopId + hash)): G09 zeigt dem
// Kaeufer die Fassung zum Zeitpunkt seiner Bestellung, und der Haendler kann belegen, welche Belehrung galt. Keine personenbezogenen
// Daten ausser der Person, die bestaetigt hat; wird mit dem Shop geloescht (loeschen.js).
shopRechtstexteArchiv: {
attributes: [str('shopId', 36), str('customerId', 36), str('hash', 64), str('texte', 100000), dt('bestaetigtAt'), str('bestaetigtVon', 255)],
indexes: [index('idx_shop_hash', 'shopId', 'hash')],
},
}
/** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */
export const SHOP_DATETIMES = Object.fromEntries(
Object.entries(SHOP_COLLECTIONS).map(([name, { attributes }]) => [
name,
attributes.filter((attribute) => attribute.type === 'datetime').map((attribute) => attribute.key),
]),
)
+69
View File
@@ -0,0 +1,69 @@
import { config } from '../../config.js'
import { getDocument } from '../appwriteAdmin.js'
import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
/**
* Details des Einrichtungs-Tickets fuer den Webshop (config.features.shop.ticket.details, Spezifikation Abschnitt 6).
* ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument, project = websiteProjects-
* Dokument, customer = Kunde oder null). Der Text ist deterministisch: Kopfzeilen, Kurzfassung der Anfrage (best effort aus
* shopConfig, Dokument-ID = ID des portalFeatures-Dokuments; Lesefehler oder unlesbares JSON => ohne), Snippet und Schritte.
* Die Funktion wirft nie (bezahlt ist bezahlt, das Ticket entsteht immer) und enthaelt keine Daten von Kaeufern.
*/
const WARENART = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' }
const ARTIKELZAHL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Kurzfassung der Anfrage als Zeile, oder '' (Lesefehler, fehlendes Dokument, leere Anfrage). */
async function anfrageZeile(doc) {
try {
const shop = await getDocument(config.collections.shopConfig, doc.$id)
const anfrage = typeof shop?.anfrage === 'string' && shop.anfrage ? JSON.parse(shop.anfrage) : null
if (!istObjekt(anfrage)) return ''
const teile = []
if (Object.hasOwn(WARENART, anfrage.warenart)) teile.push(`Warenart: ${WARENART[anfrage.warenart]}`)
const lieferung = [anfrage.versand === true ? 'Versand' : '', anfrage.abholung === true ? 'Abholung' : ''].filter(Boolean)
if (lieferung.length) teile.push(`Lieferung: ${lieferung.join(' und ')}`)
if (Object.hasOwn(ARTIKELZAHL, anfrage.artikelzahl)) teile.push(`Artikelzahl: ${ARTIKELZAHL[anfrage.artikelzahl]}`)
return teile.length ? `Anfrage: ${teile.join(', ')}` : ''
} catch {
return ''
}
}
export async function shopTicketDetails(ctx) {
try {
const { doc } = ctx
const siteKey = doc?.siteKey || ''
const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: '))
const anfrage = doc?.$id ? await anfrageZeile(doc) : ''
const zeilen = [
...kopf,
...(anfrage ? [anfrage] : []),
'',
'Snippet (an der Stelle der Shop-Seite einsetzen):',
`<div data-webklar-shop="${siteKey}"></div>`,
'<script src="https://project.webklar.com/embed/shop.js" defer></script>',
'',
'Schritte:',
'1. Shop-Seite und Menüpunkt anlegen, Snippet einsetzen.',
`2. Auf allen Seiten im Footer den Link "Vertrag widerrufen" setzen: https://project.webklar.com/shop/${siteKey}/widerruf`,
'3. Datenschutz-Absatz Webshop einfügen (docs/funktionen/shop-datenschutz-absatz.md).',
'4. Impressum prüfen.',
`5. Vorschau prüfen: https://project.webklar.com/shop/${siteKey}`,
'6. Ticket schließen: das schaltet die Funktion frei.',
'',
'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.',
`Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`,
]
return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true })
} catch (err) {
console.error(`[shop-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`)
try {
return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).toWellFormed(), TICKET_DETAILS_MAX)
} catch {
return 'Funktion: Webshop\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).'
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
/**
* Formular-Token des Webshops (Plan 2026-10-05-webshop.md, Block F): "<Ausstellzeit base36>.<HMAC>" je siteKey, wie das
* Formular-Token des Terminplaners, aber mit eigenem Schluessel (Beschriftung 'shop-form', abgeleitet aus config.sessionSecret):
* ein Terminplaner-Token gilt im Shop nicht und umgekehrt. Das Token beweist nur Alter und Herkunft (kein Geheimnis fuer den
* Besucher). Zu schnell abgeschickt (config.shop.formMinSekunden, Standard 3) = Bot, aelter als 2 Stunden = Seite neu laden.
*/
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
const FORM_TOKEN_MAX_LAENGE = 100
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
const ZEIT_RE = /^[0-9a-z]{1,13}$/
const MIN_STANDARD_SEKUNDEN = 3
/** Mindestalter in Sekunden; wird bei jedem Aufruf gelesen (Tests aendern den Wert). */
export const formMinSekunden = () => {
const wert = config.shop?.formMinSekunden
return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN
}
function schluessel() {
const geheimnis = config.sessionSecret
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
throw new Error('Shop-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
}
return createHmac('sha256', geheimnis).update('shop-form').digest()
}
const zeitMs = (now, was) => {
const ms = now instanceof Date ? now.getTime() : Number(now)
if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`)
return ms
}
function formMac(siteKey, zeit) {
return createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url')
}
/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */
export function formToken(siteKey, now = new Date()) {
if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt')
const zeit = zeitMs(now, 'formToken').toString(36)
return `${zeit}.${formMac(siteKey, zeit)}`
}
/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */
export function pruefeFormToken(siteKey, token, now = new Date()) {
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
const teile = token.split('.')
if (teile.length !== 2) return 'ungueltig'
const [zeit, mac] = teile
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
const erwartet = Buffer.from(formMac(siteKey, zeit))
const gegeben = Buffer.from(mac)
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36)
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
if (alter < formMinSekunden() * 1000) return 'zu_schnell'
return 'ok'
}
+222
View File
@@ -0,0 +1,222 @@
import { config } from '../../config.js'
import { createDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { darfEmpfaenger, darfWiderrufsversuch, zaehle } from '../termine/limits.js'
import { pruefeNow } from '../termine/util.js'
import { datumKurz, uhrzeit } from '../termine/zeit.js'
import { bestellDaten } from './bestellungen.js'
import { anHaendler, anKaeufer, haendlerK26 } from './mails.js'
/**
* Widerrufe von Kaeufern (§ 356a BGB; Spezifikation 2026-10-05-webshop-design.md Abschnitt 5.4; Plan Task E5).
*
* Der Kaeufer erklaert den Widerruf ueber die oeffentliche Widerrufsseite des Shops (Name, E-Mail als Kontakt, Bestellnummer als
* freiwillige Vertragsbezeichnung; ohne Nummer ist die Nachricht mit Bestelldatum und Artikel Pflicht). erfasseWiderruf speichert die Erklaerung (shopWiderrufe), haengt sie an die passende
* Bestellung (gleiche Nummer und gleiche E-Mail) und bestaetigt den Eingang sofort mit G12 (Datum und Uhrzeit, Berliner Zeit, auf
* dem dauerhaften Datentraeger E-Mail). K26 informiert den Haendler. Mailfehler lassen die Anfrage nie scheitern: dann bleibt
* bestaetigungAt leer. Die Bestellnummer muss nicht existieren (der Widerruf ist trotzdem wirksam erklaert).
* Gilt fuer Shops im Zustand active und disabled, auch wenn der Shop gerade geschlossen ist; die Pruefung der Funktion
* (off/unbekannt) liegt in der Route (Task F3).
* Fehler: Error mit status und Kaeufertext (Sie-Form) in message; 429 bei Grenzen, 404 bei fremden Widerrufen.
*/
const STUNDE_MS = 60 * 60 * 1000
const MAX_JE_SHOP_UND_STUNDE = 20
const SEITE = 50
const MAX_JSON = 60000
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const NUMMER_RE = /^[A-Z0-9-]{1,32}$/
/** Eine einzelne Adresse: kein Leerraum, keine Listen-/Namensformen. */
const EMAIL_RE = /^[^\s@,;:<>()[\]"\\]+@[^\s@,;:<>()[\]"\\]+\.[^\s@,;:<>()[\]"\\.]{2,}$/
const ZU_VIELE = 'Zu viele Versuche. Bitte versuchen Sie es später noch einmal.'
const NICHT_GEFUNDEN = 'Der Widerruf wurde nicht gefunden.'
const col = () => config.collections.shopWiderrufe
function fehler(status, text) {
return Object.assign(new Error(text), { status })
}
// eslint-disable-next-line no-control-regex
const STEUER_ALLE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g
// eslint-disable-next-line no-control-regex
const STEUER_OHNE_UMBRUCH = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g
/** Einzeiliger Text: Steuerzeichen raus, Leerraum zusammengefasst. */
const einzeilig = (wert) => (typeof wert === 'string' ? wert.replace(STEUER_ALLE, ' ').replace(/\s+/g, ' ').trim() : '')
/** Mehrzeiliger Text: Steuerzeichen (ausser Zeilenumbruch und Tab) raus. */
const mehrzeilig = (wert) => (typeof wert === 'string' ? wert.replace(/\r\n?/g, '\n').replace(STEUER_OHNE_UMBRUCH, '').trim() : '')
// ---------------------------------------------------------------- Pruefung
/**
* Prueft und bereinigt die Eingabe des Widerrufsformulars.
* @param {{ name?: string, email?: string, bestellnummer?: string, nachricht?: string }} eingabe
* @returns {{ widerruf: { name: string, email: string, bestellnummer: string, nachricht: string } } | { fehler: Record<string, string> }}
*/
export function normalisiereWiderruf(eingabe) {
const roh = eingabe !== null && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
const f = {}
const name = einzeilig(roh.name)
if (name.length < 2) f.name = 'Bitte geben Sie Ihren Namen an.'
else if (name.length > 200) f.name = 'Der Name darf höchstens 200 Zeichen lang sein.'
// Steuerzeichen werden zu Leerzeichen: Leerraum im Inneren macht die Adresse ungueltig (keine zweite Adresse einschmuggeln)
const email = typeof roh.email === 'string' ? roh.email.replace(STEUER_ALLE, ' ').trim().toLowerCase() : ''
if (email === '') f.email = 'Bitte geben Sie Ihre E-Mail-Adresse an.'
else if (email.length > 254 || !EMAIL_RE.test(email)) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.'
const nummer = einzeilig(roh.bestellnummer).toUpperCase()
// Die Bestellnummer ist freiwillig (§ 356a BGB: der Kaeufer hat sie nicht immer zur Hand); ohne sie muss die Erklaerung den Vertrag benennen
if (nummer !== '' && !NUMMER_RE.test(nummer)) f.bestellnummer = 'Die Bestellnummer darf nur aus Buchstaben, Ziffern und Bindestrichen bestehen und höchstens 32 Zeichen lang sein.'
const nachricht = mehrzeilig(roh.nachricht)
if (nachricht.length > 2000) f.nachricht = 'Die Nachricht darf höchstens 2000 Zeichen lang sein.'
else if (nummer === '' && nachricht === '') f.nachricht = 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.'
if (Object.keys(f).length > 0) return { fehler: f }
return { widerruf: { name, email, bestellnummer: nummer, nachricht } }
}
// ---------------------------------------------------------------- Erfassen
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */
export const eingangText = (zeit) => `${datumKurz(zeit)}, ${uhrzeit(zeit)} Uhr`
/** Die Bestellung zu Nummer und E-Mail im selben Shop (oder null). Anonymisierte Bestellungen (leere E-Mail) passen nie. */
async function passendeBestellung(shopId, bestellnummer, email) {
const docs = await listDocuments(config.collections.shopOrders, [
Query.equal('shopId', shopId),
Query.equal('bestellnummer', bestellnummer),
Query.limit(10),
])
return docs.find((d) => typeof d.kaeuferEmail === 'string' && d.kaeuferEmail.trim().toLowerCase() === email) ?? null
}
/** Verlauf der Bestellung um den Widerruf ergaenzen (Bestleistung: Fehler werden verschluckt). */
async function verlaufErgaenzen(orderId, now) {
try {
await withLocks([`shop-order:${orderId}`], async () => {
const frisch = await getDocument(config.collections.shopOrders, orderId)
const daten = bestellDaten(frisch)
const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: now.toISOString(), was: 'widerruf' }]
const json = JSON.stringify({ ...daten, verlauf })
if (json.length > MAX_JSON) return
await updateDocument(config.collections.shopOrders, orderId, { bestellung: json })
})
} catch (err) {
console.warn(`[shop-widerrufe] Verlauf nicht ergänzt: ${err?.name} ${String(err?.status ?? '').slice(0, 10)}`.trim())
}
}
/**
* Nimmt einen Widerruf entgegen.
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js); doc.state active oder disabled
* @param {object} eingabe { name, email, bestellnummer, nachricht }
* @param {{ now?: Date, ip?: string }} [optionen]
* @returns {Promise<{ ok: true, eingang: string, id: string }>}
* @throws {Error} status 429 (Grenzen) oder 400 mit fehler: { feld: Text }
*/
export async function erfasseWiderruf(ctx, eingabe, { now = new Date(), ip } = {}) {
pruefeNow(now, 'erfasseWiderruf')
const shopId = ctx?.shop?.id
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('erfasseWiderruf: ctx.shop.id fehlt')
if (!darfWiderrufsversuch(ip, now) || !zaehle(`shop-widerruf:${shopId}`, MAX_JE_SHOP_UND_STUNDE, STUNDE_MS, now)) {
throw fehler(429, ZU_VIELE)
}
const geprueft = normalisiereWiderruf(eingabe)
if (geprueft.fehler) throw Object.assign(fehler(400, Object.values(geprueft.fehler)[0]), { fehler: geprueft.fehler })
const { name, email, bestellnummer, nachricht } = geprueft.widerruf
const order = bestellnummer === '' ? null : await passendeBestellung(shopId, bestellnummer, email)
const doc = await createDocument(col(), {
shopId,
customerId: String(ctx.shop.customerId ?? '').slice(0, 36),
bestellnummer,
name,
email,
nachricht,
eingangAt: now.toISOString(),
orderId: order?.$id ?? '',
})
if (order) await verlaufErgaenzen(order.$id, now)
// G12: Eingangsbestaetigung auf dauerhaftem Datentraeger, sofort; bestaetigungAt nur, wenn die Warteschlange sie angenommen hat
// Pro Empfaengeradresse hoechstens 3 je Stunde (gemeinsame Kappe der Funktionen): sonst liesse sich G12 als Mail-Relay an fremde
// Adressen missbrauchen. Der Widerruf ist gespeichert und beantwortet, die Bestaetigung entfaellt dann (bestaetigungAt bleibt leer).
try {
const g12 = !darfEmpfaenger(email.trim().toLowerCase(), now) ? null : await anKaeufer('G12', ctx, order, { an: email, bookingId: doc.$id, name, bestellnummer, eingang: now, nachricht }, now)
if (g12?.ok) await updateDocument(col(), doc.$id, { bestaetigungAt: now.toISOString() })
} catch (err) {
console.warn(`[shop-widerrufe] G12 nicht bestätigt: ${err?.name}`)
}
try {
const { values, blocks } = haendlerK26(ctx, { name, email, bestellnummer, nachricht, eingang: now }, now)
await anHaendler('K26', ctx, values, blocks)
} catch (err) {
console.warn(`[shop-widerrufe] K26 nicht gesendet: ${err?.name}`)
}
return { ok: true, eingang: eingangText(now), id: doc.$id }
}
// ---------------------------------------------------------------- Verwalten
const ansicht = (d) => ({
id: d.$id,
bestellnummer: d.bestellnummer ?? '',
name: d.name ?? '',
email: d.email ?? '',
nachricht: d.nachricht ?? '',
eingangAt: d.eingangAt ?? '',
bestaetigungAt: d.bestaetigungAt || null,
bearbeitetAt: d.bearbeitetAt || null,
orderId: d.orderId || null,
})
/**
* Widerrufe des Shops, neueste zuerst, 50 je Seite.
* @param {string} shopId
* @param {{ seite?: number }} [optionen] seite ab 1
* @returns {Promise<{ widerrufe: object[], seite: number, mehr: boolean }>}
*/
export async function listeWiderrufe(shopId, { seite = 1 } = {}) {
const nr = Number.isInteger(seite) && seite >= 1 ? seite : 1
const filter = [Query.equal('shopId', shopId), Query.orderDesc('eingangAt')]
// Der Aufruf kennt keinen Offset: Seite fuer Seite per Cursor bis zur gewuenschten
let cursor = null
let docs = []
for (let i = 1; i <= nr; i += 1) {
docs = await listDocuments(col(), [...filter, ...(cursor ? [Query.cursorAfter(cursor)] : []), Query.limit(SEITE + 1)])
if (i < nr) {
if (docs.length <= SEITE) return { widerrufe: [], seite: nr, mehr: false }
cursor = docs[SEITE - 1].$id
}
}
return { widerrufe: docs.slice(0, SEITE).map(ansicht), seite: nr, mehr: docs.length > SEITE }
}
/**
* Markiert einen Widerruf als bearbeitet (nur im eigenen Shop, sonst 404). Ein zweiter Aufruf aendert den Zeitpunkt nicht.
* @returns {Promise<object>} der Widerruf
*/
export async function markiereBearbeitet(shopId, id, { now = new Date() } = {}) {
pruefeNow(now, 'markiereBearbeitet')
if (typeof id !== 'string' || !ID_RE.test(id)) throw fehler(404, NICHT_GEFUNDEN)
let doc
try {
doc = await getDocument(col(), id)
} catch (err) {
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') throw fehler(404, NICHT_GEFUNDEN)
throw err
}
if (doc.shopId !== shopId) throw fehler(404, NICHT_GEFUNDEN)
if (doc.bearbeitetAt) return ansicht(doc)
return ansicht(await updateDocument(col(), id, { bearbeitetAt: now.toISOString() }))
}
+98 -13
View File
@@ -5,7 +5,7 @@ import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.js'
import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js'
import { aboLage, laeuft } from './aboStatus.js'
import { berlinDateTime } from './berlinTime.js'
import { loeschTag } from './termine/frist.js'
@@ -74,7 +74,8 @@ export async function getFeatureDoc(customerId, projectId, key) {
/**
* Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt).
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt; der Kunde sieht archivierte
* Projekte nicht mehr).
*/
export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) {
let project = null
@@ -84,7 +85,9 @@ export async function assertOwnProject(customerId, projectId, { forNewOrder = fa
if (err.status !== 404) throw err
}
if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt')
if (forNewOrder && project.archived === true) throw httpError(409, 'Dieses Projekt ist archiviert. Dafür kann keine Funktion neu eingeschaltet werden.')
if (forNewOrder && project.archived) {
throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.')
}
return project
}
@@ -174,14 +177,19 @@ export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
}
/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
const projects = await listDocuments(config.collections.websiteProjects, [
/**
* docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden.
* includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort
* eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen.
*/
export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) {
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId))
const projects = (await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id))
const result = []
for (const p of projects) {
const features = {}
@@ -280,7 +288,12 @@ export function hakenTexte(feature) {
.join('\n\n')
}
/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */
/**
* Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion.
* consent: { text, at } - Wortlaut (Kopie fuer das Team) und Zeit des Klicks. IP und Browser der Bestellung stehen NUR im Nachweis
* (featureConsents, Loeschfrist wie dort, Datenschutzerklaerung Abschnitt 7) und werden am Funktionsdokument nie gespeichert: die Felder
* consentIp und consentUserAgent werden leer geschrieben, das leert auch einen Wert aus einer frueheren Bestellung desselben Dokuments.
*/
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
assertKey(key)
const feature = featureConfig(key)
@@ -295,8 +308,8 @@ export async function markOrdered({ customerId, projectId, key, sessionId, conse
orderedAt: new Date().toISOString(),
consentText: String(consent.text || '').slice(0, 1000),
consentAt: consent.at || null,
consentIp: String(consent.ip || '').slice(0, 64),
consentUserAgent: String(consent.userAgent || '').slice(0, 300),
consentIp: '',
consentUserAgent: '',
})
}
@@ -512,7 +525,10 @@ function assertReason(reason) {
*/
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
assertReason(reason)
const doc = await withLocks([`feature-disable:${docId}`], async () => {
// Dieselbe Sperre wie die Freischaltung (activateClosedTickets, freischaltenNachAbnahme): beide schreiben den Zustand desselben Dokuments und
// duerfen sich nicht ueberholen. Kein Halter von feature-activate:<id> ruft disable() auf; die Reihenfolge feature-admin -> feature-activate
// besteht schon. Keine Mehrfach-Sperre (withLocks mit mehreren Schluesseln kann sich verklemmen).
const doc = await withLocks([`feature-activate:${docId}`], async () => {
const current = await getDocument(config.collections.portalFeatures, docId)
if (current.state === 'disabled') {
if (current.disabledAt) return current
@@ -527,9 +543,22 @@ export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
})
})
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
if (doc?.featureKey === 'shop' && doc.siteKey) await leereShopCache(doc.siteKey)
return doc
}
// Shop abgeschaltet (auch ueber disableCustomerFeatures): der Kontext-Cache der oeffentlichen Schnittstelle haelt den Zustand bis zu
// 30 Sekunden, solange wuerden noch Bestellungen angenommen. Best effort, der Zustand ist schon gespeichert.
// Dynamischer Import: shop/kontext.js importiert dieses Modul.
async function leereShopCache(siteKey) {
try {
const { leereCache } = await import('./shop/kontext.js')
leereCache(siteKey)
} catch (err) {
console.warn('[siteFeatures] Shop-Cache nicht geleert:', err.message)
}
}
/**
* Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete
* behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem
@@ -585,16 +614,65 @@ async function assertAboLaeuftFuerAktivierung(customerId) {
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
}
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
export const TEXTFORM_KEYS = ['termine', 'shop']
export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : config.termine.avvVersion)
/**
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key).
*/
export function textformNachweisText({ date, channel }, fassung, key = 'termine') {
const [jahr, monat, tag] = String(date).split('-')
if (key === 'shop') {
return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${fassung}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${fassung} abgibt.`
}
return `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${fassung}) am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`
}
// Erklaerungen der Webshop-Bestelltexte, die auch bei der Aktivierung in Textform gelten (ohne Widerrufsverlangen und Pflichtinformationen:
// die gehoeren zur kostenpflichtigen Online-Bestellung)
const SHOP_TEXTFORM_ERKLAERUNGEN = ['unterlagen', 'verantwortung', 'stripe']
/** Texte des Nachweises in Speicherreihenfolge: Termine = ein Satz; Webshop = Satz mit Datum und Weg, danach der Wortlaut der Erklaerungen der Fassung. */
function textformNachweisTexte(textform, fassung, key) {
const satz = { id: 'textform', text: textformNachweisText(textform, fassung, key) }
if (key !== 'shop') return [satz]
const items = featureConfig('shop').consent.items.filter((i) => SHOP_TEXTFORM_ERKLAERUNGEN.includes(i.id))
return [satz, ...items.map(({ id, text }) => ({ id, text }))]
}
/**
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
* News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen.
* textform (nur Terminplaner und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis
* (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung
* gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet).
*/
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) {
assertKey(key)
if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop')
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
if (textform) {
const fassung = textformFassung(key)
await speichereZustimmung({
customerId,
projectId,
featureKey: key,
stripeSessionId: '',
fassungen: { leistungsbeschreibung: fassung, avv: fassung },
texte: textformNachweisTexte(textform, fassung, key),
ip: '',
userAgent: '',
konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`,
at: now,
})
}
return activate(doc.$id, { by, adminNote, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
}
@@ -1188,6 +1266,13 @@ export async function unmatchedNotice(session) {
async function adoptPaidSession(session) {
const { customerId, projectId, featureKey } = session.metadata || {}
const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null
// Erneut zugestelltes Ereignis ("Resend event" in Stripe) zu einer Bestellung, die laengst eingerichtet, abgeschaltet und nach 30 Tagen
// geloescht wurde (off, purchaseStatus 'zurueckgesetzt', dieselbe Sitzung): sie ist ausgeliefert, nichts wird neu eingerichtet und
// niemand benachrichtigt (kein Ticket, keine K15, kein T16).
if (doc?.state === 'off' && doc.purchaseStatus === 'zurueckgesetzt' && doc.stripeSessionId === session.id) {
console.warn(`[funktionen] Sitzung ${session.id} gehoert zu einer Funktion, die nach dem Abo-Ende schon geloescht und zurueckgesetzt wurde - nicht uebernommen, nichts gesendet`)
return false
}
if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) {
console.error(
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`
+3 -1
View File
@@ -58,6 +58,7 @@ const PRODUCT_METADATA = {
mailStorage: 'mail-speicher',
newsSetup: 'news-einrichtung',
termineSetup: 'termine-einrichtung',
shopSetup: 'shop-einrichtung',
zeitenSetup: 'zeiten-einrichtung',
}
@@ -66,6 +67,7 @@ function catalogFor(key) {
if (key === 'mailStorage') return config.mailStorage
if (key === 'newsSetup') return config.newsSetup
if (key === 'termineSetup') return config.termineSetup
if (key === 'shopSetup') return config.shopSetup
if (key === 'zeitenSetup') return config.zeitenSetup
return getProduct(key)
}
@@ -73,7 +75,7 @@ function catalogFor(key) {
const priceIdCache = new Map()
/**
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* 'mailStorage' | 'newsSetup' | 'termineSetup' | 'shopSetup' | 'zeitenSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
*/
export async function ensurePrice(key) {
+9 -3
View File
@@ -81,7 +81,7 @@ const istBewertet = (doc) =>
* Schritt 2b: bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist.
* Das Abo spielt keine Rolle: die Erstattung bleibt geschuldet, auch wenn es zurueckkommt.
*/
const istErstattungOffen = (doc) =>
export const istErstattungOffen = (doc) =>
doc.state === 'disabled' && doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true && doc.zahlungMeldung !== 'gemeldet'
/** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */
@@ -93,7 +93,8 @@ const portalFeatures = () => config.collections.portalFeatures
* Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2, Schritt 1b und der Sofortpfad E9 rufen nur ihn, nie T19 oder
* K22 einzeln). Reihenfolge: (T19,) K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now.
* - Grund 'abo_ende': K22 mit Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen':
* das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach.
* das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag des Abo-Endes (lage.endedAt), sonst von disabledAt;
* loeschdatum = 30 Tage nach disabledAt.
* - Grund 'abo_ende_vor_einrichtung': ein unbezahltes Dokument (unlockedByPurchase nicht true, die Bestellung wartet noch auf die
* Zahlung) hat nichts zu erstatten: weder T19 noch K22 noch Marker, kein Schreibzugriff (kommt die Zahlung spaeter, meldet
* siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team
@@ -136,6 +137,11 @@ export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null })
}
const projekt = (await projectName(doc.projectId)) || 'Dein Projekt'
const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten
// enddatum ist der Tag des Abo-Endes (lage.endedAt: endedAt || canceledAt des Abos), wenn bekannt - das Abschalten kann Tage danach liegen;
// sonst (Sofortpfad, Nachlauf ohne Abo-Lesen, kein lesbares Datum) der Tag des Abschaltens. loeschdatum bleibt 30 Tage nach dem Abschalten.
// Nur bei art 'beendet' ist lage.endedAt das Ende des Wartungs-Abos; bei 'hosting' gehoert es zum Hosting-Abo (etwa eine vorgemerkte Kuendigung).
const aboEnde = lage?.art === 'beendet' ? zeitMs(lage.endedAt) : NaN
const ende = Number.isFinite(aboEnde) ? new Date(aboEnde) : beginn
const grundBlock = vorEinrichtung
? { vor_einrichtung: true }
: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true }
@@ -144,7 +150,7 @@ export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null })
values: {
kunde_name: customer.name || '',
projekt,
enddatum: datumKurz(beginn),
enddatum: datumKurz(ende),
loeschdatum: datumKurz(loeschzeit(beginn)),
preis: config.features.termine.priceLabel,
portal_url: `${config.publicBaseUrl}/dashboard.html#termine`,
+6 -1
View File
@@ -40,18 +40,23 @@ export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) {
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
* Liefert true, wenn K16 rausging; false, wenn nicht (Fehler, Kunde ohne Adresse oder unbekannt). Nur die Staff-Aktivierung mit
* Unterlagen wertet das aus (sie muss die Unterlagen dann von Hand nachsenden); alle anderen Aufrufer ignorieren den Wert wie bisher.
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
* @param {{ attachments?: object[] }} [optionen]
* @returns {Promise<boolean>}
*/
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) return
if (!customer?.email) return false
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
await sendCustomerMail(customer.email, { ...mail, attachments })
return true
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
return false
}
}
+70 -19
View File
@@ -4,7 +4,7 @@ import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, up
import { withLocks } from '../keyedLock.js'
import { stateFields } from '../siteFeatures.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import { gleicheAboAb } from './aboAbgleich.js'
import { gleicheAboAb, istErstattungOffen } from './aboAbgleich.js'
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
import {
abgelaufeneHaltungen,
@@ -23,6 +23,7 @@ import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { shopModus } from '../shop/schalter.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
@@ -57,6 +58,12 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei
* TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen
* bleiben aus.
* - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb
* laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und
* Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles
* andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit
* `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung
* abgeschalteter Funktionen nicht.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
@@ -127,18 +134,21 @@ async function schritt(lauf, was, fn, ersatz) {
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
* `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true
* und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht
* `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf
* das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre
* gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen.
*/
async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) {
const nowMs = pruefeNow(now, job)
let eingeschraenkt = false
let freigabe = null
if (termineAus()) {
if (trotzAus === null) return { aus: true }
try {
if (!(await trotzAus())) return { aus: true }
freigabe = await trotzAus()
if (!freigabe) return { aus: true }
} catch (err) {
console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`)
return { aus: true }
@@ -155,13 +165,16 @@ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}
}
laufend.add(sperre)
try {
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt })
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe })
} finally {
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */
const shopMailsLaufen = async () => shopModus() !== 'off'
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
@@ -285,10 +298,15 @@ export async function minutenJob(now) {
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
{ teil: 'Mailversand' },
{ teil: 'Mailversand', trotzAus: shopMailsLaufen },
),
])
if (verfall.aus || mail.aus) return { aus: true }
if (verfall.aus && mail.aus) return { aus: true }
// Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief
if (verfall.aus) {
if (mail.uebersprungen) return { uebersprungen: true }
return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 }
}
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
@@ -603,7 +621,8 @@ function loescheEinstellung(lauf, funktionId, grenze) {
* Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren
* feature-admin:<projectId> (wie Aktivieren und Abschalten im WOMS) und termine-cfg:<id> frisch lesen und nochmals pruefen
* (leseAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es.
* R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet'), wird NICHT
* R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet', oder C2: bezahlte
* Einrichtung mit Grund abo_ende_vor_einrichtung und T19 nicht gemeldet, auch bei leerer zahlungMeldung), wird NICHT
* zurueckgesetzt (Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf): das Dokument bleibt disabled samt unlockedByPurchase, ticketId
* und zahlungMeldung, der taegliche Abo-Abgleich (aboAbgleich, Nachholen der Erstattungsmeldung T19) bleibt zustaendig und ein
* spaeterer Lauf setzt zurueck, sobald 'gemeldet' steht. Buchungen und Einstellung sind dann schon geloescht (bleibt so).
@@ -619,7 +638,10 @@ function setzeFunktionZurueck(lauf, funktion, grenze) {
// R108: ein offener Zahlungsfall (zahlungMeldung gesetzt, nicht 'gemeldet') bleibt sichtbar. Das Dokument bleibt disabled samt Markern
// (unlockedByPurchase, ticketId, zahlungMeldung), damit der Abo-Abgleich die Meldung (T19) weiter nachholt; ein spaeterer Lauf setzt zurueck,
// sobald 'gemeldet' steht. Ohne PII im Log, zaehlt in lauf.fehler (Tagesbericht), kein Wurf.
if (frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') {
// C2: dazu die bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist, auch
// wenn zahlungMeldung noch leer ist (T19 scheiterte seit dem Abschalten): das Zuruecksetzen wuerde unlockedByPurchase, ticketId und
// zahlungMeldung loeschen, und mit ihnen die Spur der geschuldeten Erstattung. Der Abo-Abgleich (Schritt 2b) meldet T19 weiter.
if ((frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') || istErstattungOffen(frisch)) {
lauf.fehler += 1
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion hat einen offenen Zahlungsfall, sie wird nicht zurückgesetzt (der Abo-Abgleich meldet ihn weiter)`)
return false
@@ -788,6 +810,23 @@ async function gibtBezahlteTerminplaner() {
return treffer.length > 0
}
/**
* Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes
* Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen).
* Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher.
*/
async function tagesjobFreigabe() {
const mails = shopModus() !== 'off'
let termine = false
try {
termine = await gibtBezahlteTerminplaner()
} catch (err) {
if (!mails) throw err
console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`)
}
return termine || mails ? { termine, mails } : false
}
/**
* Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des
* Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs
@@ -860,7 +899,7 @@ export async function taeglicherJob(now) {
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
},
{ trotzAus: gibtBezahlteTerminplaner },
{ trotzAus: tagesjobFreigabe },
)
} catch (err) {
// Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter
@@ -876,19 +915,28 @@ export async function taeglicherJob(now) {
* und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team).
*/
async function eingeschraenkterTagesjob(lauf, now) {
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
// Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile
const termine = lauf.freigabe?.termine !== false
const mails = lauf.freigabe?.mails === true
const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz()
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine
? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
: { funktionen: 0, buchungen: 0, gedeckelt: 0 }
// Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen
const mailErgebnis = mails
? {
festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0),
aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0),
}
: {}
const ergebnis = {
eingeschraenkt: true,
aboAbgleich,
abgeschaltet,
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 },
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 },
geloescht: abgeschaltet.buchungen,
aktiveBetriebe: null,
...mailErgebnis,
fehler: lauf.fehler,
}
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
@@ -922,9 +970,12 @@ export function tagesbericht(ergebnis) {
? ''
: `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, `
if (ergebnis.eingeschraenkt) {
// Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen)
const mitMails = Number.isInteger(ergebnis.aufgeraeumt)
const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : ''
return (
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler`
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler`
)
}
const l = ergebnis.loeschfristen
+1 -1
View File
@@ -214,7 +214,7 @@ export async function kontextFuerPortal(feature) {
return baueKontext(feature)
}
const veraltet = () =>
export const veraltet = () =>
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
status: 409,
code: 'EINSTELLUNG_VERALTET',
+25 -13
View File
@@ -1,5 +1,5 @@
import { config } from '../../config.js'
import { Query, countDocuments, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { Query, countDocuments, createDocument, deleteDocument, getDocument, keinerVon, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendGuestMail } from '../mailer.js'
import { renderMail } from '../mailTemplates.js'
@@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
* pause Anmeldepause laeuft
* budget Plaetze im Fenster >= mailsProStunde
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12, G15) und den normalen Mails vorbehalten
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
*
* Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage.
@@ -71,7 +71,11 @@ const ID_BLOCK = 50
const iso = (wertMs) => new Date(wertMs).toISOString()
/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
/**
* Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Die Shop-Mails
* G09 bis G15 sind bewusst 'normal' (jede Minute bedient): jede Art in VORRANG/NACHRANGIG kostet in der Abfrage der Klasse
* 'normal' ein notEqual mehr, und die URL stoesst live bei nginx an (414, Messung G5 im TP2-Livegang).
*/
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
@@ -115,7 +119,7 @@ function gesamtBremse(nowMs) {
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
* @param {number} nowMs
* @param {string} featureId
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G15')
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
*/
export function pruefePlatz(nowMs, featureId, art) {
@@ -372,16 +376,24 @@ async function speichereFehlschlag(doc, err, nowMs) {
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
const RUNDEN_MAX = 5
/** Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage (Appwrite erlaubt hoechstens 100 Werte je Abfrage). */
const AUSSCHLUSS_MAX = 90
/**
* Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage. Jede ist ein eigenes notEqual in der URL (Appwrite 1.8.1
* nimmt je notEqual nur einen Wert, M1). Gemessen am Produktionsserver (M4, 2026-10-05): in dieser Abfrageform antwortet der nginx
* ab 46 Funktions-IDs mit 36 Zeichen mit 414 (URL zu lang); 30 lassen Luft fuer laengere IDs. Eine blockierte Funktion ausserhalb
* der Liste wird gelesen und uebersprungen (verarbeiteKlasse), das kostet nur Platz in der Liste.
*/
export const AUSSCHLUSS_MAX = 30
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
/** Filter auf die Art der Mail: die Arten der Klasse (equal) bzw. alle anderen (notEqual, die Klasse normal). */
/**
* Filter auf die Art der Mail, immer eine Liste von Abfragen: die Arten der Klasse (equal mit Liste, das erlaubt Appwrite) bzw.
* alle anderen (die Klasse normal: je Art ein notEqual, Appwrite schneidet sie; art ist an jedem Eintrag gesetzt, einreihen prueft es).
*/
function klassenFilter(klasse) {
if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG])
if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG])
return Query.notEqual('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])]
if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])]
return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
}
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
@@ -389,8 +401,8 @@ function listenAbfrage(klasse, nowMs, blockiert) {
return [
Query.equal('status', 'wartet'),
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
klassenFilter(klasse),
...(blockiert.size > 0 ? [Query.notEqual('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX))] : []),
...klassenFilter(klasse),
...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)),
Query.orderAsc('naechsterVersuch'),
Query.limit(config.termine.mailListe),
Query.select(LISTENFELDER),
@@ -438,7 +450,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
}
/**
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21),
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12, G15) vor normalen vor nachrangigen (G05, K20, K21),
* in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege
* (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen
* kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
+1 -1
View File
@@ -386,7 +386,7 @@ export const ZUSTIMMUNG_VORZEITIG = (name) =>
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat und die gebuchte Leistung einen Preis hat (bei kostenlosen Leistungen gibt es ihn nicht). */
export const ZUSTIMMUNG_STORNO = 'Ich habe die Stornobedingungen gelesen.'
/**
+12
View File
@@ -28,3 +28,15 @@ export function termineFuerKunde(email) {
const adresse = String(email || '').trim().toLowerCase()
return adresse !== '' && erlaubt.includes(adresse)
}
/**
* Verlangt die Aktivierung durch das Team (WOMS, ohne Bestellung) die Zustimmung des Kunden in Textform? Nur im Modus on und nur,
* wenn die Adresse des Kunden nicht in TERMINE_PILOT_EMAILS steht. '*' ist keine Pilotadresse und befreit niemanden. In off und
* pilot bleibt die Aktivierung wie in TP1 (Pilotbetrieb, Mitarbeiter und Kunde sprechen direkt miteinander).
*/
export function termineTextformNoetig(email) {
if (termineModus() !== 'on') return false
const adresse = String(email || '').trim().toLowerCase()
if (adresse === '' || adresse === '*') return true
return !config.termine.pilotEmails.includes(adresse)
}