v2.4: Medienbibliothek+WebP, Varianten-Matrix, Litestream-Backups, intelligentere Analytics

P1 Medien: eigener Admin-Bereich /admin/medien (Grid, Mehrfach-Upload, Drag&Drop, Alt-Text, URL kopieren, Loeschen). Upload konvertiert JPG/PNG via sharp zu WebP (Qualitaet 82, max 2000px), Original wird verworfen; WebP/SVG/GIF/AVIF unveraendert; Konvertierungsfehler -> Original behalten statt 500. media um alt/width/height erweitert. Wiederverwendbarer Medien-Picker (public/media-picker.js) ersetzt den URL-Prompt im Block-Editor, Produkt-Editor (Karte/Galerie/Varianten-Bild), Slides und Popups. JSON-Quelle /api/admin/media (session-gesichert).

P2 Varianten: products.options_json + Tabelle product_variants. Produkt-Editor mit Options-Definition + Matrix-Generator (Preis-Override/Bestand/SKU/Bild/aktiv je Variante). PDP-Selektoren -> Variante; Cart/Checkout tragen sku+Options, Order-Item bekommt sku/variant, Variantenpreis serverseitig verifiziert. Produkte ohne Optionen unveraendert.

P3 Litestream: Binary im Dockerfile, docker-entrypoint.sh (Restore+replicate nur bei LITESTREAM_REPLICA_URL, sonst reiner Node-Start), litestream.yml, Backup-Status unter Einstellungen, README + .env.example.

P4 Analytics: Bestseller, Top-Suchbegriffe, Umsatz/Quelle, Umsatz-Zeitreihe, AOV, Wiederkaufrate, Lager-Warnungen.

