kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
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
|
|
}
|