api.ts raw

   1  import { Hono } from 'hono';
   2  import { cors } from 'hono/cors';
   3  import { getAllProducts, getProductBySlug, getProductsByCategory, getCategories } from './lib/products';
   4  import {
   5    verifyPassword, findOrCreateNostrUser, createSession, getUserFromSession,
   6    deleteSession, issueNostrChallenge, verifyNostrAuthEvent, createUser,
   7    updateUserPrefs, setUserPassword,
   8  } from './lib/auth';
   9  import { getCities, getOfficesByCourier } from './lib/couriers';
  10  import { createOrder, getOrderForView, orderView, setPaymentId, CheckoutError } from './lib/orders';
  11  import { createPayment } from './lib/payments';
  12  
  13  const api = new Hono();
  14  
  15  api.use('/*', cors({ origin: process.env.SITE_URL || 'http://localhost:3000' }));
  16  
  17  // --- simple in-memory rate limiter for auth endpoints ---
  18  const buckets = new Map<string, { count: number; reset: number }>();
  19  function rateLimit(key: string, max: number, windowMs: number): boolean {
  20    const now = Date.now();
  21    const b = buckets.get(key);
  22    if (!b || b.reset < now) {
  23      buckets.set(key, { count: 1, reset: now + windowMs });
  24      return true;
  25    }
  26    b.count++;
  27    return b.count <= max;
  28  }
  29  function clientKey(c: any): string {
  30    return c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || 'local';
  31  }
  32  
  33  // --- catalog ---
  34  function stripeConfigKey(key: string): string {
  35    const envMap: Record<string, string> = {
  36      stripe_publishable_key: 'STRIPE_PUBLISHABLE_KEY',
  37      stripe_secret_key: 'STRIPE_SECRET_KEY',
  38      stripe_webhook_secret: 'STRIPE_WEBHOOK_SECRET',
  39      btcpay_url: 'BTCPAY_URL',
  40      btcpay_api_key: 'BTCPAY_API_KEY',
  41      btcpay_store_id: 'BTCPAY_STORE_ID',
  42    };
  43    const envVar = envMap[key];
  44    const env = envVar ? process.env[envVar] : undefined;
  45    if (env) return env;
  46    const db = (() => { try { return require('./lib/db').getDb(); } catch { return null; } })();
  47    if (!db) return '';
  48    const row = db.query('SELECT value FROM content WHERE key = ?').get(key) as { value: string } | null;
  49    return row?.value || '';
  50  }
  51  
  52  api.get('/stripe-key', (c) => c.json({ publishableKey: stripeConfigKey('stripe_publishable_key') || 'pk_mock' }));
  53  
  54  api.post('/stripe/payment-intent', async (c) => {
  55    try {
  56      const { items } = await c.req.json();
  57      if (!Array.isArray(items) || items.length === 0) return c.json({ error: 'items required' }, 400);
  58      let amount = 0;
  59      let weightKg = 0;
  60      for (const it of items) {
  61        const product = getProductBySlug(String(it?.slug ?? ''));
  62        const qty = Number(it?.qty);
  63        if (!product || !Number.isInteger(qty) || qty < 1) continue;
  64        amount += product.price_cents * qty;
  65        weightKg += (product.unit_size * qty) / 1000;
  66      }
  67      const { shippingCostCentsSync } = await import('./lib/shipping');
  68      const shipping = shippingCostCentsSync(weightKg, amount);
  69      const total = amount + shipping;
  70  
  71      const Stripe = (await import('stripe')).default;
  72      const key = stripeConfigKey('stripe_secret_key');
  73      if (!key) {
  74        return c.json({ clientSecret: null, amount: total });
  75      }
  76      const stripe = new Stripe(key);
  77      const intent = await stripe.paymentIntents.create({
  78        amount: total,
  79        currency: 'eur',
  80        automatic_payment_methods: { enabled: true },
  81      });
  82      return c.json({ clientSecret: intent.client_secret, amount: total });
  83    } catch (e: any) {
  84      console.error('payment-intent error:', e);
  85      return c.json({ error: 'failed to create intent' }, 400);
  86    }
  87  });
  88  
  89  api.get('/categories', (c) => c.json(getCategories()));
  90  
  91  api.get('/products', (c) => {
  92    const cat = c.req.query('cat');
  93    return c.json(cat ? getProductsByCategory(cat) : getAllProducts());
  94  });
  95  
  96  api.get('/products/:slug', (c) => {
  97    const product = getProductBySlug(c.req.param('slug'));
  98    if (!product) return c.json({ error: 'not found' }, 404);
  99    return c.json(product);
 100  });
 101  
 102  // --- auth ---
 103  api.post('/auth/login', async (c) => {
 104    if (!rateLimit('login:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429);
 105    try {
 106      const { email, password } = await c.req.json();
 107      if (!email || !password) return c.json({ error: 'email and password required' }, 400);
 108      const userId = verifyPassword(email, password);
 109      if (!userId) return c.json({ error: 'invalid email or password' }, 401);
 110      const token = createSession(userId);
 111      return c.json({ token, user: { id: userId, email } });
 112    } catch {
 113      return c.json({ error: 'invalid request body' }, 400);
 114    }
 115  });
 116  
 117  api.post('/auth/register', async (c) => {
 118    if (!rateLimit('register:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many attempts' }, 429);
 119    try {
 120      const { email, password, name, phone, delivery_prefs } = await c.req.json();
 121      if (!email || !password) return c.json({ error: 'email and password required' }, 400);
 122      if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
 123        return c.json({ error: 'valid email required' }, 400);
 124      }
 125      if (typeof password !== 'string' || password.length < 6) {
 126        return c.json({ error: 'password must be at least 6 characters' }, 400);
 127      }
 128      const { getDb } = await import('./lib/db');
 129      const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email);
 130      if (existing) return c.json({ error: 'email already registered' }, 409);
 131      const userId = createUser(email, password);
 132      if (name || phone || delivery_prefs) {
 133        updateUserPrefs(userId, { name, phone, delivery_prefs: delivery_prefs ? JSON.stringify(delivery_prefs) : undefined });
 134      }
 135      const token = createSession(userId);
 136      return c.json({ token, user: { id: userId, email } });
 137    } catch {
 138      return c.json({ error: 'invalid request body' }, 400);
 139    }
 140  });
 141  
 142  api.get('/auth/nostr-challenge', (c) => {
 143    if (!rateLimit('challenge:' + clientKey(c), 30, 60_000)) return c.json({ error: 'too many attempts' }, 429);
 144    return c.json({ challenge: issueNostrChallenge() });
 145  });
 146  
 147  api.post('/auth/nostr', async (c) => {
 148    if (!rateLimit('nostr:' + clientKey(c), 10, 60_000)) return c.json({ error: 'too many attempts' }, 429);
 149    try {
 150      const { event } = await c.req.json();
 151      const pubkey = verifyNostrAuthEvent(event);
 152      if (!pubkey) return c.json({ error: 'invalid auth event' }, 401);
 153      const { userId, created } = findOrCreateNostrUser(pubkey);
 154      const token = createSession(userId);
 155      return c.json({ token, user: { id: userId, pubkey }, new_user: created });
 156    } catch {
 157      return c.json({ error: 'invalid request body' }, 400);
 158    }
 159  });
 160  
 161  api.get('/auth/session', (c) => {
 162    const auth = c.req.header('Authorization');
 163    if (!auth?.startsWith('Bearer ')) return c.json({ user: null });
 164    const user = getUserFromSession(auth.slice(7));
 165    return c.json({ user: user ? { id: user.id, email: user.email, nostr_pubkey: user.nostr_pubkey, is_admin: user.is_admin, delivery_prefs: user.delivery_prefs, name: user.name, phone: user.phone, has_password: !!user.password_hash } : null });
 166  });
 167  
 168  // --- user prefs ---
 169  function authUser(c: any): any | null {
 170    const auth = c.req.header('Authorization');
 171    if (!auth?.startsWith('Bearer ')) return null;
 172    return getUserFromSession(auth.slice(7));
 173  }
 174  
 175  api.put('/user/prefs', async (c) => {
 176    const user = authUser(c);
 177    if (!user) return c.json({ error: 'unauthorized' }, 401);
 178    let body: any;
 179    try {
 180      body = await c.req.json();
 181      const updated = updateUserPrefs(user.id, {
 182        name: body.name,
 183        phone: body.phone,
 184        email: body.email,
 185        delivery_prefs: body.delivery_prefs ? JSON.stringify(body.delivery_prefs) : undefined,
 186      });
 187      return c.json({ user: updated ? { id: updated.id, email: updated.email, nostr_pubkey: updated.nostr_pubkey, is_admin: updated.is_admin, delivery_prefs: updated.delivery_prefs, name: updated.name, phone: updated.phone } : null });
 188    } catch (e: any) {
 189      const msg = e?.message || String(e);
 190      console.error('user/prefs error:', msg, 'userId:', user?.id, 'bodyKeys:', Object.keys(body || {}));
 191      if (msg.includes('UNIQUE')) return c.json({ error: 'email address in use' }, 409);
 192      return c.json({ error: msg || 'invalid request body' }, 400);
 193    }
 194  });
 195  
 196  api.put('/user/password', async (c) => {
 197    const user = authUser(c);
 198    if (!user) return c.json({ error: 'unauthorized' }, 401);
 199    const { password } = await c.req.json();
 200    if (!password || password.length < 6) return c.json({ error: 'password must be at least 6 characters' }, 400);
 201    const ok = setUserPassword(user.id, password);
 202    return c.json({ ok });
 203  });
 204  
 205  // --- user orders ---
 206  api.get('/user/orders', (c) => {
 207    const user = authUser(c);
 208    if (!user) return c.json({ error: 'unauthorized' }, 401);
 209    const { getDb } = require('./lib/db');
 210    const rows = getDb().query(
 211      'SELECT id, total_cents, status, payment_method, payment_status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 100'
 212    ).all(user.id);
 213    return c.json(rows);
 214  });
 215  
 216  api.get('/user/orders/:id', (c) => {
 217    const user = authUser(c);
 218    if (!user) return c.json({ error: 'unauthorized' }, 401);
 219    const { getDb } = require('./lib/db');
 220    const order = getDb().query(
 221      'SELECT * FROM orders WHERE id = ? AND user_id = ?'
 222    ).get(c.req.param('id'), user.id) as any;
 223    if (!order) return c.json({ error: 'not found' }, 404);
 224    const { orderView } = require('./lib/orders');
 225    return c.json({ ...orderView(order), items: JSON.parse(order.items), customer_name: order.customer_name, customer_phone: order.customer_phone, shipping_address: order.shipping_address });
 226  });
 227  
 228  api.post('/user/orders/:id/cancel', async (c) => {
 229    const user = authUser(c);
 230    if (!user) return c.json({ error: 'unauthorized' }, 401);
 231    try {
 232      const { getOrder, transitionOrder } = await import('./lib/orders');
 233      const order = getOrder(c.req.param('id'));
 234      if (!order) return c.json({ error: 'not found' }, 404);
 235      if (order.user_id !== user.id) return c.json({ error: 'forbidden' }, 403);
 236      if (order.status !== 'pending') return c.json({ error: 'only pending orders can be cancelled' }, 400);
 237      transitionOrder(order.id, 'cancelled');
 238      return c.json({ ok: true });
 239    } catch (e: any) {
 240      return c.json({ error: e.message || 'cancel failed' }, 400);
 241    }
 242  });
 243  function adminUser(c: any): any | null {
 244    const user = authUser(c);
 245    return user?.is_admin ? user : null;
 246  }
 247  
 248  api.get('/admin/users', (c) => {
 249    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 250    const { getDb } = require('./lib/db');
 251    const rows = getDb().query(
 252      `SELECT u.id, u.email, u.nostr_pubkey, u.is_admin, u.name, u.delivery_prefs, u.created_at,
 253              (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
 254       FROM users u ORDER BY u.created_at DESC LIMIT 200`
 255    ).all();
 256    return c.json(rows);
 257  });
 258  
 259  api.post('/admin/users', async (c) => {
 260    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 261    try {
 262      const { email, password, is_admin } = await c.req.json();
 263      if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
 264        return c.json({ error: 'valid email required' }, 400);
 265      }
 266      if (typeof password !== 'string' || password.length < 6) {
 267        return c.json({ error: 'password must be at least 6 characters' }, 400);
 268      }
 269      const { getDb } = require('./lib/db');
 270      const existing = getDb().query('SELECT id FROM users WHERE email = ?').get(email);
 271      if (existing) return c.json({ error: 'email already registered' }, 409);
 272      const userId = createUser(email, password);
 273      if (is_admin) {
 274        getDb().query('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
 275      }
 276      return c.json({ ok: true, id: userId });
 277    } catch {
 278      return c.json({ error: 'create failed' }, 400);
 279    }
 280  });
 281  
 282  api.post('/admin/users/:id/admin', async (c) => {
 283    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 284    try {
 285      const { is_admin } = await c.req.json();
 286      if (typeof is_admin !== 'number' || (is_admin !== 0 && is_admin !== 1)) {
 287        return c.json({ error: 'is_admin must be 0 or 1' }, 400);
 288      }
 289      const { getDb } = require('./lib/db');
 290      getDb().query('UPDATE users SET is_admin = ? WHERE id = ?').run(is_admin, c.req.param('id'));
 291      return c.json({ ok: true });
 292    } catch {
 293      return c.json({ error: 'bad request' }, 400);
 294    }
 295  });
 296  
 297  api.get('/admin/users/:id/orders', (c) => {
 298    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 299    const { getDb } = require('./lib/db');
 300    const rows = getDb().query(
 301      'SELECT id, customer_name, total_cents, status, payment_method, payment_status, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'
 302    ).all(c.req.param('id'));
 303    return c.json(rows);
 304  });
 305  
 306  api.post('/auth/logout', (c) => {
 307    const auth = c.req.header('Authorization');
 308    if (auth?.startsWith('Bearer ')) deleteSession(auth.slice(7));
 309    return c.json({ ok: true });
 310  });
 311  
 312  // --- checkout ---
 313  api.post('/checkout', async (c) => {
 314    try {
 315      const body = await c.req.json();
 316      const auth = c.req.header('Authorization');
 317      if (auth?.startsWith('Bearer ')) {
 318        const user = getUserFromSession(auth.slice(7));
 319        if (user) body.user_id = user.id;
 320      }
 321      const order = await createOrder(body);
 322      let payment: any = null;
 323      if (body.paymentIntentId) {
 324        // express checkout paid already - mark as paid
 325        const { getDb } = require('./lib/db');
 326        getDb().query('UPDATE orders SET payment_status = ?, payment_id = ? WHERE id = ?').run('paid', body.paymentIntentId, order.id);
 327        payment = { type: body.paymentMethod, paymentId: body.paymentIntentId };
 328      } else {
 329        try {
 330          payment = await createPayment(order);
 331          if (payment?.paymentId) setPaymentId(order.id, payment.paymentId);
 332        } catch (e: any) {
 333          payment = { type: order.payment_method, error: e.message || 'payment setup failed' };
 334        }
 335      }
 336  
 337      // fire best-effort confirmation email
 338      const { orderCreatedEmail } = await import('./lib/email');
 339      const items = JSON.parse(order.items);
 340      const desc = items.map((i: any) => `${i.name} × ${i.qty}кг`).join(', ');
 341      orderCreatedEmail(order.customer_email, order.id, order.view_token, ((order.total_cents) / 100).toFixed(2) + ' лв', desc);
 342      return c.json({
 343        orderId: order.id,
 344        viewToken: order.view_token,
 345        status: order.status,
 346        total_cents: order.total_cents,
 347        shipping_cents: order.shipping_cents,
 348        payment,
 349      });
 350    } catch (e: any) {
 351      if (e.status && e.message) return c.json({ error: e.message }, e.status as any);
 352      console.error('checkout error:', e);
 353      return c.json({ error: 'checkout failed' }, 500);
 354    }
 355  });
 356  
 357  api.get('/orders/:id', async (c) => {
 358    let order = getOrderForView(c.req.param('id'), c.req.query('t') || '');
 359    if (!order) return c.json({ error: 'not found' }, 404);
 360    // lightning settles via LUD-21 verify polling - check on read while pending
 361    if (order.status === 'pending' && order.payment_method === 'lightning' && order.payment_id) {
 362      const { checkLightningPaid } = await import('./lib/payments');
 363      if (await checkLightningPaid(order.payment_id)) {
 364        const { transitionOrder } = await import('./lib/orders');
 365        order = transitionOrder(order.id, 'paid');
 366      }
 367    }
 368    return c.json(orderView(order));
 369  });
 370  
 371  // shipping preview for the checkout page - server-priced, same path as createOrder
 372  api.post('/shipping-quote', async (c) => {
 373    try {
 374      const { items, city, courier, cod, officeDelivery } = await c.req.json();
 375      if (!Array.isArray(items)) return c.json({ error: 'items required' }, 400);
 376      let subtotal = 0;
 377      let weightKg = 0;
 378      for (const it of items) {
 379        const product = getProductBySlug(String(it?.slug ?? ''));
 380        const qty = Number(it?.qty);
 381        if (!product || !Number.isInteger(qty) || qty < 1) continue;
 382        subtotal += product.price_cents * qty;
 383        weightKg += (product.unit_size * qty) / 1000;
 384      }
 385      const { shippingCostCents } = await import('./lib/shipping');
 386      const shipping = await shippingCostCents(weightKg, subtotal, city || undefined, courier || undefined, cod ? subtotal : 0, officeDelivery || false);
 387      return c.json({ subtotal_cents: subtotal, shipping_cents: shipping, total_cents: subtotal + shipping });
 388    } catch (e: any) {
 389      console.error('shipping-quote error:', e);
 390      return c.json({ error: 'invalid request body' }, 400);
 391    }
 392  });
 393  
 394  // --- payment webhooks (public path: /api/webhooks/... via caddy) ---
 395  api.post('/webhooks/stripe', async (c) => {
 396    const secret = process.env.STRIPE_WEBHOOK_SECRET;
 397    const key = process.env.STRIPE_SECRET_KEY;
 398    if (!secret || !key) return c.json({ error: 'not configured' }, 501);
 399    const sig = c.req.header('stripe-signature');
 400    if (!sig) return c.json({ error: 'missing signature' }, 400);
 401    try {
 402      const Stripe = (await import('stripe')).default;
 403      const stripe = new Stripe(key);
 404      const body = await c.req.text();
 405      const event = await stripe.webhooks.constructEventAsync(body, sig, secret);
 406      if (event.type === 'payment_intent.succeeded') {
 407        const intent = event.data.object as any;
 408        const { markPaidByPaymentId, getOrder } = await import('./lib/orders');
 409        const paid = markPaidByPaymentId(intent.id);
 410        if (paid) {
 411          const { paymentConfirmedEmail } = await import('./lib/email');
 412          paymentConfirmedEmail(paid.customer_email, paid.id);
 413        }
 414      } else if (event.type === 'payment_intent.payment_failed') {
 415        const intent = event.data.object as any;
 416        const { getDb } = await import('./lib/db');
 417        const order = getDb().query('SELECT id, status FROM orders WHERE payment_id = ?').get(intent.id) as any;
 418        if (order?.status === 'pending') {
 419          const { transitionOrder } = await import('./lib/orders');
 420          transitionOrder(order.id, 'failed');
 421        }
 422      }
 423      return c.json({ received: true });
 424    } catch (e: any) {
 425      return c.json({ error: 'signature verification failed' }, 400);
 426    }
 427  });
 428  
 429  api.post('/webhooks/btcpay', async (c) => {
 430    const secret = process.env.BTCPAY_WEBHOOK_SECRET;
 431    if (!secret) return c.json({ error: 'not configured' }, 501);
 432    const sig = c.req.header('btcpay-sig');
 433    if (!sig) return c.json({ error: 'missing signature' }, 400);
 434    const body = await c.req.text();
 435    const crypto = await import('crypto');
 436    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
 437    const sigBuf = Buffer.from(sig);
 438    const expBuf = Buffer.from(expected);
 439    if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
 440      return c.json({ error: 'signature verification failed' }, 400);
 441    }
 442    try {
 443      const event = JSON.parse(body);
 444      const orderId = event.metadata?.orderId;
 445      if (orderId && (event.type === 'InvoiceSettled' || event.type === 'InvoicePaymentSettled')) {
 446        const { getOrder, transitionOrder } = await import('./lib/orders');
 447        const order = getOrder(orderId);
 448        if (order && order.status === 'pending') {
 449          transitionOrder(orderId, 'paid');
 450          const { paymentConfirmedEmail } = await import('./lib/email');
 451          paymentConfirmedEmail(order.customer_email, orderId);
 452        }
 453      } else if (orderId && event.type === 'InvoiceExpired') {
 454        const { getOrder, transitionOrder } = await import('./lib/orders');
 455        const order = getOrder(orderId);
 456        if (order?.status === 'pending') transitionOrder(orderId, 'expired');
 457      }
 458      return c.json({ received: true });
 459    } catch {
 460      return c.json({ error: 'bad payload' }, 400);
 461    }
 462  });
 463  
 464  // --- couriers ---
 465  api.get('/cities', (c) => c.json(getCities()));
 466  
 467  api.get('/offices', (c) => {
 468    const city = c.req.query('city');
 469    const courier = c.req.query('courier');
 470    let offices = getOfficesByCourier(courier || '');
 471    if (city) offices = offices.filter(o => o.city.toLowerCase().includes(city.toLowerCase()));
 472    return c.json(offices);
 473  });
 474  
 475  // --- admin orders ---
 476  api.get('/admin/orders', (c) => {
 477    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 478    const { getDb } = require('./lib/db');
 479    const orders = getDb().query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 200').all();
 480    return c.json(orders);
 481  });
 482  
 483  api.post('/admin/orders/:id/status', async (c) => {
 484    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 485    try {
 486      const { to } = await c.req.json();
 487      const { transitionOrder } = await import('./lib/orders');
 488      const order = transitionOrder(c.req.param('id'), to);
 489      if (to === 'paid') {
 490        const { paymentConfirmedEmail } = await import('./lib/email');
 491        paymentConfirmedEmail(order.customer_email, order.id);
 492      }
 493      return c.json(order);
 494    } catch (e: any) {
 495      if (e.status && e.message) return c.json({ error: e.message }, e.status as any);
 496      return c.json({ error: 'transition failed' }, 500);
 497    }
 498  });
 499  
 500  api.post('/admin/orders/:id/label', async (c) => {
 501    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 502    try {
 503      const { getOrder } = await import('./lib/orders');
 504      const order = getOrder(c.req.param('id'));
 505      if (!order) return c.json({ error: 'order not found' }, 404);
 506      if (order.status !== 'paid' && order.status !== 'pending') return c.json({ error: 'order must be paid or pending' }, 400);
 507  
 508      const items: any[] = JSON.parse(order.items);
 509      const addr: any = JSON.parse(order.shipping_address || '{}');
 510      const weightKg = items.reduce((s: number, it: any) => s + (it.unit_size * it.qty) / 1000, 0);
 511  
 512      const { econtCreateLabel } = await import('./lib/econt');
 513      const result = await econtCreateLabel(
 514        order.customer_name,
 515        order.customer_phone || '',
 516        addr.city || '',
 517        addr.address || '',
 518        addr.postCode || '',
 519        addr.address2 || '',
 520        weightKg,
 521        order.shipping_method === 'office',
 522        order.payment_method === 'cod' ? order.total_cents : 0,
 523      );
 524  
 525      if (result.error) return c.json({ error: result.error }, 400);
 526  
 527      const { getDb } = await import('./lib/db');
 528      getDb().query("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('shipped', order.id);
 529      getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(`label_${order.id}`, JSON.stringify(result));
 530  
 531      return c.json(result);
 532    } catch (e: any) {
 533      console.error('label creation error:', e);
 534      return c.json({ error: e.message || 'label creation failed' }, 400);
 535    }
 536  });
 537  
 538  api.post('/admin/products', async (c) => {
 539    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 540    try {
 541      const p = await c.req.json();
 542      if (!p.name || !p.slug || !Number.isInteger(p.price_cents) || p.price_cents < 0) {
 543        return c.json({ error: 'name, slug, price_cents required' }, 400);
 544      }
 545      const { getDb } = await import('./lib/db');
 546      const maxOrd = (getDb().query('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM products').get() as { n: number }).n;
 547      getDb().query(`INSERT INTO products (name, name_en, slug, description, description_en, price_cents, unit_size, unit, images, category, stock_count, harvest_year, ord)
 548        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
 549        p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '',
 550        p.price_cents, p.unit_size ?? 100, p.unit || 'g', p.images || '[]', p.category || 'bilki', p.stock_count ?? 0, p.harvest_year || null, maxOrd);
 551      return c.json(getProductBySlug(p.slug));
 552    } catch (e: any) {
 553      return c.json({ error: e.message?.includes('UNIQUE') ? 'slug already exists' : 'create failed' }, 400);
 554    }
 555  });
 556  
 557  api.put('/admin/products/:id', async (c) => {
 558    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 559    let p: any = {};
 560    try {
 561      p = await c.req.json();
 562      const id = parseInt(c.req.param('id'));
 563      const { getDb } = await import('./lib/db');
 564      const existing = getDb().query('SELECT id FROM products WHERE id = ?').get(id);
 565      if (!existing) return c.json({ error: 'not found' }, 404);
 566      getDb().query(`UPDATE products SET name = ?, name_en = ?, slug = ?, description = ?, description_en = ?,
 567        price_cents = ?, unit_size = ?, unit = ?, images = ?, category = ?, stock_count = ?, harvest_year = ?, usage = ?, usage_en = ? WHERE id = ?`).run(
 568        p.name, p.name_en || '', p.slug, p.description || '', p.description_en || '',
 569        Number.isFinite(p.price_cents) ? p.price_cents : 100, p.unit_size ?? 100, p.unit || 'g', p.images || '[]', p.category || 'bilki', Number.isFinite(p.stock_count) ? p.stock_count : 0, p.harvest_year || null,
 570        p.usage || '', p.usage_en || '', id);
 571      return c.json(getDb().query('SELECT * FROM products WHERE id = ?').get(id));
 572    } catch (e: any) {
 573      console.error('admin/products PUT failed:', e?.message || e, 'body:', JSON.stringify(p || {}));
 574      return c.json({ error: 'update failed' }, 400);
 575    }
 576  });
 577  
 578  api.delete('/admin/products/:id', async (c) => {
 579    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 580    const { getDb } = await import('./lib/db');
 581    getDb().query('DELETE FROM products WHERE id = ?').run(parseInt(c.req.param('id')));
 582    return c.json({ ok: true });
 583  });
 584  
 585  api.post('/admin/products/:id/reorder', async (c) => {
 586    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 587    try {
 588      const { direction } = await c.req.json();
 589      if (direction !== 'up' && direction !== 'down') return c.json({ error: 'invalid direction' }, 400);
 590      const id = parseInt(c.req.param('id'));
 591      const { getDb } = await import('./lib/db');
 592      const cur = getDb().query('SELECT ord FROM products WHERE id = ?').get(id) as { ord: number } | null;
 593      if (!cur) return c.json({ error: 'not found' }, 404);
 594      const swapOrd = direction === 'up' ? cur.ord - 1 : cur.ord + 1;
 595      const other = getDb().query('SELECT id FROM products WHERE ord = ? LIMIT 1').get(swapOrd) as { id: number } | null;
 596      if (!other) return c.json({ ok: true });
 597      getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(swapOrd, id);
 598      getDb().query('UPDATE products SET ord = ? WHERE id = ?').run(cur.ord, other.id);
 599      return c.json({ ok: true });
 600    } catch (e: any) {
 601      return c.json({ error: e.message || 'reorder failed' }, 400);
 602    }
 603  });
 604  
 605  api.post('/admin/upload', async (c) => {
 606    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 607    try {
 608      const form = await c.req.formData();
 609      const file = form.get('file');
 610      if (!file || !(file instanceof Blob)) return c.json({ error: 'file required' }, 400);
 611      const ext = ((file as any).name || 'image').split('.').pop()?.toLowerCase() || 'jpg';
 612      if (!['jpg','jpeg','png','gif','webp','svg'].includes(ext)) return c.json({ error: 'invalid image type' }, 400);
 613      const buf = await file.arrayBuffer();
 614      const filename = `upload_${Date.now()}_${Math.random().toString(36).slice(2,8)}.${ext}`;
 615      const path = `public/images/products/${filename}`;
 616      await Bun.write(path, new Uint8Array(buf));
 617      return c.json({ url: `/images/products/${filename}` });
 618    } catch (e: any) {
 619      return c.json({ error: e.message || 'upload failed' }, 500);
 620    }
 621  });
 622  
 623  // --- content (terms) ---
 624  api.get('/terms', (c) => {
 625    const { getDb } = require('./lib/db');
 626    const rows = getDb().query("SELECT key, value FROM content WHERE key LIKE 'terms_%' OR key LIKE 'privacy_%'").all() as { key: string; value: string }[];
 627    const result: Record<string, string> = {};
 628    for (const r of rows) result[r.key] = r.value;
 629    return c.json(result);
 630  });
 631  
 632  api.put('/admin/terms', async (c) => {
 633    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 634    try {
 635      const body = await c.req.json();
 636      const { getDb } = await import('./lib/db');
 637      for (const [key, value] of Object.entries(body)) {
 638        if (typeof key === 'string' && ['terms_bg', 'terms_en', 'privacy_bg', 'privacy_en'].includes(key) && typeof value === 'string') {
 639          getDb().query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(key, value);
 640        }
 641      }
 642      return c.json({ ok: true });
 643    } catch {
 644      return c.json({ error: 'update failed' }, 400);
 645    }
 646  });
 647  
 648  api.post('/contact', async (c) => {
 649    if (!rateLimit('contact:' + clientKey(c), 5, 300_000)) return c.json({ error: 'too many requests' }, 429);
 650    try {
 651      const body = await c.req.json();
 652      const { name, email, subject, message } = body;
 653      if (!name || !email || !message) return c.json({ error: 'name, email and message required' }, 400);
 654      const db = (await import('./lib/db')).getDb();
 655      db.query('INSERT INTO contact_messages (name, email, subject, message) VALUES (?, ?, ?, ?)').run(
 656        String(name).slice(0, 200), String(email).slice(0, 200),
 657        String(subject || 'general').slice(0, 50), String(message).slice(0, 5000)
 658      );
 659      return c.json({ ok: true });
 660    } catch {
 661      return c.json({ error: 'failed to send' }, 400);
 662    }
 663  });
 664  
 665  api.get('/admin/messages', async (c) => {
 666    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 667    const db = (await import('./lib/db')).getDb();
 668    const rows = db.query('SELECT * FROM contact_messages ORDER BY created_at DESC').all();
 669    return c.json(rows);
 670  });
 671  
 672  api.get('/admin/stripe-config', async (c) => {
 673    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 674    const db = (await import('./lib/db')).getDb();
 675    const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('stripe_%') as { key: string; value: string }[];
 676    const cfg: Record<string, string> = {};
 677    for (const r of rows) cfg[r.key] = r.value;
 678    return c.json(cfg);
 679  });
 680  
 681  api.put('/admin/stripe-config', async (c) => {
 682    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 683    try {
 684      const body = await c.req.json();
 685      const db = (await import('./lib/db')).getDb();
 686      const keys = ['stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'];
 687      for (const k of keys) {
 688        if (typeof body[k] === 'string') {
 689          db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
 690        }
 691      }
 692      return c.json({ ok: true });
 693    } catch {
 694      return c.json({ error: 'update failed' }, 400);
 695    }
 696  });
 697  
 698  api.get('/admin/btcpay-config', async (c) => {
 699    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 700    const db = (await import('./lib/db')).getDb();
 701    const rows = db.query('SELECT key, value FROM content WHERE key LIKE ?').all('btcpay_%') as { key: string; value: string }[];
 702    const cfg: Record<string, string> = {};
 703    for (const r of rows) cfg[r.key] = r.value;
 704    return c.json(cfg);
 705  });
 706  
 707  api.put('/admin/btcpay-config', async (c) => {
 708    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 709    try {
 710      const body = await c.req.json();
 711      const db = (await import('./lib/db')).getDb();
 712      const keys = ['btcpay_url', 'btcpay_api_key', 'btcpay_store_id'];
 713      for (const k of keys) {
 714        if (typeof body[k] === 'string') {
 715          db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
 716        }
 717      }
 718      return c.json({ ok: true });
 719    } catch {
 720      return c.json({ error: 'update failed' }, 400);
 721    }
 722  });
 723  
 724  api.get('/options', async (c) => {
 725    const db = (await import('./lib/db')).getDb();
 726    const rows = db.query('SELECT key, value FROM content').all() as { key: string; value: string }[];
 727    const cfg: Record<string, string> = {};
 728    for (const r of rows) cfg[r.key] = r.value;
 729    return c.json(cfg);
 730  });
 731  
 732  api.put('/admin/options', async (c) => {
 733    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 734    try {
 735      const body = await c.req.json();
 736      const db = (await import('./lib/db')).getDb();
 737      for (const [k, v] of Object.entries(body)) {
 738        if (typeof v === 'string') {
 739          db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, v);
 740        }
 741      }
 742      return c.json({ ok: true });
 743    } catch {
 744      return c.json({ error: 'update failed' }, 400);
 745    }
 746  });
 747  
 748  api.get('/admin/shipping-config', async (c) => {
 749    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 750    const db = (await import('./lib/db')).getDb();
 751    const rows = db.query("SELECT key, value FROM content WHERE key LIKE 'econt_%' OR key = 'shipping_rate_per_kg' OR key = 'free_shipping_threshold'").all() as { key: string; value: string }[];
 752    const cfg: Record<string, string> = {};
 753    for (const r of rows) cfg[r.key] = r.value;
 754    return c.json(cfg);
 755  });
 756  
 757  api.put('/admin/shipping-config', async (c) => {
 758    if (!adminUser(c)) return c.json({ error: 'forbidden' }, 403);
 759    try {
 760      const body = await c.req.json();
 761      const db = (await import('./lib/db')).getDb();
 762      const keys = ['econt_username', 'econt_password', 'econt_sender_city', 'econt_sender_postcode', 'econt_sender_street', 'econt_sender_street_num', 'econt_sender_quarter', 'econt_test_mode', 'shipping_rate_per_kg', 'free_shipping_threshold'];
 763      for (const k of keys) {
 764        if (typeof body[k] === 'string') {
 765          db.query('INSERT OR REPLACE INTO content (key, value) VALUES (?, ?)').run(k, body[k]);
 766        }
 767      }
 768      return c.json({ ok: true });
 769    } catch {
 770      return c.json({ error: 'update failed' }, 400);
 771    }
 772  });
 773  
 774  api.get('/proxy-image', async (c) => {
 775    const url = c.req.query('url');
 776    if (!url) return c.json({ error: 'url required' }, 400);
 777    try {
 778      const resp = await fetch(url);
 779      if (!resp.ok) return c.json({ error: 'fetch failed' }, 502);
 780      const ct = resp.headers.get('content-type') || 'image/jpeg';
 781      const buf = await resp.arrayBuffer();
 782      return new Response(buf, { headers: { 'Content-Type': ct, 'Cache-Control': 'public, max-age=86400' } });
 783    } catch {
 784      return c.json({ error: 'fetch failed' }, 502);
 785    }
 786  });
 787  
 788  export default api;
 789