// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP). // Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth). import * as store from './store.js'; import { BLOCK_TYPES } from './blocks.js'; export function json(obj, status = 200) { return new Response(JSON.stringify(obj, null, 2), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } }); } export function authOk(request) { const token = (process.env.HDC_API_TOKEN || '').trim(); if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt const hdr = request.headers.get('authorization') || ''; const m = hdr.match(/^Bearer\s+(.+)$/i); return !!m && m[1].trim() === token; } // ---- 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{}'] }, 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'] }, discounts: { rw: true, fields: ['code', 'title', 'type(percent|fixed|freeshipping)', 'value', 'min_order_cents', 'starts_at', 'expires_at', 'max_uses', 'used_count', 'max_per_customer', 'active', 'secret', 'auto'] }, shipping_zones: { rw: true, fields: ['name', 'countries(CSV ISO, EU)', 'price_cents', 'free_over_cents', 'delivery_days', 'sort', 'active'] }, 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'] }, }; export function listResource(name) { switch (name) { case 'products': return store.listProducts(); case 'pages': return store.listPages(); case 'slides': return store.listSlides(); case 'popups': return store.listPopups(); case 'discounts': return store.listDiscounts(); case 'shipping_zones': return store.listShippingZones(); case 'orders': return store.listOrders(); case 'customers': return store.listCustomers(); case 'settings': return store.getSettings(); default: return null; } } export function getResource(name, id) { switch (name) { case 'products': return store.getProductById(id); 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); case 'discounts': return store.getDiscountById(id); case 'shipping_zones': return store.getShippingZoneById(id); case 'orders': return store.getOrderById(id); case 'customers': return store.getCustomerById(id); default: return null; } } // 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); } if (name === 'pages') { if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); } if (body.slug) { const ex = store.getPageBySlug(body.slug); if (ex) { store.updatePage(ex.id, { ...ex, ...body }); return store.getPageById(ex.id); } } const id = store.createPage(body); return store.getPageById(id); } if (name === 'slides') { if (body.id) { store.updateSlide(body.id, body); return store.getSlideById(body.id); } const id = store.createSlide(body); return store.getSlideById(id); } if (name === 'popups') { if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); } const id = store.createPopup(body); return store.getPopupById(id); } if (name === 'discounts') { if (body.id) { store.updateDiscount(body.id, body); return store.getDiscountById(body.id); } 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 === 'shipping_zones') { if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); } const id = store.createShippingZone(body); return store.getShippingZoneById(id); } if (name === 'settings') { const entries = body && typeof body === 'object' ? Object.entries(body) : []; for (const [k, v] of entries) store.setSetting(k, v); return store.getSettings(); } throw new Error('Ressource nicht schreibbar: ' + name); } export function deleteResource(name, id) { switch (name) { case 'products': store.deleteProduct(id); return true; case 'pages': store.deletePage(id); return true; case 'slides': store.deleteSlide(id); return true; case 'popups': store.deletePopup(id); return true; case 'discounts': store.deleteDiscount(id); return true; case 'shipping_zones': store.deleteShippingZone(id); return true; default: throw new Error('Ressource nicht löschbar: ' + name); } } export function updatePageBlocks(id, blocks) { store.updatePageBlocks(id, blocks); return store.getPageById(id); } export function recordAudit(o) { store.recordAudit(o); } export function blockTypes() { return BLOCK_TYPES.map(b => ({ key: b.key, label: b.label, fields: b.fields.map(f => ({ name: f.name, type: f.type })) })); } export function manifest(origin) { const ep = []; ep.push({ method: 'GET', path: '/api/admin', desc: 'Dieses Manifest' }); for (const [name, def] of Object.entries(RESOURCES)) { ep.push({ method: 'GET', path: `/api/admin/${name}`, desc: `Liste ${name}` }); ep.push({ method: 'GET', path: `/api/admin/${name}/{id}`, desc: `Einzelnes ${name}` }); if (def.rw) { ep.push({ method: 'POST', path: `/api/admin/${name}`, desc: `Upsert ${name} (id oder slug => Update, sonst Create)` }); if (name !== 'settings') ep.push({ method: 'DELETE', path: `/api/admin/${name}/{id}`, desc: `Löschen ${name}` }); } } 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', auth: 'Authorization: Bearer ', base_url: origin || '', resources: RESOURCES, block_types: blockTypes(), endpoints: ep, notes: [ 'Preise in Cent (priceCents/total_cents).', 'orders und customers sind nur lesbar.', 'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.', 'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.', 'Block-Objekte sind FLACH: { type, : ... } — NICHT unter einem data-Schlüssel verschachtelt.', 'discounts.value: bei percent 1–100, 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).', ], }; }