- 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).
353 lines
11 KiB
JavaScript
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));
|
|
});
|
|
}
|