v2.3: Feature-Module live — Suche, Merkliste, Kundenkonten+Adressbuch, Bewertungen, Abandoned-Cart

- feature_search: Storefront-Header-Suche + /suche (SSR, SQLite LIKE, case-insensitiv; Name/Kurz/Desc/Material/Kategorie), Treffer als Karten, Leer-Zustand
- feature_wishlist: Herz-Button auf Karten/PDP (localStorage, public/wishlist.js) + /merkliste
- feature_accounts: getrennte Kunden-Session (Cookie hdc_customer, scrypt), /konto/registrieren|anmelden|abmelden, /konto (Bestellhistorie+Adressbuch), Tabelle customer_addresses, Checkout-Vorbefuellung + orders.customer_id-Zuordnung; Gast-Checkout bleibt
- feature_reviews: Tabelle reviews (1-5, Moderation), /api/review (approved=0), PDP-Anzeige Durchschnitt+Reviews + aggregateRating-JSON-LD, Admin /bewertungen (Freigeben/Verbergen/Loeschen) + Nav-Zaehler
- feature_abandoned_cart: Tabelle abandoned_carts, /api/cart-capture beim Checkout-Start, /api/cron/abandoned (CRON_TOKEN) sendet Erinnerungsmail (Mailer/Log) + reminded=1, recovered=1 bei Bestellung; Status in Einstellungen
- Gating: Flag aus => Storefront-Elemente weg, Routen 302/404, Admin-Nav-Punkt entfaellt; KEIN 'in Vorbereitung' mehr
- API/MCP: reviews CRUD + abandoned_carts (read) in admin-api + ai-admin.txt + MCP-Tools; Manifest v2.3
- README + .env.example (CRON_TOKEN, ABANDONED_AFTER_MINUTES); 16 neue Unit-Tests (Suche/Review-Avg/Kunden/Abandoned)
This commit is contained in:
2026-06-18 07:27:34 +00:00
parent 3fe80e76dd
commit 30c41c355e
32 changed files with 1181 additions and 42 deletions
+21 -2
View File
@@ -28,6 +28,8 @@ export const RESOURCES = {
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'tax_cents', 'shipping_cents', 'country', 'items[]', 'address', 'created_at'] },
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
reviews: { rw: true, fields: ['product_slug', 'name', 'rating(1-5)', 'text', 'approved(0|1)', 'created_at'] },
abandoned_carts: { rw: false, fields: ['email', 'items[]', 'total_cents', 'recovered(0|1)', 'reminded(0|1)', 'created_at'] },
};
export function listResource(name) {
@@ -41,6 +43,8 @@ export function listResource(name) {
case 'orders': return store.listOrders();
case 'customers': return store.listCustomers();
case 'settings': return store.getSettings();
case 'reviews': return store.listReviews();
case 'abandoned_carts': return store.listAbandonedCarts();
default: return null;
}
}
@@ -54,6 +58,7 @@ export function getResource(name, id) {
case 'shipping_zones': return store.getShippingZoneById(id);
case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id);
case 'reviews': return store.getReviewById(id);
default: return null;
}
}
@@ -83,6 +88,17 @@ export function upsertResource(name, body) {
if (body.code) { const ex = store.getDiscountByCode(body.code); if (ex) { store.updateDiscount(ex.id, { ...ex, ...body }); return store.getDiscountById(ex.id); } }
const id = store.createDiscount(body); return store.getDiscountById(id);
}
if (name === 'reviews') {
if (body.id) {
if (body.approved !== undefined) store.setReviewApproved(body.id, body.approved ? 1 : 0);
return store.getReviewById(body.id);
}
const res = store.addReview({ product_slug: body.product_slug, name: body.name, rating: body.rating, text: body.text });
if (!res.ok) throw new Error(res.error || 'Ungültige Bewertung');
// optional direkt freigeben
if (body.approved) store.setReviewApproved(res.id, 1);
return store.getReviewById(res.id);
}
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);
@@ -103,6 +119,7 @@ export function deleteResource(name, id) {
case 'popups': store.deletePopup(id); return true;
case 'discounts': store.deleteDiscount(id); return true;
case 'shipping_zones': store.deleteShippingZone(id); return true;
case 'reviews': store.deleteReview(id); return true;
default: throw new Error('Ressource nicht löschbar: ' + name);
}
}
@@ -125,7 +142,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.2.0',
version: '2.3.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '',
resources: RESOURCES,
@@ -140,7 +157,9 @@ export function manifest(origin) {
'discounts.value: bei percent 1100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.',
'shipping_zones.countries: CSV von ISO-Codes (DE, AT,CH) oder "EU" für alle EU-Länder. free_over_cents nullable.',
'products.mwst: 0, 7 oder 19. base_amount/base_unit/base_price_per ergeben den Grundpreis (PAngV), z. B. 250 + g + kg.',
'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, , payment_provider (mollie|stripe|demo).',
'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).',
],
};
}
+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', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'],
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
versand: ['bestellungen'],
};
export function canAccess(role, section) {
+71
View File
@@ -0,0 +1,71 @@
// hd-commerce — Kunden-Session (getrennt von der Admin-Auth).
// Eigener Cookie-Name (hdc_customer), eigenes signiertes HMAC-Token. NICHT die Admin-Session.
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
import { getSetting, setSetting, getCustomerAccountById } from './store.js';
function resolveSecret() {
const env = (process.env.SESSION_SECRET || '').trim();
// Eigenes, vom Admin getrenntes Geheimnis ableiten, damit Admin- und Kunden-Token
// niemals gegeneinander gültig sind (anderer HMAC-Key).
let base = env && env.length >= 16 ? env : null;
if (!base) {
try {
let sec = getSetting('customer_session_secret');
if (!sec) { sec = randomBytes(32).toString('hex'); setSetting('customer_session_secret', sec); }
base = sec;
} catch { base = randomBytes(32).toString('hex'); }
}
return createHmac('sha256', base).update('hdc-customer-session').digest('hex');
}
const SECRET = resolveSecret();
export const CUSTOMER_COOKIE = 'hdc_customer';
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
export function signCustomer(cid, maxAgeSeconds) {
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 24 * 30);
const payload = b64url(JSON.stringify({ cid: Number(cid), exp }));
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
return payload + '.' + sig;
}
export function verifyCustomerToken(token) {
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
const [payload, sig] = token.split('.');
if (!payload || !sig) return null;
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
try {
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
} catch { return null; }
let data;
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
if (!data || !data.cid || !data.exp) return null;
if (data.exp < Math.floor(Date.now() / 1000)) return null;
return data;
}
const cookieSecure = () => (process.env.PUBLIC_BASE_URL || '').startsWith('https') || process.env.COOKIE_SECURE === '1';
export function buildCustomerCookie(token) {
const parts = [`${CUSTOMER_COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=' + (60 * 60 * 24 * 30)];
if (cookieSecure()) parts.push('Secure');
return parts.join('; ');
}
export function clearCustomerCookie() {
return `${CUSTOMER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax;${cookieSecure() ? ' Secure;' : ''} Max-Age=0`;
}
function parseCookies(request) {
const h = request.headers.get('cookie') || '';
const out = {};
h.split(';').forEach(p => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
return out;
}
// Aktuell eingeloggter Kunde (oder null). Liest NUR den Kunden-Cookie.
export function currentCustomer(request) {
const token = parseCookies(request)[CUSTOMER_COOKIE];
const data = verifyCustomerToken(token);
if (!data) return null;
const c = getCustomerAccountById(data.cid);
return c || null;
}
+42
View File
@@ -118,3 +118,45 @@ export async function sendOrderConfirmation(order) {
const html = orderConfirmationHtml(order);
return sendMail({ to: order.email, subject, html, type: 'order_confirmation' });
}
// Gebrandete Warenkorb-Erinnerung (Abandoned-Cart). cart: { email, items, total_cents }
export function abandonedCartHtml(cart, baseUrl) {
const s = getSettings();
const shopName = s.shop_name || 'hd-commerce';
const accent = s.brand_accent || '#b8566a';
const items = Array.isArray(cart.items) ? cart.items : [];
const shopUrl = (baseUrl ? String(baseUrl).replace(/\/$/, '') : '') + '/warenkorb';
const rows = items.map(i => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #eee">${esc(i.name)}${i.size && i.size !== 'One Size' ? ' <span style="color:#888">(' + esc(i.size) + ')</span>' : ''}</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:center;color:#555">${esc(i.qty || 1)}×</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:right;white-space:nowrap">${esc(formatPrice((Number(i.priceCents) || 0) * (Number(i.qty) || 1)))}</td>
</tr>`).join('');
return `<!doctype html><html lang="de"><body style="margin:0;background:#f6f4f1;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#222">
<div style="max-width:560px;margin:0 auto;padding:24px">
<div style="background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.06)">
<div style="background:${esc(accent)};padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:700">${esc(shopName)}</div>
<div style="opacity:.9;font-size:14px;margin-top:2px">Du hast etwas vergessen</div>
</div>
<div style="padding:28px">
<p style="margin:0 0 14px;font-size:16px">Dein Warenkorb wartet noch auf dich. Diese Artikel hast du dir angesehen:</p>
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${rows}</tbody></table>
<table style="width:100%;border-collapse:collapse;font-size:14px;margin-top:10px">
<tr><td style="padding:10px 0 0;font-weight:700;font-size:16px;border-top:1px solid #eee">Summe</td><td style="padding:10px 0 0;text-align:right;font-weight:700;font-size:16px;border-top:1px solid #eee">${esc(formatPrice(Number(cart.total_cents) || 0))}</td></tr>
</table>
<div style="margin-top:24px"><a href="${esc(shopUrl)}" style="display:inline-block;background:${esc(accent)};color:#fff;text-decoration:none;padding:13px 26px;border-radius:10px;font-weight:600">Warenkorb ansehen</a></div>
<p style="margin:22px 0 0;font-size:13px;color:#888">Falls du die Bestellung schon abgeschlossen hast, kannst du diese E-Mail ignorieren.</p>
</div>
</div>
<div style="text-align:center;color:#aaa;font-size:12px;margin-top:14px">© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce</div>
</div></body></html>`;
}
export async function sendAbandonedCartReminder(cart, baseUrl) {
if (!cart || !cart.email) return { ok: false, status: 'no-recipient' };
const s = getSettings();
const subject = `Dein Warenkorb wartet · ${s.shop_name || 'hd-commerce'}`;
const html = abandonedCartHtml(cart, baseUrl);
return sendMail({ to: cart.email, subject, html, type: 'abandoned_cart' });
}
+191
View File
@@ -850,3 +850,194 @@ export function logEmail({ recipient = '', subject = '', html = '', type = 'gene
}
export const listEmailLog = (limit = 100) => db.prepare('SELECT * FROM email_log ORDER BY id DESC LIMIT ?').all(Number(limit) || 100);
export const getEmailLogById = (id) => db.prepare('SELECT * FROM email_log WHERE id=?').get(Number(id));
// =====================================================================
// v2.3 — Feature-Module: Suche, Kundenkonten/Adressen, Bewertungen, Abandoned-Cart
// =====================================================================
// ---------- neue Tabellen + Spalten ----------
db.exec(`
CREATE TABLE IF NOT EXISTS customer_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL,
name TEXT DEFAULT '', strasse TEXT DEFAULT '', plz TEXT DEFAULT '', ort TEXT DEFAULT '',
land TEXT DEFAULT 'DE', is_default INTEGER DEFAULT 0, created_at TEXT
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT, product_slug TEXT NOT NULL, name TEXT DEFAULT '',
rating INTEGER DEFAULT 5, text TEXT DEFAULT '', approved INTEGER DEFAULT 0, created_at TEXT
);
CREATE TABLE IF NOT EXISTS abandoned_carts (
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, items_json TEXT DEFAULT '[]',
total_cents INTEGER DEFAULT 0, recovered INTEGER DEFAULT 0, reminded INTEGER DEFAULT 0, created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reviews_slug ON reviews(product_slug);
CREATE INDEX IF NOT EXISTS idx_addr_customer ON customer_addresses(customer_id);
`);
// Kundenkonten: Passwort-Hash + Zuordnung Bestellung→Kunde
ensureColumn('customers', 'pass_hash', "pass_hash TEXT");
ensureColumn('customers', 'pass_salt', "pass_salt TEXT");
ensureColumn('orders', 'customer_id', "customer_id INTEGER");
// ---------- Volltextsuche (feature_search) ----------
// LIKE/instr-basiert (case-insensitiv, ohne FTS-Tokenizer-Abhängigkeit), durchsucht
// Name, Kurzname, Beschreibung, Material und Kategorie.
export function searchProducts(query, limit = 60) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
const like = '%' + q.replace(/[%_]/g, m => '\\' + m) + '%';
const rows = db.prepare(`
SELECT * FROM products
WHERE lower(name) LIKE ? ESCAPE '\\'
OR lower(COALESCE(shortName,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(desc,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(material,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(category,'')) LIKE ? ESCAPE '\\'
ORDER BY
CASE WHEN lower(name) LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
featured DESC, sort, id
LIMIT ?`).all(like, like, like, like, like, like, Number(limit) || 60);
return rows.map(P);
}
// ---------- Kundenkonten (feature_accounts) ----------
const CUST = (r) => r && ({ id: r.id, name: r.name, email: r.email, city: r.city, created_at: r.created_at, has_password: !!r.pass_hash });
export const getCustomerByEmail = (email) => db.prepare('SELECT * FROM customers WHERE LOWER(email)=LOWER(?)').get(String(email || '').trim());
export function registerCustomer({ name, email, password }) {
const e = String(email || '').toLowerCase().trim();
if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'Bitte gültige E-Mail angeben.' };
if (!password || String(password).length < 6) return { ok: false, error: 'Passwort muss mindestens 6 Zeichen haben.' };
const existing = getCustomerByEmail(e);
if (existing && existing.pass_hash) return { ok: false, error: 'Für diese E-Mail existiert bereits ein Konto.' };
const { pass_hash, pass_salt } = hashPassword(password);
const now = new Date().toISOString();
if (existing) {
db.prepare('UPDATE customers SET name=COALESCE(NULLIF(?,\'\'),name), pass_hash=?, pass_salt=? WHERE id=?')
.run(name || existing.name || '', pass_hash, pass_salt, existing.id);
return { ok: true, id: existing.id };
}
const r = db.prepare('INSERT INTO customers (name,email,city,created_at,pass_hash,pass_salt) VALUES (?,?,?,?,?,?)')
.run(name || e, e, '', now, pass_hash, pass_salt);
return { ok: true, id: r.lastInsertRowid };
}
export function verifyCustomer(email, password) {
const c = getCustomerByEmail(email);
if (!c || !c.pass_hash) return null;
if (!verifyPassword(password, c.pass_hash, c.pass_salt)) return null;
return CUST(c);
}
export const getCustomerAccountById = (id) => CUST(db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id)));
// Adressbuch
const ADDR = (r) => r && ({ ...r, is_default: !!r.is_default });
export const listCustomerAddresses = (customerId) =>
db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC').all(Number(customerId)).map(ADDR);
export const getCustomerAddress = (id, customerId) =>
ADDR(db.prepare('SELECT * FROM customer_addresses WHERE id=? AND customer_id=?').get(Number(id), Number(customerId)));
export const defaultAddress = (customerId) =>
ADDR(db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC LIMIT 1').get(Number(customerId)));
export function addCustomerAddress(customerId, d) {
const cid = Number(customerId);
const isDefault = d.is_default ? 1 : 0;
if (isDefault) db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid);
const haveAny = db.prepare('SELECT COUNT(*) c FROM customer_addresses WHERE customer_id=?').get(cid).c;
const r = db.prepare(`INSERT INTO customer_addresses (customer_id,name,strasse,plz,ort,land,is_default,created_at)
VALUES (?,?,?,?,?,?,?,?)`).run(cid, d.name || '', d.strasse || '', d.plz || '', d.ort || '',
String(d.land || 'DE').toUpperCase(), (isDefault || haveAny === 0) ? 1 : 0, new Date().toISOString());
return r.lastInsertRowid;
}
export function deleteCustomerAddress(id, customerId) {
db.prepare('DELETE FROM customer_addresses WHERE id=? AND customer_id=?').run(Number(id), Number(customerId));
return true;
}
export function setDefaultAddress(id, customerId) {
const cid = Number(customerId);
db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid);
db.prepare('UPDATE customer_addresses SET is_default=1 WHERE id=? AND customer_id=?').run(Number(id), cid);
return true;
}
export const listOrdersForCustomer = (customerId, email) => {
const cid = Number(customerId);
return db.prepare('SELECT * FROM orders WHERE customer_id=? OR (? <> \'\' AND LOWER(email)=LOWER(?)) ORDER BY datetime(created_at) DESC, id DESC')
.all(cid, String(email || ''), String(email || '')).map(O);
};
export function attachOrderToCustomer(orderId, customerId) {
db.prepare('UPDATE orders SET customer_id=? WHERE id=?').run(Number(customerId), Number(orderId));
return true;
}
// ---------- Bewertungen (feature_reviews) ----------
const RV = (r) => r && ({ ...r, approved: !!r.approved });
export function addReview({ product_slug, name, rating, text }) {
const slug = String(product_slug || '').trim();
if (!slug) return { ok: false, error: 'Produkt fehlt.' };
const raw = Math.round(Number(rating));
if (!Number.isFinite(raw) || raw < 1 || raw > 5) return { ok: false, error: 'Bitte 1 bis 5 Sterne wählen.' };
const r = raw;
const res = db.prepare('INSERT INTO reviews (product_slug,name,rating,text,approved,created_at) VALUES (?,?,?,?,0,?)')
.run(slug, String(name || '').trim().slice(0, 80) || 'Anonym', r, String(text || '').trim().slice(0, 2000), new Date().toISOString());
return { ok: true, id: res.lastInsertRowid };
}
export const listApprovedReviews = (slug) =>
db.prepare('SELECT * FROM reviews WHERE product_slug=? AND approved=1 ORDER BY id DESC').all(String(slug || '')).map(RV);
export function reviewSummary(slug) {
const row = db.prepare('SELECT COUNT(*) c, COALESCE(AVG(rating),0) avg FROM reviews WHERE product_slug=? AND approved=1').get(String(slug || ''));
return { count: row.c, average: row.c ? Math.round(row.avg * 10) / 10 : 0 };
}
export const listAllReviews = (filter = 'all') => {
let where = '';
if (filter === 'pending') where = 'WHERE approved=0';
else if (filter === 'approved') where = 'WHERE approved=1';
return db.prepare(`SELECT * FROM reviews ${where} ORDER BY approved ASC, id DESC`).all().map(RV);
};
export const getReviewById = (id) => RV(db.prepare('SELECT * FROM reviews WHERE id=?').get(Number(id)));
export const countPendingReviews = () => db.prepare('SELECT COUNT(*) c FROM reviews WHERE approved=0').get().c;
export function setReviewApproved(id, approved) {
db.prepare('UPDATE reviews SET approved=? WHERE id=?').run(approved ? 1 : 0, Number(id));
return true;
}
export const deleteReview = (id) => db.prepare('DELETE FROM reviews WHERE id=?').run(Number(id));
// API-Mapper: Upsert/CRUD über admin-api
export const listReviews = () => db.prepare('SELECT * FROM reviews ORDER BY id DESC').all().map(RV);
// ---------- Abandoned Carts (feature_abandoned_cart) ----------
const AC = (r) => r && ({ ...r, items: (() => { try { return JSON.parse(r.items_json || '[]'); } catch { return []; } })(), recovered: !!r.recovered, reminded: !!r.reminded });
export function captureAbandonedCart({ email, items, total_cents }) {
const e = String(email || '').toLowerCase().trim();
if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'E-Mail fehlt.' };
const its = Array.isArray(items) ? items : [];
if (!its.length) return { ok: false, error: 'Warenkorb leer.' };
const total = Math.round(Number(total_cents) || its.reduce((s, i) => s + (Number(i.priceCents) || 0) * (Number(i.qty) || 1), 0));
// Offene (nicht recovered) Karte derselben E-Mail aktualisieren statt duplizieren.
const open = db.prepare("SELECT * FROM abandoned_carts WHERE LOWER(email)=LOWER(?) AND recovered=0 AND reminded=0 ORDER BY id DESC LIMIT 1").get(e);
if (open) {
db.prepare('UPDATE abandoned_carts SET items_json=?, total_cents=?, created_at=? WHERE id=?')
.run(JSON.stringify(its), total, new Date().toISOString(), open.id);
return { ok: true, id: open.id, updated: true };
}
const r = db.prepare('INSERT INTO abandoned_carts (email,items_json,total_cents,recovered,reminded,created_at) VALUES (?,?,?,0,0,?)')
.run(e, JSON.stringify(its), total, new Date().toISOString());
return { ok: true, id: r.lastInsertRowid };
}
export const listAbandonedCarts = (limit = 100) => db.prepare('SELECT * FROM abandoned_carts ORDER BY id DESC LIMIT ?').all(Number(limit) || 100).map(AC);
export function abandonedCartStats() {
const total = db.prepare('SELECT COUNT(*) c FROM abandoned_carts').get().c;
const open = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=0 AND reminded=0').get().c;
const reminded = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE reminded=1').get().c;
const recovered = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=1').get().c;
return { total, open, reminded, recovered };
}
// Karten älter als olderThanMinutes, weder recovered noch reminded.
export function dueAbandonedCarts(olderThanMinutes = 30) {
const cutoff = new Date(Date.now() - Math.max(0, Number(olderThanMinutes) || 0) * 60000).toISOString();
return db.prepare("SELECT * FROM abandoned_carts WHERE recovered=0 AND reminded=0 AND created_at <= ? ORDER BY id ASC").all(cutoff).map(AC);
}
export const markAbandonedReminded = (id) => { db.prepare('UPDATE abandoned_carts SET reminded=1 WHERE id=?').run(Number(id)); return true; };
// Bei erfolgreicher Bestellung: offene Karten dieser E-Mail als recovered markieren.
export function markCartRecoveredByEmail(email) {
const e = String(email || '').toLowerCase().trim();
if (!e) return 0;
const r = db.prepare("UPDATE abandoned_carts SET recovered=1 WHERE LOWER(email)=LOWER(?) AND recovered=0").run(e);
return r.changes || 0;
}