Files
Webklar-Kundenbereich/server/services/shop/einstellungen.js
T
2026-10-05 08:35:37 +00:00

331 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}