- server/pb-mcp.mjs: 15-tool MCP server for purchase-order workflow (CRUD, suppliers, stock, customers, customer POs, validate_draft, generate_order_number) - src/lib/api/purchaseOrders.ts: typed helpers shared by UI + MCP + n8n - src/components/purchase-orders/pdf.ts: jsPDF PO generator - src/components/purchase-orders/ViewOrderDialog.tsx: read-only dialog - src/components/purchase-orders/AttachmentsDialog.tsx: attachment mgmt - src/components/purchase-orders/NumberInput.tsx: numeric input - scripts/mcp-create-po.mjs: end-to-end driver (MCP stdio smoke test) - pocketbase/pb_migrations/1786200007_*: schema migration - docs/MCP_AND_N8N.md: MCP + n8n integration guide Verified end-to-end via MCP: generate_order_number → create_purchase_order with snapshot_items for an actual quote (DRILL-19042 / 161053).
395 lines
No EOL
13 KiB
JavaScript
395 lines
No EOL
13 KiB
JavaScript
#!/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 <PO_API_TOKEN>`. /healthz is open so a load
|
|
* balancer can probe it.
|
|
*
|
|
* Usage:
|
|
* PO_API_TOKEN=<long-random-string> \
|
|
* 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<T>(fn: (pb: PocketBase) => Promise<T>): Promise<T> {
|
|
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<string> {
|
|
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<string, any> {
|
|
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<string, string>) => Promise<void>;
|
|
|
|
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<string, string> = {};
|
|
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));
|
|
});
|
|
} |