#!/usr/bin/env node /** * PO API HTTP shim. * * Exposes the helpers in `src/lib/api/purchaseOrders.ts` (and the photo * ingest path in `src/lib/api/photoIngest.ts`) as JSON endpoints so an * MCP server, n8n Code node, or any HTTP client can drive the JEISA PO * system end-to-end. * * Endpoint surface (matches docs/MCP_AND_N8N.md §1): * * GET /purchase-orders * POST /purchase-orders * GET /purchase-orders/:id * PATCH /purchase-orders/:id * DELETE /purchase-orders/:id (soft-delete) * POST /purchase-orders/:id/restore * POST /purchase-orders/:id/receive * POST /purchase-orders/draft:validate * POST /purchase-orders/draft:generate-id * POST /purchase-orders/from-photo * POST /suppliers * GET /stock-items?q=…&limit=… * GET /healthz (no auth) * * Auth: every /purchase-orders, /suppliers, /stock-items route requires * `Authorization: Bearer `. /healthz is open so a load * balancer can probe it. * * Usage: * PO_API_TOKEN= \ * node --import tsx/esm server/po-api.mjs * * Or via the npm script: * npm run po-api * * Env: * PO_API_TOKEN required * PO_API_PORT default 8787 * POCKETBASE_URL default https://pb.jeistk.apphub.co.za * POCKETBASE_ADMIN_EMAIL * POCKETBASE_ADMIN_PASSWORD * OCR_PROVIDER openai | google | textract | stub (default stub) * OPENAI_API_KEY for provider=openai */ import http from 'node:http'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { Buffer } from 'node:buffer'; import PocketBase from 'pocketbase'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const TOKEN = process.env.PO_API_TOKEN || ''; const PORT = Number(process.env.PO_API_PORT || 8787); const PB_URL = process.env.POCKETBASE_URL || 'https://pb.jeistk.apphub.co.za'; const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || 'david@wfsales.co.za'; const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || '1234567890'; if (!TOKEN) { console.error('FATAL: PO_API_TOKEN env var is required.'); process.exit(1); } // --------------------------------------------------------------------------- // PocketBase session // --------------------------------------------------------------------------- const pb = new PocketBase(PB_URL); pb.autoCancellation(false); let authReady = (async () => { try { await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); console.log(`[po-api] PB auth OK (${PB_EMAIL} @ ${PB_URL})`); } catch (err) { console.error('[po-api] PB auth FAILED:', err?.message || err); process.exit(1); } })(); // Re-auth on 401 — PocketBase sessions can expire. async function withPb(fn: (pb: PocketBase) => Promise): Promise { await authReady; try { return await fn(pb); } catch (err: any) { if (err?.status === 401 || err?.response?.status === 401) { console.warn('[po-api] PB 401 — re-authenticating'); await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); return fn(pb); } throw err; } } // --------------------------------------------------------------------------- // Typed helpers (loaded once at startup) // --------------------------------------------------------------------------- const apiUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/purchaseOrders.ts')).href; const photoUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href; const api: typeof import('../src/lib/api/purchaseOrders') = await import(apiUrl); const photo: typeof import('../src/lib/api/photoIngest') = await import(photoUrl); const { listPurchaseOrders, createPurchaseOrder, updatePurchaseOrder, softDeletePurchaseOrder, restorePurchaseOrder, receiveStock, validatePurchaseOrderDraft, generateOrderNumber, } = api; // --------------------------------------------------------------------------- // Tiny HTTP helpers // --------------------------------------------------------------------------- const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, PATCH, DELETE, OPTIONS', 'access-control-allow-headers': 'authorization, content-type', }; function json(res: http.ServerResponse, status: number, body: unknown) { res.writeHead(status, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify(body)); } function err(res: http.ServerResponse, status: number, message: string, extra?: unknown) { json(res, status, { error: message, ...(extra ? { detail: extra } : {}) }); } function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function parseBody(raw: string): Record { if (!raw) return {}; try { return JSON.parse(raw); } catch { return {}; } } function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean { const h = req.headers.authorization || ''; const expected = `Bearer ${TOKEN}`; // Constant-time compare to avoid timing leaks. if (h.length !== expected.length) { err(res, 401, 'Unauthorized'); return false; } let mismatch = 0; for (let i = 0; i < h.length; i++) mismatch |= h.charCodeAt(i) ^ expected.charCodeAt(i); if (mismatch !== 0) { err(res, 401, 'Unauthorized'); return false; } return true; } /** * Translate a TS-side helper error into an HTTP status: * 400 — invalid input (e.g. draft validation failed) * 404 — record not found * 500 — anything else */ function statusFor(err: any): number { const msg = err?.message || ''; if (msg.startsWith('No supplier matched') || msg.startsWith('No stock item matched')) return 404; if (msg.startsWith('Draft validation failed')) return 400; if (msg.startsWith('OCR provider not yet wired up')) return 501; return 500; } // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- type Handler = (req: http.IncomingMessage, res: http.ServerResponse, params: Record) => Promise; const routes: Array<{ method: string; pattern: RegExp; keys: string[]; handler: Handler; auth: boolean }> = []; function add(method: string, path: string, handler: Handler, auth = true) { const keys: string[] = []; const pattern = new RegExp( '^' + path.replace(/:([a-zA-Z_]+)/g, (_m, k) => { keys.push(k); return '([^/]+)'; }) + '/?$', ); routes.push({ method, pattern, keys, handler, auth }); } // Purchase orders --------------------------------------------------------- add('GET', '/purchase-orders', async (req, res) => { const url = new URL(req.url || '/', 'http://x'); const page = Number(url.searchParams.get('page') || 1); const perPage = Number(url.searchParams.get('perPage') || 50); const includeDeleted = url.searchParams.get('includeDeleted') === 'true'; const result = await withPb((pb) => listPurchaseOrders(pb, { page, perPage, includeDeleted })); json(res, 200, result); }); add('POST', '/purchase-orders', async (req, res) => { const body = parseBody(await readBody(req)); try { const record = await withPb((pb) => createPurchaseOrder(pb, body)); json(res, 201, record); } catch (e: any) { err(res, statusFor(e), e?.message || 'create failed', e?.data); } }); add('GET', '/purchase-orders/:id', async (_req, res, p) => { try { const record = await withPb((pb) => pb.collection('purchase_orders').getOne(p.id, { expand: 'items,supplierName', })); json(res, 200, record); } catch (e: any) { if (e?.status === 404) return err(res, 404, 'Purchase order not found'); err(res, 500, e?.message || 'fetch failed'); } }); add('PATCH', '/purchase-orders/:id', async (req, res, p) => { const body = parseBody(await readBody(req)); try { const record = await withPb((pb) => updatePurchaseOrder(pb, p.id, body)); json(res, 200, record); } catch (e: any) { err(res, statusFor(e), e?.message || 'update failed', e?.data); } }); add('DELETE', '/purchase-orders/:id', async (_req, res, p) => { try { await withPb((pb) => softDeletePurchaseOrder(pb, p.id)); json(res, 200, { ok: true, id: p.id, deleted: true }); } catch (e: any) { err(res, statusFor(e), e?.message || 'delete failed'); } }); add('POST', '/purchase-orders/:id/restore', async (_req, res, p) => { try { await withPb((pb) => restorePurchaseOrder(pb, p.id)); json(res, 200, { ok: true, id: p.id, deleted: false }); } catch (e: any) { err(res, statusFor(e), e?.message || 'restore failed'); } }); add('POST', '/purchase-orders/:id/receive', async (req, res, p) => { const body = parseBody(await readBody(req)); const received = Array.isArray(body.received) ? body.received : []; if (received.length === 0) { return err(res, 400, 'received[] must be a non-empty array of { stockItemId, quantity }'); } try { await withPb((pb) => receiveStock(pb, p.id, received)); json(res, 200, { ok: true, id: p.id, status: 'Received' }); } catch (e: any) { err(res, statusFor(e), e?.message || 'receive failed'); } }); // Draft helpers ----------------------------------------------------------- add('POST', '/purchase-orders/draft:validate', async (req, res) => { const body = parseBody(await readBody(req)); try { const result = await withPb((pb) => validatePurchaseOrderDraft(pb, body)); json(res, result.ok ? 200 : 400, result); } catch (e: any) { err(res, statusFor(e), e?.message || 'validate failed'); } }); add('POST', '/purchase-orders/draft:generate-id', async (_req, res) => { try { const order_number = await withPb((pb) => generateOrderNumber(pb)); json(res, 200, { order_number }); } catch (e: any) { err(res, statusFor(e), e?.message || 'generate-id failed'); } }); add('POST', '/purchase-orders/from-photo', async (req, res) => { const body = parseBody(await readBody(req)); if (!body.image) return err(res, 400, 'image (base64) is required'); try { const result = await withPb((pb) => photo.ingestPhotoAndCreate({ pb, image: body.image, mimeType: body.mimeType, emailFrom: body.email_from, subject: body.subject, provider: body.provider, })); json(res, 201, result); } catch (e: any) { err(res, statusFor(e), e?.message || 'photo ingest failed', e?.data); } }); // Suppliers / Stock ------------------------------------------------------- add('POST', '/suppliers', async (req, res) => { const body = parseBody(await readBody(req)); try { const record = await withPb((pb) => pb.collection('suppliers').create(body)); json(res, 201, record); } catch (e: any) { err(res, statusFor(e), e?.message || 'create supplier failed', e?.data); } }); add('GET', '/stock-items', async (req, res) => { const url = new URL(req.url || '/', 'http://x'); const q = url.searchParams.get('q') || ''; const limit = Number(url.searchParams.get('limit') || 50); const filter = q ? `name ~ "${q.replace(/"/g, '\\"')}" || sku ~ "${q.replace(/"/g, '\\"')}"` : ''; try { const result = await withPb((pb) => pb.collection('stock_items').getList(1, limit, { sort: 'name', ...(filter ? { filter } : {}), })); json(res, 200, result); } catch (e: any) { err(res, statusFor(e), e?.message || 'list stock failed'); } }); // --------------------------------------------------------------------------- // Dispatcher // --------------------------------------------------------------------------- const server = http.createServer(async (req, res) => { // CORS preflight if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); } const url = req.url || '/'; const pathOnly = url.split('?')[0]; // /healthz is open + fast. if (req.method === 'GET' && pathOnly === '/healthz') { return json(res, 200, { ok: true, ts: new Date().toISOString() }); } // Match routes. for (const r of routes) { if (r.method !== req.method) continue; const m = pathOnly.match(r.pattern); if (!m) continue; if (r.auth && !checkAuth(req, res)) return; const params: Record = {}; r.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); }); const start = Date.now(); try { await r.handler(req, res, params); const ms = Date.now() - start; console.log(`[po-api] ${req.method} ${pathOnly} → ${res.statusCode} (${ms}ms)`); } catch (e: any) { console.error(`[po-api] ${req.method} ${pathOnly} crashed:`, e?.stack || e); if (!res.writableEnded) err(res, 500, e?.message || 'internal error'); } return; } err(res, 404, `No route for ${req.method} ${pathOnly}`); }); server.listen(PORT, () => { console.log(`[po-api] listening on :${PORT} (POST /healthz for liveness)`); }); // Graceful shutdown so the PB admin token isn't orphaned in test runs. for (const sig of ['SIGINT', 'SIGTERM'] as const) { process.on(sig, () => { console.log(`[po-api] ${sig} received, shutting down.`); server.close(() => process.exit(0)); }); }