JEISA-WEBAPP/server/po-api.mjs
Dave 0b8f003f87 feat(po): add MCP server, agent-friendly PO API, and PDF rendering
- 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).
2026-09-07 11:12:13 +02:00

353 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* HTTP JSON shim for the JEISA purchase-order helpers.
*
* Run with:
* PO_API_TOKEN=<long-random-string> node --import tsx/esm server/po-api.mjs
*/
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, '..');
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';
const MAX_BODY_BYTES = Number(process.env.PO_API_MAX_BODY_BYTES || 25 * 1024 * 1024);
if (!TOKEN) {
console.error('FATAL: PO_API_TOKEN env var is required.');
process.exit(1);
}
if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
console.error('FATAL: PO_API_PORT must be an integer between 1 and 65535.');
process.exit(1);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
let authReady = authenticate();
async function authenticate() {
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
console.log(`[po-api] PB auth OK (${PB_EMAIL} @ ${PB_URL})`);
}
async function withPb(fn) {
await authReady;
try {
return await fn(pb);
} catch (error) {
if (error?.status === 401 || error?.response?.status === 401) {
console.warn('[po-api] PB 401 — re-authenticating');
authReady = authenticate();
await authReady;
return fn(pb);
}
throw error;
}
}
const api = await import(pathToFileURL(path.join(ROOT, 'src/lib/api/purchaseOrders.ts')).href);
const photo = await import(pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href);
const {
listPurchaseOrders,
createPurchaseOrder,
updatePurchaseOrder,
softDeletePurchaseOrder,
restorePurchaseOrder,
receiveStock,
validatePurchaseOrderDraft,
generateOrderNumber,
} = api;
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, status, body) {
if (res.writableEnded) return;
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...CORS });
res.end(JSON.stringify(body));
}
function fail(res, status, message, detail) {
json(res, status, { error: message, ...(detail !== undefined ? { detail } : {}) });
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let rejected = false;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
rejected = true;
reject(Object.assign(new Error('Request body is too large'), { status: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!rejected) resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', (error) => {
if (!rejected) reject(error);
});
});
}
async function readJson(req, res) {
let raw;
try {
raw = await readBody(req);
} catch (error) {
fail(res, error.status || 400, error.message || 'Unable to read request body');
return null;
}
if (!raw) return {};
try {
const value = JSON.parse(raw);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail(res, 400, 'JSON body must be an object');
return null;
}
return value;
} catch {
fail(res, 400, 'Request body must be valid JSON');
return null;
}
}
function checkAuth(req, res) {
const supplied = req.headers.authorization || '';
const expected = `Bearer ${TOKEN}`;
if (supplied.length !== expected.length) {
fail(res, 401, 'Unauthorized');
return false;
}
let mismatch = 0;
for (let i = 0; i < supplied.length; i += 1) {
mismatch |= supplied.charCodeAt(i) ^ expected.charCodeAt(i);
}
if (mismatch !== 0) {
fail(res, 401, 'Unauthorized');
return false;
}
return true;
}
function errorStatus(error) {
const message = error?.message || '';
if (error?.status === 404) return 404;
if (error?.status === 413) return 413;
if (message.startsWith('No supplier matched') || message.startsWith('No stock item matched')) return 404;
if (message.startsWith('Draft validation failed')) return 400;
if (message.startsWith('OCR provider not yet wired up')) return 501;
return 500;
}
function escapeFilter(value) {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
}
const routes = [];
function add(method, routePath, handler, auth = true) {
const keys = [];
const pattern = new RegExp(`^${routePath.replace(/:([a-zA-Z_]+)/g, (_match, key) => {
keys.push(key);
return '([^/]+)';
})}/?$`);
routes.push({ method, pattern, keys, handler, auth });
}
add('GET', '/purchase-orders', async (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const page = Math.max(1, Number(url.searchParams.get('page') || 1));
const perPage = Math.min(500, Math.max(1, Number(url.searchParams.get('perPage') || 50)));
const includeDeleted = url.searchParams.get('includeDeleted') === 'true';
const result = await withPb((client) => listPurchaseOrders(client, { page, perPage, includeDeleted }));
json(res, 200, result);
});
add('POST', '/purchase-orders', async (req, res) => {
const body = await readJson(req, res);
if (body === null) return;
try {
json(res, 201, await withPb((client) => createPurchaseOrder(client, body)));
} catch (error) {
fail(res, errorStatus(error), error?.message || 'create failed', error?.data);
}
});
add('GET', '/purchase-orders/:id', async (_req, res, params) => {
try {
const record = await withPb((client) => client.collection('purchase_orders').getOne(params.id, { expand: 'items,supplierName' }));
json(res, 200, record);
} catch (error) {
fail(res, error?.status === 404 ? 404 : errorStatus(error), error?.status === 404 ? 'Purchase order not found' : (error?.message || 'fetch failed'));
}
});
add('PATCH', '/purchase-orders/:id', async (req, res, params) => {
const body = await readJson(req, res);
if (body === null) return;
try {
json(res, 200, await withPb((client) => updatePurchaseOrder(client, params.id, body)));
} catch (error) {
fail(res, errorStatus(error), error?.message || 'update failed', error?.data);
}
});
add('DELETE', '/purchase-orders/:id', async (_req, res, params) => {
try {
await withPb((client) => softDeletePurchaseOrder(client, params.id));
json(res, 200, { ok: true, id: params.id, deleted: true });
} catch (error) {
fail(res, errorStatus(error), error?.message || 'delete failed');
}
});
add('POST', '/purchase-orders/:id/restore', async (_req, res, params) => {
try {
await withPb((client) => restorePurchaseOrder(client, params.id));
json(res, 200, { ok: true, id: params.id, deleted: false });
} catch (error) {
fail(res, errorStatus(error), error?.message || 'restore failed');
}
});
add('POST', '/purchase-orders/:id/receive', async (req, res, params) => {
const body = await readJson(req, res);
if (body === null) return;
if (!Array.isArray(body.received) || body.received.length === 0) {
fail(res, 400, 'received[] must be a non-empty array of { stockItemId, quantity }');
return;
}
try {
await withPb((client) => receiveStock(client, params.id, body.received));
json(res, 200, { ok: true, id: params.id, status: 'Received' });
} catch (error) {
fail(res, errorStatus(error), error?.message || 'receive failed');
}
});
add('POST', '/purchase-orders/draft:validate', async (req, res) => {
const body = await readJson(req, res);
if (body === null) return;
try {
const result = await withPb((client) => validatePurchaseOrderDraft(client, body));
json(res, result.ok ? 200 : 400, result);
} catch (error) {
fail(res, errorStatus(error), error?.message || 'validate failed');
}
});
add('POST', '/purchase-orders/draft:generate-id', async (_req, res) => {
try {
json(res, 200, { order_number: await withPb((client) => generateOrderNumber(client)) });
} catch (error) {
fail(res, errorStatus(error), error?.message || 'generate-id failed');
}
});
add('POST', '/purchase-orders/from-photo', async (req, res) => {
const body = await readJson(req, res);
if (body === null) return;
if (typeof body.image !== 'string' || !body.image) {
fail(res, 400, 'image (base64) is required');
return;
}
try {
const result = await withPb((client) => photo.ingestPhotoAndCreate({
pb: client,
image: body.image,
mimeType: body.mimeType,
emailFrom: body.email_from,
subject: body.subject,
provider: body.provider,
}));
json(res, 201, result);
} catch (error) {
fail(res, errorStatus(error), error?.message || 'photo ingest failed', error?.data);
}
});
add('POST', '/suppliers', async (req, res) => {
const body = await readJson(req, res);
if (body === null) return;
try {
json(res, 201, await withPb((client) => client.collection('suppliers').create(body)));
} catch (error) {
fail(res, errorStatus(error), error?.message || 'create supplier failed', error?.data);
}
});
add('GET', '/stock-items', async (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const q = url.searchParams.get('q') || '';
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 50)));
const escaped = escapeFilter(q);
const filter = q ? `name ~ "${escaped}" || sku ~ "${escaped}"` : '';
try {
const result = await withPb((client) => client.collection('stock_items').getList(1, limit, {
sort: 'name',
...(filter ? { filter } : {}),
}));
json(res, 200, result);
} catch (error) {
fail(res, errorStatus(error), error?.message || 'list stock failed');
}
});
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS);
res.end();
return;
}
const requestUrl = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && requestUrl.pathname === '/healthz') {
json(res, 200, { ok: true, ts: new Date().toISOString() });
return;
}
for (const route of routes) {
if (route.method !== req.method) continue;
const match = requestUrl.pathname.match(route.pattern);
if (!match) continue;
if (route.auth && !checkAuth(req, res)) return;
const params = Object.fromEntries(route.keys.map((key, index) => [key, decodeURIComponent(match[index + 1])]));
const started = Date.now();
try {
await route.handler(req, res, params);
console.log(`[po-api] ${req.method} ${requestUrl.pathname} → ${res.statusCode} (${Date.now() - started}ms)`);
} catch (error) {
console.error(`[po-api] ${req.method} ${requestUrl.pathname} crashed:`, error?.stack || error);
fail(res, errorStatus(error), error?.message || 'internal error');
}
return;
}
fail(res, 404, `No route for ${req.method} ${requestUrl.pathname}`);
});
server.listen(PORT, () => console.log(`[po-api] listening on :${PORT}`));
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
console.log(`[po-api] ${signal} received, shutting down.`);
server.close(() => process.exit(0));
});
}