Shop C3: Produkte normalisieren, Käufer-Sicht und Speichern (shopProducts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
129f8de278
commit
bea9f3cfba
2 files changed
+885
No files matched your search
@@ -0,0 +1,487 @@
|
||||
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. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function speichereProdukt({ shopId, customerId }, id, produkt) {
|
||||
if (!istObjekt(produkt) || typeof produkt.name !== 'string' || produkt.name === '') throw fehlerMit(400, 'Der Artikel ist unvollständig.')
|
||||
const json = JSON.stringify(produkt)
|
||||
if (json.length > MAX_JSON) throw fehlerMit(400, 'Der Artikel ist zu umfangreich. Bitte kürze die Beschreibung oder lege weniger Varianten an.')
|
||||
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).')
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user