Shop C2: Einstellungen normalisieren, laden und speichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e74f1669c
commit
129f8de278
2 files changed
+598
No files matched your search
@@ -0,0 +1,330 @@
|
||||
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(). 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: '' },
|
||||
kleinunternehmer: true,
|
||||
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
|
||||
abholung: { aktiv: false, adresse: '', hinweis: '' },
|
||||
lieferzeit: '2–4 Werktage',
|
||||
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
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user