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} */ 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 }