Neue Dep sharp. +19 Unit-Tests (49 gesamt gruen), Build + Smoke (P1-P4) gruen.
This commit is contained in:
2026-06-18 08:09:57 +00:00
parent 30c41c355e
commit 50dfca59e1
28 changed files with 1147 additions and 66 deletions
+22 -6
View File
@@ -19,7 +19,9 @@ export function authOk(request) {
// ---- Ressourcen-Definitionen für das Manifest ----
export const RESOURCES = {
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}', 'options[]{name,values[]}'] },
product_variants: { rw: true, fields: ['product_id', 'options{}', 'sku', 'price_cents(override, nullable)', 'stock', 'image', 'sort', 'active(0|1)'] },
media: { rw: false, fields: ['filename', 'url', 'mime', 'size', 'alt', 'width', 'height', 'created_at'] },
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] },
@@ -45,12 +47,14 @@ export function listResource(name) {
case 'settings': return store.getSettings();
case 'reviews': return store.listReviews();
case 'abandoned_carts': return store.listAbandonedCarts();
case 'media': return store.listMedia();
case 'product_variants': return store.listAllVariants();
default: return null;
}
}
export function getResource(name, id) {
switch (name) {
case 'products': return store.getProductById(id);
case 'products': { const pr = store.getProductById(id); return pr ? { ...pr, variants: store.listVariants(pr.id) } : null; }
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
case 'slides': return store.getSlideById(id);
case 'popups': return store.getPopupById(id);
@@ -59,6 +63,8 @@ export function getResource(name, id) {
case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id);
case 'reviews': return store.getReviewById(id);
case 'media': return store.getMediaById(id);
case 'product_variants': return store.listVariants(id);
default: return null;
}
}
@@ -66,9 +72,10 @@ export function getResource(name, id) {
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
export function upsertResource(name, body) {
if (name === 'products') {
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
const id = store.createProduct(body); return store.getProductById(id);
const applyVariants = (pid) => { if (Array.isArray(body.variants)) { try { store.setProductVariants(pid, body.variants); } catch {} } };
if (body.id) { store.updateProduct(body.id, body); applyVariants(body.id); const pr = store.getProductById(body.id); return { id: Number(body.id), ...pr, variants: store.listVariants(body.id) }; }
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); applyVariants(ex.id); return { ...store.getProductById(ex.id), variants: store.listVariants(ex.id) }; } }
const id = store.createProduct(body); applyVariants(id); return { ...store.getProductById(id), variants: store.listVariants(id) };
}
if (name === 'pages') {
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
@@ -99,6 +106,13 @@ export function upsertResource(name, body) {
if (body.approved) store.setReviewApproved(res.id, 1);
return store.getReviewById(res.id);
}
if (name === 'product_variants') {
const pid = body.product_id || body.productId;
if (!pid) throw new Error('product_variants benötigt product_id');
const list = Array.isArray(body.variants) ? body.variants : (Array.isArray(body) ? body : []);
store.setProductVariants(pid, list);
return store.listVariants(pid);
}
if (name === 'shipping_zones') {
if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); }
const id = store.createShippingZone(body); return store.getShippingZoneById(id);
@@ -142,7 +156,7 @@ export function manifest(origin) {
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
return {
name: 'hd-commerce Admin API',
version: '2.3.0',
version: '2.4.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '',
resources: RESOURCES,
@@ -160,6 +174,8 @@ export function manifest(origin) {
'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, feature_reviews, feature_wishlist, feature_abandoned_cart, feature_search, payment_provider (mollie|stripe|demo).',
'reviews: POST ohne id legt eine Bewertung an (approved=0); POST mit { id, approved:true|false } moderiert. rating 15.',
'abandoned_carts ist nur lesbar; Versand-Trigger ist POST /api/cron/abandoned (Bearer CRON_TOKEN).',
'products.options ist [{name, values[]}]. Varianten als product_variants pflegen: POST /api/admin/product_variants { product_id, variants:[{options:{"Größe":"M","Farbe":"Blau"}, sku, price_cents(nullable=Basispreis), stock, image, active}] } ersetzt die Matrix. Alternativ products-POST mit variants:[...].',
'media ist nur lesbar (Liste). Upload über POST /api/upload (multipart, Felder file/files) — JPG/PNG werden zu WebP konvertiert.',
],
};
}
+2 -2
View File
@@ -88,8 +88,8 @@ export function currentUser(request) {
// --- Rollen-Gate ---
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
const ROLE_SECTIONS = {
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'medien', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'medien', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
versand: ['bestellungen'],
};
export function canAccess(role, section) {
+166 -8
View File
@@ -118,8 +118,31 @@ CREATE TABLE IF NOT EXISTS email_log (
);
`);
// v2.4 — Medien: Alt-Text + Maße
ensureColumn('media', 'alt', "alt TEXT DEFAULT ''");
ensureColumn('media', 'width', "width INTEGER");
ensureColumn('media', 'height', "height INTEGER");
// v2.4 — Varianten-Matrix: definierte Optionen am Produkt + Varianten-Tabelle
ensureColumn('products', 'options_json', "options_json TEXT DEFAULT '[]'");
db.exec(`
CREATE TABLE IF NOT EXISTS product_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
sku TEXT DEFAULT '',
options_json TEXT DEFAULT '{}',
price_cents INTEGER,
stock INTEGER,
image TEXT DEFAULT '',
sort INTEGER DEFAULT 0,
active INTEGER DEFAULT 1,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_variants_product ON product_variants(product_id);
`);
// ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) });
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), options: (() => { try { return JSON.parse(r.options_json || '[]'); } catch { return []; } })(), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) });
const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') });
const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') });
@@ -373,19 +396,20 @@ function normProduct(d) {
base_amount: (d.base_amount === '' || d.base_amount == null) ? null : Number(d.base_amount),
base_unit: d.base_unit || '',
base_price_per: d.base_price_per || '',
options_json: JSON.stringify(Array.isArray(d.options) ? d.options : (() => { try { return JSON.parse(d.options_json || '[]'); } catch { return []; } })()),
};
}
export function createProduct(d) {
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`).run(normProduct(d));
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per,options_json)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per,@options_json)`).run(normProduct(d));
return r.lastInsertRowid;
}
export function updateProduct(id, d) {
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per WHERE id=@id`)
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per,options_json=@options_json WHERE id=@id`)
.run({ ...normProduct(d), id: Number(id) });
return id;
}
export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(Number(id));
export const deleteProduct = (id) => { db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(id)); return db.prepare('DELETE FROM products WHERE id=?').run(Number(id)); };
// ---------- orders ----------
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
@@ -523,12 +547,108 @@ export function addSubscriber(email, source = 'web') {
}
export const listSubscribers = () => db.prepare('SELECT * FROM subscribers ORDER BY id DESC').all();
// ---------- backup status (Litestream) ----------
export function backupStatus() {
const url = (process.env.LITESTREAM_REPLICA_URL || '').trim();
const configured = !!url;
const keyId = (process.env.LITESTREAM_ACCESS_KEY_ID || '').trim();
const secret = (process.env.LITESTREAM_SECRET_ACCESS_KEY || '').trim();
const endpoint = (process.env.LITESTREAM_ENDPOINT || '').trim();
let target = '';
if (url) { try { target = url.replace(/^(s3:\/\/[^/]+).*/, '$1'); } catch { target = url; } }
return {
configured,
target: target || url,
fullCredentials: configured && !!keyId && !!secret,
endpoint,
dbPath: process.env.DB_PATH || './data/hdc.db',
};
}
// ---------- media ----------
export function addMedia(d) {
return db.prepare('INSERT INTO media (filename,url,mime,size,created_at) VALUES (?,?,?,?,?)')
.run(d.filename, d.url, d.mime || '', d.size || 0, new Date().toISOString()).lastInsertRowid;
return db.prepare('INSERT INTO media (filename,url,mime,size,alt,width,height,created_at) VALUES (?,?,?,?,?,?,?,?)')
.run(d.filename, d.url, d.mime || '', d.size || 0, d.alt || '', (d.width == null ? null : Number(d.width)), (d.height == null ? null : Number(d.height)), new Date().toISOString()).lastInsertRowid;
}
export const listMedia = () => db.prepare('SELECT * FROM media ORDER BY id DESC').all();
export const getMediaById = (id) => db.prepare('SELECT * FROM media WHERE id=?').get(Number(id));
export const getMediaByFilename = (fn) => db.prepare('SELECT * FROM media WHERE filename=?').get(String(fn || ''));
export function updateMediaAlt(id, alt) {
db.prepare('UPDATE media SET alt=? WHERE id=?').run(String(alt || ''), Number(id));
return getMediaById(id);
}
export function deleteMedia(id) {
const m = getMediaById(id);
db.prepare('DELETE FROM media WHERE id=?').run(Number(id));
return m;
}
// ---------- product variants (v2.4) ----------
const V = (r) => r && ({ ...r, options: (() => { try { return JSON.parse(r.options_json || '{}'); } catch { return {}; } })(), active: !!r.active });
export const listVariants = (productId) => db.prepare('SELECT * FROM product_variants WHERE product_id=? ORDER BY sort, id').all(Number(productId)).map(V);
export const listAllVariants = () => db.prepare('SELECT * FROM product_variants ORDER BY product_id, sort, id').all().map(V);
export const getVariantById = (id) => V(db.prepare('SELECT * FROM product_variants WHERE id=?').get(Number(id)));
export const getVariantBySku = (sku) => V(db.prepare('SELECT * FROM product_variants WHERE sku=? AND sku<>\'\'').get(String(sku || '')));
export function deleteVariantsForProduct(productId) {
db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(productId));
}
function normVariant(productId, v, i) {
return {
product_id: Number(productId),
sku: String(v.sku || '').trim(),
options_json: JSON.stringify(v.options && typeof v.options === 'object' ? v.options : {}),
price_cents: (v.price_cents === '' || v.price_cents == null) ? null : Math.round(Number(v.price_cents)),
stock: (v.stock === '' || v.stock == null) ? null : Math.round(Number(v.stock)),
image: String(v.image || ''),
sort: Number.isFinite(Number(v.sort)) ? Math.round(Number(v.sort)) : (i || 0),
active: (v.active === false || v.active === 0 || v.active === '0') ? 0 : 1,
};
}
// Ersetzt alle Varianten eines Produkts atomar durch die übergebene Liste.
export function setProductVariants(productId, variants) {
const now = new Date().toISOString();
const ins = db.prepare(`INSERT INTO product_variants (product_id,sku,options_json,price_cents,stock,image,sort,active,created_at)
VALUES (@product_id,@sku,@options_json,@price_cents,@stock,@image,@sort,@active,@created_at)`);
const tx = db.transaction((rows) => {
db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(productId));
rows.forEach((v, i) => ins.run({ ...normVariant(productId, v, i), created_at: now }));
});
tx(Array.isArray(variants) ? variants : []);
return listVariants(productId);
}
// Generiert alle Options-Kombinationen aus options=[{name,values:[]}].
export function variantCombinations(options) {
const opts = (Array.isArray(options) ? options : [])
.map(o => ({ name: String(o && o.name || '').trim(), values: (Array.isArray(o && o.values) ? o.values : []).map(x => String(x).trim()).filter(Boolean) }))
.filter(o => o.name && o.values.length);
if (!opts.length) return [];
let combos = [{}];
for (const o of opts) {
const next = [];
for (const c of combos) for (const val of o.values) next.push({ ...c, [o.name]: val });
combos = next;
}
return combos;
}
// Setzt das Produkt-options_json und synchronisiert die Varianten-Matrix.
// Bestehende Varianten (per Options-Signatur erkannt) behalten ihre Werte; neue werden angelegt, verschwundene gelöscht.
export function syncProductVariants(productId, options, existingOverrides = []) {
const combos = variantCombinations(options);
const sig = (o) => JSON.stringify(Object.keys(o).sort().reduce((a, k) => (a[k] = o[k], a), {}));
const prev = {};
for (const v of listVariants(productId)) prev[sig(v.options)] = v;
for (const ov of (existingOverrides || [])) { if (ov && ov.options) prev[sig(ov.options)] = { ...prev[sig(ov.options)], ...ov }; }
const rows = combos.map((opts, i) => {
const ex = prev[sig(opts)] || {};
return {
options: opts, sku: ex.sku || '', price_cents: ex.price_cents ?? null,
stock: ex.stock ?? null, image: ex.image || '', sort: i, active: ex.active === undefined ? true : ex.active,
};
});
setProductVariants(productId, rows);
db.prepare('UPDATE products SET options_json=? WHERE id=?').run(JSON.stringify(Array.isArray(options) ? options : []), Number(productId));
return listVariants(productId);
}
// ---------- events / analytics ----------
export function recordEvent({ type, path = '', referrer = '', utm_source = '', utm_medium = '', utm_campaign = '', session = '', value_cents = 0, meta = {} }) {
@@ -577,8 +697,46 @@ export function analyticsSummary(days = 30) {
series.push({ date: d0.toISOString().slice(0, 10), label: d0.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), views, revenue: rev });
}
// v2.4 — Umsatz je Quelle ist bereits in bySource enthalten.
// Top-Suchbegriffe aus search-Events.
const topSearches = db.prepare(`SELECT lower(trim(json_extract(meta,'$.q'))) term, COUNT(*) hits,
SUM(CASE WHEN json_extract(meta,'$.results')=0 THEN 1 ELSE 0 END) zero
FROM events WHERE type='search' AND created_at>=? AND json_extract(meta,'$.q') IS NOT NULL AND trim(json_extract(meta,'$.q'))<>''
GROUP BY term ORDER BY hits DESC, term LIMIT 12`).all(s);
// Bestseller: Menge & Umsatz aus den Positionen bezahlter/erfüllter Bestellungen.
const paidOrders = db.prepare("SELECT items, total_cents, email FROM orders WHERE status IN ('paid','fulfilled') AND created_at>=?").all(s);
const sellerMap = {};
for (const o of paidOrders) {
let items = []; try { items = JSON.parse(o.items || '[]'); } catch {}
for (const it of items) {
const key = it.slug || it.name || '—';
if (!sellerMap[key]) sellerMap[key] = { key, name: it.name || it.slug || '—', slug: it.slug || '', qty: 0, revenue: 0 };
const qty = Math.max(1, Number(it.qty) || 1);
sellerMap[key].qty += qty;
sellerMap[key].revenue += (Number(it.priceCents) || 0) * qty;
}
}
const bestsellers = Object.values(sellerMap).sort((a, b) => b.qty - a.qty).slice(0, 10);
// Wiederkaufrate: Anteil Kund:innen (per E-Mail) mit >1 bezahlter Bestellung (gesamt, nicht zeitgefenstert).
const buyerRows = db.prepare("SELECT email, COUNT(*) c FROM orders WHERE status IN ('paid','fulfilled') AND email<>'' GROUP BY email").all();
const buyers = buyerRows.length;
const repeatBuyers = buyerRows.filter(r => r.c > 1).length;
const repeatRate = buyers ? (repeatBuyers / buyers) * 100 : 0;
// Lager-Warnungen: knappe/ausverkaufte Produkte (Bestand gesetzt) + knappe Varianten.
const lowStock = db.prepare('SELECT slug, shortName, name, stock FROM products WHERE stock IS NOT NULL AND stock <= 5 ORDER BY stock ASC LIMIT 12').all()
.map(p => ({ slug: p.slug, name: p.shortName || p.name, stock: p.stock, kind: 'product' }));
const lowVariants = db.prepare(`SELECT v.sku sku, v.stock stock, v.options_json opt, p.shortName sn, p.name nm
FROM product_variants v JOIN products p ON p.id=v.product_id
WHERE v.active=1 AND v.stock IS NOT NULL AND v.stock <= 5 ORDER BY v.stock ASC LIMIT 12`).all()
.map(v => { let o={}; try{o=JSON.parse(v.opt||'{}');}catch{} return { name: (v.sn || v.nm) + ' · ' + Object.values(o).join('/'), sku: v.sku, stock: v.stock, kind: 'variant' }; });
const stockWarnings = [...lowStock, ...lowVariants].sort((a, b) => a.stock - b.stock).slice(0, 12);
return {
days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov,
repeatRate, buyers, repeatBuyers,
funnel: [
{ label: 'Aufrufe', value: pageviews },
{ label: 'Produktansichten', value: productViews },
@@ -586,7 +744,7 @@ export function analyticsSummary(days = 30) {
{ label: 'Checkout', value: checkoutStart },
{ label: 'Kauf', value: purchases },
],
bySource, topProducts, series,
bySource, topProducts, series, topSearches, bestsellers, stockWarnings,
};
}