u
This commit is contained in:
@@ -19,6 +19,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
|
|||||||
cp .env.example .env && npm install
|
cp .env.example .env && npm install
|
||||||
npm start # PORT 4208 (HTTPS 8443)
|
npm start # PORT 4208 (HTTPS 8443)
|
||||||
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ (no openapi.json on disk — /docs/ builds it live)
|
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ (no openapi.json on disk — /docs/ builds it live)
|
||||||
|
npm run api-graph # regenerates api-graph.svg + api-graph.dot
|
||||||
npm run manage-users # email allowlist
|
npm run manage-users # email allowlist
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ Docs are generated from `@openapi` JSDoc on route handlers:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run docs:api
|
npm run docs:api
|
||||||
|
npm run api-graph # regenerate api-graph.svg + api-graph.dot
|
||||||
```
|
```
|
||||||
|
|
||||||
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`. There is no generated `openapi.json` on disk — the interactive Swagger UI at `/docs/` builds the spec from the same `@openapi` JSDoc at runtime (`src/api/openapiSpec.js`).
|
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`. `npm run api-graph` generates `api-graph.svg` and `api-graph.dot` visualizing the API resources, endpoints, and dataflow relationships. There is no generated `openapi.json` on disk — the interactive Swagger UI at `/docs/` builds the spec from the same `@openapi` JSDoc at runtime (`src/api/openapiSpec.js`).
|
||||||
|
|
||||||
### Function review files
|
### Function review files
|
||||||
|
|
||||||
|
|||||||
173
api-graph.dot
Normal file
173
api-graph.dot
Normal file
File diff suppressed because one or more lines are too long
2509
api-graph.svg
Normal file
2509
api-graph.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 289 KiB |
781
generate-api-graph.js
Normal file
781
generate-api-graph.js
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||||||
|
import { getOpenApiSpec } from './src/api/openapiSpec.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outputPath = join(__dirname, 'api-graph.svg');
|
||||||
|
const dotOutputPath = join(__dirname, 'api-graph.dot');
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: '#1e2430',
|
||||||
|
tableBorder: '#3f4c5f',
|
||||||
|
headerBg: '#33415c',
|
||||||
|
headerText: '#e2e8f0',
|
||||||
|
rowBg: '#2a3242',
|
||||||
|
rowBgAlt: '#272e3d',
|
||||||
|
keyBg: '#2b3a52',
|
||||||
|
text: '#d8dee9',
|
||||||
|
muted: '#8b95a7',
|
||||||
|
clusterFill: '#202a3a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_COLORS = {
|
||||||
|
GET: { fg: '#38bdf8', bg: '#182b40' },
|
||||||
|
POST: { fg: '#4ade80', bg: '#143826' },
|
||||||
|
PUT: { fg: '#fbbf24', bg: '#3b2c12' },
|
||||||
|
PATCH: { fg: '#c084fc', bg: '#301c44' },
|
||||||
|
DELETE: { fg: '#f87171', bg: '#3a181e' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_COLORS = [
|
||||||
|
'#ff6b6b',
|
||||||
|
'#6bdf9a',
|
||||||
|
'#5aa7ff',
|
||||||
|
'#f5c15c',
|
||||||
|
'#c79bff',
|
||||||
|
'#ff9f5a',
|
||||||
|
'#5cd6d6',
|
||||||
|
'#ff7fc2',
|
||||||
|
'#8f95ff',
|
||||||
|
'#58d18a',
|
||||||
|
'#ff8f66',
|
||||||
|
'#4fc4e8',
|
||||||
|
'#a98cff',
|
||||||
|
'#ff6f6f',
|
||||||
|
'#4ecb8f',
|
||||||
|
'#5aa7e6',
|
||||||
|
'#ff7aa8',
|
||||||
|
'#e0b25a',
|
||||||
|
'#56c8c8',
|
||||||
|
'#ff5f9e',
|
||||||
|
'#ffb25a',
|
||||||
|
'#56d68a',
|
||||||
|
'#66b3ff',
|
||||||
|
'#c79bff',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLUSTERS = [
|
||||||
|
{
|
||||||
|
id: 'identity',
|
||||||
|
label: 'Identity & Access',
|
||||||
|
resources: ['Auth', 'Company', 'CompanySetting', 'Customergroup', 'Number'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm',
|
||||||
|
label: 'Entities & CRM',
|
||||||
|
resources: ['Entity'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'catalog',
|
||||||
|
label: 'Catalog & Products',
|
||||||
|
resources: ['Article', 'Category', 'Brand', 'Unit', 'PublicCatalog'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'properties',
|
||||||
|
label: 'Properties & Attributes',
|
||||||
|
resources: ['Property', 'Attribute'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchasing',
|
||||||
|
label: 'Suppliers & Purchasing',
|
||||||
|
resources: ['Supplier', 'ArticleSupplier', 'Delivery'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales',
|
||||||
|
label: 'Sales & Fulfillment',
|
||||||
|
resources: ['Quote', 'Order', 'Shipment', 'Shipping', 'Package'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
label: 'Inventory & Warehousing',
|
||||||
|
resources: ['Stock', 'Ledger', 'Warehouse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finance',
|
||||||
|
label: 'Finance & Payments',
|
||||||
|
resources: ['Account', 'Payment'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media',
|
||||||
|
label: 'Media & Storage',
|
||||||
|
resources: ['Image', 'Serper', 'Vfs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pos',
|
||||||
|
label: 'POS & Hardware',
|
||||||
|
resources: ['POSTerminal', 'Printer', 'LabelTemplate', 'POSSync'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai_mcp',
|
||||||
|
label: 'AI & Integrations',
|
||||||
|
resources: ['ModelConversation', 'MCP'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
label: 'System & Realtime',
|
||||||
|
resources: ['Events', 'Health', 'Telemetry', 'Legend'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an OpenAPI endpoint to its logical API resource card.
|
||||||
|
*/
|
||||||
|
function assignResource(apiPath, method, tag) {
|
||||||
|
if (apiPath.startsWith('/auth') || apiPath.startsWith('/.well-known')) return 'Auth';
|
||||||
|
if (apiPath.startsWith('/company/setting') || apiPath.startsWith('/companysetting')) return 'CompanySetting';
|
||||||
|
if (apiPath.startsWith('/company')) return 'Company';
|
||||||
|
if (apiPath.startsWith('/customergroup')) return 'Customergroup';
|
||||||
|
if (apiPath.startsWith('/number')) return 'Number';
|
||||||
|
if (apiPath.startsWith('/entity')) return 'Entity';
|
||||||
|
if (apiPath.startsWith('/propertyname')) return 'Property';
|
||||||
|
if (apiPath.startsWith('/attributename') || apiPath.startsWith('/attributevalue')) return 'Attribute';
|
||||||
|
if (apiPath.startsWith('/article/') && apiPath.includes('/supplier')) return 'ArticleSupplier';
|
||||||
|
if (apiPath.startsWith('/article/') && apiPath.includes('/property')) return 'Property';
|
||||||
|
if (apiPath.startsWith('/article/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||||||
|
if (apiPath.startsWith('/article')) return 'Article';
|
||||||
|
if (apiPath.startsWith('/category/') && apiPath.includes('/property')) return 'Property';
|
||||||
|
if (apiPath.startsWith('/category/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||||||
|
if (apiPath.startsWith('/category')) return 'Category';
|
||||||
|
if (apiPath.startsWith('/brand')) return 'Brand';
|
||||||
|
if (apiPath.startsWith('/unit')) return 'Unit';
|
||||||
|
if (apiPath.startsWith('/public')) return 'PublicCatalog';
|
||||||
|
if (apiPath.startsWith('/supplier')) return 'Supplier';
|
||||||
|
if (apiPath.startsWith('/delivery')) return 'Delivery';
|
||||||
|
if (apiPath.startsWith('/quote')) return 'Quote';
|
||||||
|
if (apiPath.startsWith('/order')) return 'Order';
|
||||||
|
if (apiPath.startsWith('/shipment')) return 'Shipment';
|
||||||
|
if (apiPath.startsWith('/shipping')) return 'Shipping';
|
||||||
|
if (apiPath.startsWith('/package')) return 'Package';
|
||||||
|
if (apiPath.startsWith('/stock')) return 'Stock';
|
||||||
|
if (apiPath.startsWith('/ledger')) return 'Ledger';
|
||||||
|
if (apiPath.startsWith('/warehouse')) return 'Warehouse';
|
||||||
|
if (apiPath.startsWith('/account')) return 'Account';
|
||||||
|
if (apiPath.startsWith('/payment')) return 'Payment';
|
||||||
|
if (apiPath.startsWith('/image') || apiPath.startsWith('/thumb') || apiPath.startsWith('/pic')) return 'Image';
|
||||||
|
if (apiPath.startsWith('/serper')) return 'Serper';
|
||||||
|
if (apiPath.startsWith('/vfs')) return 'Vfs';
|
||||||
|
if (apiPath.startsWith('/posterminal')) return 'POSTerminal';
|
||||||
|
if (apiPath.startsWith('/printer')) return 'Printer';
|
||||||
|
if (apiPath.startsWith('/labeltemplate')) return 'LabelTemplate';
|
||||||
|
if (apiPath.startsWith('/v1')) return 'POSSync';
|
||||||
|
if (apiPath.startsWith('/modelconversation')) return 'ModelConversation';
|
||||||
|
if (apiPath.startsWith('/mcp')) return 'MCP';
|
||||||
|
if (apiPath.startsWith('/events')) return 'Events';
|
||||||
|
if (apiPath.startsWith('/health')) return 'Health';
|
||||||
|
if (apiPath.startsWith('/telemetry')) return 'Telemetry';
|
||||||
|
|
||||||
|
return tag || 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceBasePath(resourceId) {
|
||||||
|
const map = {
|
||||||
|
Auth: '/auth',
|
||||||
|
Company: '/company',
|
||||||
|
CompanySetting: '/companysetting',
|
||||||
|
Customergroup: '/customergroup',
|
||||||
|
Number: '/number',
|
||||||
|
Entity: '/entity',
|
||||||
|
Article: '/article',
|
||||||
|
Category: '/category',
|
||||||
|
Brand: '/brand',
|
||||||
|
Unit: '/unit',
|
||||||
|
PublicCatalog: '/public',
|
||||||
|
Property: '/propertyname',
|
||||||
|
Attribute: '/attributename',
|
||||||
|
Supplier: '/supplier',
|
||||||
|
ArticleSupplier: '/article/{sku}/supplier',
|
||||||
|
Delivery: '/delivery',
|
||||||
|
Quote: '/quote',
|
||||||
|
Order: '/order',
|
||||||
|
Shipment: '/shipment',
|
||||||
|
Shipping: '/shipping',
|
||||||
|
Package: '/package',
|
||||||
|
Stock: '/stock',
|
||||||
|
Ledger: '/ledger',
|
||||||
|
Warehouse: '/warehouse',
|
||||||
|
Account: '/account',
|
||||||
|
Payment: '/payment',
|
||||||
|
Image: '/image',
|
||||||
|
Serper: '/serper',
|
||||||
|
Vfs: '/vfs',
|
||||||
|
POSTerminal: '/posterminal',
|
||||||
|
Printer: '/printer',
|
||||||
|
LabelTemplate: '/labeltemplate',
|
||||||
|
POSSync: '/v1',
|
||||||
|
ModelConversation: '/modelconversation',
|
||||||
|
MCP: '/mcp',
|
||||||
|
Events: '/events',
|
||||||
|
Health: '/health',
|
||||||
|
Telemetry: '/telemetry',
|
||||||
|
Legend: '',
|
||||||
|
};
|
||||||
|
return map[resourceId] ?? `/${resourceId.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSubpath(apiPath, basePath) {
|
||||||
|
if (apiPath === basePath) return '/';
|
||||||
|
if (apiPath.startsWith(basePath)) {
|
||||||
|
const sub = apiPath.slice(basePath.length);
|
||||||
|
return sub.startsWith('/') ? sub : `/${sub}`;
|
||||||
|
}
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str, max = 28) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePort(name) {
|
||||||
|
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApi(spec) {
|
||||||
|
const resources = new Map();
|
||||||
|
|
||||||
|
for (const [apiPath, pathItem] of Object.entries(spec.paths || {})) {
|
||||||
|
for (const [method, op] of Object.entries(pathItem || {})) {
|
||||||
|
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) continue;
|
||||||
|
if (!op || typeof op !== 'object') continue;
|
||||||
|
|
||||||
|
const methodUpper = method.toUpperCase();
|
||||||
|
const tag = Array.isArray(op.tags) && op.tags[0] ? op.tags[0] : '';
|
||||||
|
const resourceId = assignResource(apiPath, methodUpper, tag);
|
||||||
|
const basePath = getResourceBasePath(resourceId);
|
||||||
|
|
||||||
|
if (!resources.has(resourceId)) {
|
||||||
|
resources.set(resourceId, {
|
||||||
|
id: resourceId,
|
||||||
|
basePath,
|
||||||
|
endpoints: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = resources.get(resourceId);
|
||||||
|
const subpath = cleanSubpath(apiPath, basePath);
|
||||||
|
const summary = op.summary || op.description || '';
|
||||||
|
const portId = sanitizePort(`ep_${methodUpper.toLowerCase()}_${apiPath}`);
|
||||||
|
|
||||||
|
res.endpoints.push({
|
||||||
|
method: methodUpper,
|
||||||
|
path: apiPath,
|
||||||
|
subpath,
|
||||||
|
portId,
|
||||||
|
summary: truncate(summary, 28),
|
||||||
|
operation: op,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort endpoints within each resource: root subpath first, then by method rank, then path
|
||||||
|
const methodRank = { GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5 };
|
||||||
|
for (const res of resources.values()) {
|
||||||
|
res.endpoints.sort((a, b) => {
|
||||||
|
if (a.subpath === '/' && b.subpath !== '/') return -1;
|
||||||
|
if (b.subpath === '/' && a.subpath !== '/') return 1;
|
||||||
|
const rankDiff = (methodRank[a.method] ?? 99) - (methodRank[b.method] ?? 99);
|
||||||
|
if (rankDiff !== 0) return rankDiff;
|
||||||
|
return a.subpath.localeCompare(b.subpath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEndpointPort(res, method, pathSnippet) {
|
||||||
|
if (!res || !res.endpoints) return null;
|
||||||
|
const match = res.endpoints.find((ep) => {
|
||||||
|
if (method && ep.method !== method.toUpperCase()) return false;
|
||||||
|
if (pathSnippet) {
|
||||||
|
return ep.path.includes(pathSnippet) || ep.subpath.includes(pathSnippet);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return match?.portId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds high-fidelity relationships between API resources.
|
||||||
|
*/
|
||||||
|
function buildApiEdges(resources) {
|
||||||
|
const edgeDefs = [
|
||||||
|
// Sales & Fulfillment
|
||||||
|
{ from: 'Order', to: 'Entity', label: 'entityId', method: 'GET', path: '/order/{id}' },
|
||||||
|
{ from: 'Order', to: 'Article', label: 'lines.sku', method: 'POST', path: '/order' },
|
||||||
|
{ from: 'Order', to: 'Shipment', label: 'shipments', method: 'GET', path: '/order/{id}' },
|
||||||
|
{ from: 'Order', to: 'Payment', label: 'payments', method: 'GET', path: '/order/{id}' },
|
||||||
|
{ from: 'Order', to: 'Shipping', label: 'shippings', method: 'GET', path: '/order/{id}' },
|
||||||
|
{ from: 'Quote', to: 'Entity', label: 'entityId', method: 'GET', path: '/quote/{id}' },
|
||||||
|
{ from: 'Quote', to: 'Article', label: 'lines.sku', method: 'POST', path: '/quote' },
|
||||||
|
{ from: 'Quote', to: 'Order', label: 'convert order', method: 'POST', path: '/quote' },
|
||||||
|
{ from: 'Shipment', to: 'Order', label: 'manifestId', method: 'GET', path: '/shipment/{id}' },
|
||||||
|
{ from: 'Shipment', to: 'Article', label: 'lines.sku', method: 'GET', path: '/shipment/{id}' },
|
||||||
|
{ from: 'Shipment', to: 'Shipping', label: 'shippings', method: 'GET', path: '/shipment/{id}/shipping' },
|
||||||
|
{ from: 'Shipping', to: 'Package', label: 'packages', method: 'GET', path: '/shipping/{id}' },
|
||||||
|
{ from: 'Package', to: 'Shipping', label: 'shippingId', method: 'POST', path: '/package' },
|
||||||
|
{ from: 'Package', to: 'Entity', label: 'addressId', method: 'POST', path: '/package' },
|
||||||
|
|
||||||
|
// Purchasing & Goods receipt
|
||||||
|
{ from: 'Delivery', to: 'Supplier', label: 'supplierId', method: 'POST', path: '/delivery' },
|
||||||
|
{ from: 'Delivery', to: 'Article', label: 'lines.sku', method: 'POST', path: '/delivery' },
|
||||||
|
{ from: 'Delivery', to: 'Ledger', label: 'ledger', method: 'GET', path: '/delivery/{id}' },
|
||||||
|
{ from: 'ArticleSupplier', to: 'Supplier', label: 'entityId', method: 'POST', path: '/supplier' },
|
||||||
|
{ from: 'ArticleSupplier', to: 'Article', label: 'sku', method: 'GET', path: '/supplier' },
|
||||||
|
{ from: 'Supplier', to: 'Entity', label: 'entityId', method: 'GET', path: '/supplier/{id}' },
|
||||||
|
|
||||||
|
// Inventory & Warehousing
|
||||||
|
{ from: 'Stock', to: 'Article', label: 'sku', method: 'POST', path: '/stock' },
|
||||||
|
{ from: 'Stock', to: 'Warehouse', label: 'warehouseId', method: 'POST', path: '/stock' },
|
||||||
|
{ from: 'Stock', to: 'Ledger', label: 'ledger', method: 'POST', path: '/stock' },
|
||||||
|
{ from: 'Ledger', to: 'Article', label: 'sku', method: 'GET', path: '/ledger' },
|
||||||
|
{ from: 'Ledger', to: 'Warehouse', label: 'warehouseId', method: 'GET', path: '/ledger' },
|
||||||
|
|
||||||
|
// Catalog & Products
|
||||||
|
{ from: 'Article', to: 'Category', label: 'categoryId', method: 'POST', path: '/article' },
|
||||||
|
{ from: 'Article', to: 'Brand', label: 'brand', method: 'PUT', path: '/brand' },
|
||||||
|
{ from: 'Article', to: 'Unit', label: 'unitSi', method: 'PATCH', path: '/article/{sku}' },
|
||||||
|
{ from: 'Article', to: 'ArticleSupplier', label: 'suppliers', method: 'GET', path: '/article/{sku}' },
|
||||||
|
{ from: 'Article', to: 'Image', label: 'photos', method: 'POST', path: '/image' },
|
||||||
|
{ from: 'Article', to: 'Customergroup', label: 'prices', method: 'PUT', path: '/prices' },
|
||||||
|
{ from: 'Article', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||||||
|
{ from: 'Article', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||||||
|
{ from: 'Article', to: 'Article', label: 'bundles & variants', method: 'GET', path: '/children' },
|
||||||
|
|
||||||
|
{ from: 'Category', to: 'Category', label: 'parentId', method: 'GET', path: '/category' },
|
||||||
|
{ from: 'Category', to: 'Article', label: 'articles', method: 'GET', path: '/category/{id}/article' },
|
||||||
|
{ from: 'Category', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||||
|
{ from: 'Category', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||||||
|
{ from: 'Category', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||||||
|
|
||||||
|
{ from: 'Brand', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||||
|
|
||||||
|
// Properties & Attributes
|
||||||
|
{ from: 'Property', to: 'Company', label: 'companyId', method: 'GET', path: '/propertyname' },
|
||||||
|
{ from: 'Attribute', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||||
|
|
||||||
|
// Finance & Payments
|
||||||
|
{ from: 'Payment', to: 'Account', label: 'accountId', method: 'POST', path: '/payment' },
|
||||||
|
{ from: 'Payment', to: 'Order', label: 'manifestId', method: 'POST', path: '/payment' },
|
||||||
|
{ from: 'Account', to: 'Company', label: 'companyId', method: 'GET', path: '/account' },
|
||||||
|
|
||||||
|
// CRM & Company
|
||||||
|
{ from: 'Entity', to: 'Customergroup', label: 'customergroupId', method: 'POST', path: '/entity' },
|
||||||
|
{ from: 'Company', to: 'Customergroup', label: 'poscustomergroup', method: 'GET', path: '/company/{id}' },
|
||||||
|
{ from: 'Company', to: 'Category', label: 'root_category', method: 'GET', path: '/company/{id}' },
|
||||||
|
{ from: 'Company', to: 'CompanySetting', label: 'settings', method: 'GET', path: '/company/{id}' },
|
||||||
|
{ from: 'Number', to: 'Company', label: 'companyId', method: 'GET', path: '/number' },
|
||||||
|
|
||||||
|
// POS & Hardware
|
||||||
|
{ from: 'POSTerminal', to: 'Company', label: 'companyId', method: 'GET', path: '/posterminal' },
|
||||||
|
{ from: 'POSTerminal', to: 'Category', label: 'root_category', method: 'GET', path: '/posterminal' },
|
||||||
|
{ from: 'Printer', to: 'Company', label: 'companyId', method: 'GET', path: '/printer' },
|
||||||
|
{ from: 'Printer', to: 'LabelTemplate', label: 'templates', method: 'GET', path: '/printer' },
|
||||||
|
{ from: 'LabelTemplate', to: 'Company', label: 'companyId', method: 'GET', path: '/labeltemplate' },
|
||||||
|
{ from: 'POSSync', to: 'POSTerminal', label: 'client bind', method: 'POST', path: '/client' },
|
||||||
|
{ from: 'POSSync', to: 'Article', label: 'product sync', method: 'GET', path: '/product' },
|
||||||
|
{ from: 'POSSync', to: 'Category', label: 'category sync', method: 'GET', path: '/category' },
|
||||||
|
|
||||||
|
// AI, Tools & Public
|
||||||
|
{ from: 'PublicCatalog', to: 'Article', label: 'articles', method: 'GET', path: '/product' },
|
||||||
|
{ from: 'PublicCatalog', to: 'Category', label: 'category', method: 'GET', path: '/category' },
|
||||||
|
{ from: 'ModelConversation', to: 'Vfs', label: 'vfs workspace', method: 'GET', path: '/modelconversation' },
|
||||||
|
{ from: 'MCP', to: 'Article', label: 'article tools', method: 'POST', path: '/mcp' },
|
||||||
|
{ from: 'MCP', to: 'Order', label: 'order tools', method: 'POST', path: '/mcp' },
|
||||||
|
{ from: 'Events', to: 'Stock', label: 'stock sse', method: 'GET', path: '/events' },
|
||||||
|
{ from: 'Events', to: 'Ledger', label: 'ledger sse', method: 'GET', path: '/events' },
|
||||||
|
{ from: 'Events', to: 'Article', label: 'article sse', method: 'GET', path: '/events' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [];
|
||||||
|
for (const def of edgeDefs) {
|
||||||
|
if (!resources.has(def.from) || !resources.has(def.to)) continue;
|
||||||
|
const resFrom = resources.get(def.from);
|
||||||
|
const resolvedPort = findEndpointPort(resFrom, def.method, def.path);
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
from: def.from,
|
||||||
|
to: def.to,
|
||||||
|
label: def.label,
|
||||||
|
port: resolvedPort,
|
||||||
|
tooltip: `${def.from} → ${def.to} (${def.label})`,
|
||||||
|
inferred: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeKey(edge) {
|
||||||
|
return `${edge.from}|${edge.to}|${edge.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignEdgeColors(edges) {
|
||||||
|
const colors = new Map();
|
||||||
|
let next = 0;
|
||||||
|
for (const edge of edges) {
|
||||||
|
const key = edgeKey(edge);
|
||||||
|
if (!colors.has(key)) {
|
||||||
|
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spanning ranking forest: only one primary outgoing edge per resource
|
||||||
|
* is used for rank constraints. Other edges get constraint=false to avoid
|
||||||
|
* vertical over-stretching.
|
||||||
|
*/
|
||||||
|
function selectRankingEdgeKeys(edges) {
|
||||||
|
const ranking = new Set();
|
||||||
|
const bySource = new Map();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (edge.from === edge.to) continue;
|
||||||
|
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||||
|
bySource.get(edge.from).push(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of bySource.values()) {
|
||||||
|
group.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
ranking.add(edgeKey(group[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranking;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegendLabel() {
|
||||||
|
const methodRow = (method, desc) => {
|
||||||
|
const c = METHOD_COLORS[method];
|
||||||
|
return (
|
||||||
|
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="38">` +
|
||||||
|
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${method}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const row = (swatch, text) =>
|
||||||
|
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
|
||||||
|
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
|
||||||
|
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
|
||||||
|
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>API Graph Legend</B></FONT></TD></TR>` +
|
||||||
|
methodRow('GET', 'Safe retrieval / query') +
|
||||||
|
methodRow('POST', 'Create resource / action') +
|
||||||
|
methodRow('PUT', 'Full replace / collection update') +
|
||||||
|
methodRow('PATCH', 'Partial entity update') +
|
||||||
|
methodRow('DELETE', 'Remove resource') +
|
||||||
|
row(
|
||||||
|
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||||||
|
'Inbound arrival port on resource'
|
||||||
|
) +
|
||||||
|
row(
|
||||||
|
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||||||
|
'Relational reference / dataflow'
|
||||||
|
) +
|
||||||
|
`</TABLE>>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResourceNode(res, inboundCount = 0) {
|
||||||
|
if (res.id === 'Legend') {
|
||||||
|
return buildLegendLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleFont =
|
||||||
|
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(res.id)}</B></FONT>` +
|
||||||
|
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(res.basePath)}</FONT>`;
|
||||||
|
|
||||||
|
let headerInner;
|
||||||
|
if (inboundCount > 0) {
|
||||||
|
const portCells = [];
|
||||||
|
for (let i = 0; i < inboundCount; i++) {
|
||||||
|
portCells.push(
|
||||||
|
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headerInner =
|
||||||
|
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||||
|
`<TR>${portCells.join('')}</TR>` +
|
||||||
|
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
|
||||||
|
`</TABLE>`;
|
||||||
|
} else {
|
||||||
|
headerInner = titleFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
|
||||||
|
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [i, ep] of res.endpoints.entries()) {
|
||||||
|
const mColor = METHOD_COLORS[ep.method] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||||||
|
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
`<TR><TD PORT="${escapeHtml(ep.portId)}" COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||||||
|
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||||
|
`<TR>` +
|
||||||
|
`<TD BGCOLOR="${mColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="38">` +
|
||||||
|
`<FONT COLOR="${mColor.fg}" POINT-SIZE="9"><B>${escapeHtml(ep.method)}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||||||
|
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(ep.subpath)}</B></FONT></TD>` +
|
||||||
|
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||||||
|
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(ep.summary)}</FONT></TD>` +
|
||||||
|
`</TR></TABLE></TD></TR>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
|
||||||
|
rows.join('') +
|
||||||
|
`</TABLE>>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignEdgeAnchors(edges, positions) {
|
||||||
|
const headPort = new Map();
|
||||||
|
const tailCompass = new Map();
|
||||||
|
const inboundCounts = new Map();
|
||||||
|
|
||||||
|
const byTarget = new Map();
|
||||||
|
const bySource = new Map();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
|
||||||
|
byTarget.get(edge.to).push(edge);
|
||||||
|
|
||||||
|
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||||
|
bySource.get(edge.from).push(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [target, group] of byTarget) {
|
||||||
|
group.sort((a, b) => {
|
||||||
|
const ax = positions?.get(a.from)?.x ?? 0;
|
||||||
|
const bx = positions?.get(b.from)?.x ?? 0;
|
||||||
|
if (ax !== bx) return ax - bx;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
inboundCounts.set(target, group.length);
|
||||||
|
group.forEach((edge, i) => {
|
||||||
|
headPort.set(edgeKey(edge), `in${i}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of bySource.values()) {
|
||||||
|
group.forEach((edge, i) => {
|
||||||
|
if (edge.from === edge.to) {
|
||||||
|
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = positions?.get(edge.from);
|
||||||
|
const to = positions?.get(edge.to);
|
||||||
|
const fromX = from?.x ?? 0;
|
||||||
|
const toX = to?.x ?? fromX;
|
||||||
|
const goEast = toX >= fromX;
|
||||||
|
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headPort, tailCompass, inboundCounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDot(resources, edges, positions, anchors) {
|
||||||
|
const edgeColors = assignEdgeColors(edges);
|
||||||
|
const rankingKeys = selectRankingEdgeKeys(edges);
|
||||||
|
const { headPort, tailCompass, inboundCounts } =
|
||||||
|
anchors ?? assignEdgeAnchors(edges, positions);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'digraph api {',
|
||||||
|
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
|
||||||
|
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||||||
|
' compound=true, mclimit=1.0];',
|
||||||
|
' node [shape=plaintext, fontname="Helvetica"];',
|
||||||
|
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
const clusteredResources = new Set();
|
||||||
|
|
||||||
|
for (const cluster of CLUSTERS) {
|
||||||
|
const presentInCluster = cluster.resources.filter((id) => resources.has(id));
|
||||||
|
if (presentInCluster.length === 0) continue;
|
||||||
|
|
||||||
|
lines.push(` subgraph cluster_${cluster.id} {`);
|
||||||
|
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
|
||||||
|
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
|
||||||
|
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
|
||||||
|
|
||||||
|
for (const resId of presentInCluster) {
|
||||||
|
clusteredResources.add(resId);
|
||||||
|
const res = resources.get(resId);
|
||||||
|
const inCount = inboundCounts.get(resId) ?? 0;
|
||||||
|
const label = renderResourceNode(res, inCount);
|
||||||
|
lines.push(` ${resId} [label=${label}];`);
|
||||||
|
}
|
||||||
|
lines.push(' }');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any unclustered resources
|
||||||
|
for (const [resId, res] of resources) {
|
||||||
|
if (clusteredResources.has(resId)) continue;
|
||||||
|
const inCount = inboundCounts.get(resId) ?? 0;
|
||||||
|
const label = renderResourceNode(res, inCount);
|
||||||
|
lines.push(` ${resId} [label=${label}];`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const key = edgeKey(edge);
|
||||||
|
const color = edgeColors.get(key) ?? '#5aa7ff';
|
||||||
|
const isRanking = rankingKeys.has(key);
|
||||||
|
const hp = headPort.get(key) ?? 'in0';
|
||||||
|
const tc = tailCompass.get(key) ?? 'e';
|
||||||
|
|
||||||
|
const attrs = [
|
||||||
|
`color="${color}"`,
|
||||||
|
`fontcolor="${color}"`,
|
||||||
|
`tooltip="${escapeHtml(edge.tooltip)}"`,
|
||||||
|
`label=" ${escapeHtml(edge.label)} "`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isRanking && edge.from !== edge.to) {
|
||||||
|
attrs.push('constraint=false', 'weight=1');
|
||||||
|
} else {
|
||||||
|
attrs.push('weight=3');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source port from endpoint if available, else exit compass
|
||||||
|
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
|
||||||
|
const toPort = `:${hp}:n`;
|
||||||
|
|
||||||
|
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlainPositions(plain) {
|
||||||
|
const positions = new Map();
|
||||||
|
for (const line of plain.split('\n')) {
|
||||||
|
if (!line.startsWith('node ')) continue;
|
||||||
|
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
||||||
|
if (!match) continue;
|
||||||
|
positions.set(match[1], {
|
||||||
|
x: Number(match[2]),
|
||||||
|
y: Number(match[3]),
|
||||||
|
width: Number(match[4]),
|
||||||
|
height: Number(match[5]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixSelfLoopSvg(svg) {
|
||||||
|
return svg.replace(
|
||||||
|
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
|
||||||
|
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
|
||||||
|
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
|
||||||
|
if (pts.length < 2) return match;
|
||||||
|
|
||||||
|
const [x0, y0] = pts[0];
|
||||||
|
const [x1, y1] = pts[pts.length - 1];
|
||||||
|
const east = Math.max(x0, x1) + 36;
|
||||||
|
const north = Math.min(y0, y1) - 28;
|
||||||
|
|
||||||
|
const tx = x1 - east;
|
||||||
|
const ty = y1 - north;
|
||||||
|
const len = Math.hypot(tx, ty) || 1;
|
||||||
|
const dx = tx / len;
|
||||||
|
const dy = ty / len;
|
||||||
|
const arrowLen = 9;
|
||||||
|
const arrowHalf = 3.5;
|
||||||
|
const baseX = x1 - dx * arrowLen;
|
||||||
|
const baseY = y1 - dy * arrowLen;
|
||||||
|
const px = -dy * arrowHalf;
|
||||||
|
const py = dx * arrowHalf;
|
||||||
|
|
||||||
|
const fmt = (n) => (Math.round(n * 100) / 100).toString();
|
||||||
|
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
|
||||||
|
const newPointsFmt =
|
||||||
|
`${fmt(x1)},${fmt(y1)} ` +
|
||||||
|
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
|
||||||
|
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
|
||||||
|
`${fmt(x1)},${fmt(y1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<g id="${edgeId}" class="edge">\n` +
|
||||||
|
`<title>${_table}->${_table}</title>\n` +
|
||||||
|
`<g id="${aId}"><a ${aAttrs}>\n` +
|
||||||
|
`<path${pathAttrs} d="${newDFmt}"/>\n` +
|
||||||
|
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
|
||||||
|
`</a>\n` +
|
||||||
|
`</g>\n` +
|
||||||
|
`</g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generating Dash API Graph…');
|
||||||
|
const spec = getOpenApiSpec(true);
|
||||||
|
const resources = parseApi(spec);
|
||||||
|
|
||||||
|
// Add Legend pseudo-resource
|
||||||
|
resources.set('Legend', {
|
||||||
|
id: 'Legend',
|
||||||
|
basePath: '',
|
||||||
|
endpoints: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = buildApiEdges(resources);
|
||||||
|
|
||||||
|
console.log(`Parsed ${resources.size - 1} API resources and ${edges.length} relationships`);
|
||||||
|
|
||||||
|
const graphviz = await Graphviz.load();
|
||||||
|
|
||||||
|
// Pass 1: Initial layout to learn coordinates
|
||||||
|
console.log('Layout pass 1/2: computing node positions…');
|
||||||
|
const initialDot = buildDot(resources, edges, null, null);
|
||||||
|
const plain = graphviz.layout(initialDot, 'plain', 'dot');
|
||||||
|
const positions = parsePlainPositions(plain);
|
||||||
|
|
||||||
|
// Pass 2: Anchor assignment using learned positions
|
||||||
|
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
|
||||||
|
const anchors = assignEdgeAnchors(edges, positions);
|
||||||
|
const finalDot = buildDot(resources, edges, positions, anchors);
|
||||||
|
|
||||||
|
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
|
||||||
|
const fixedSvg = fixSelfLoopSvg(rawSvg);
|
||||||
|
|
||||||
|
// Ensure background color is set on svg tag
|
||||||
|
const finalSvg = fixedSvg.replace(
|
||||||
|
/<svg([^>]*?)>/,
|
||||||
|
`<svg$1 style="background-color:${COLORS.background};">`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(dotOutputPath, finalDot, 'utf8');
|
||||||
|
writeFileSync(outputPath, finalSvg, 'utf8');
|
||||||
|
|
||||||
|
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||||||
|
console.log(`Written ${dotOutputPath}`);
|
||||||
|
console.log(`Written ${outputPath}`);
|
||||||
|
if (dim) {
|
||||||
|
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
|
||||||
|
}
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||||
"acorn": "^8.19.0",
|
"acorn": "^8.19.0",
|
||||||
"swagger-jsdoc": "^6.3.0"
|
"swagger-jsdoc": "^6.3.0"
|
||||||
}
|
}
|
||||||
@@ -212,6 +213,13 @@
|
|||||||
"hono": "^4 || ^5.0.0-0"
|
"hono": "^4 || ^5.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hpcc-js/wasm-graphviz": {
|
||||||
|
"version": "1.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.29.2.tgz",
|
||||||
|
"integrity": "sha512-AkArBAZERT5OxH52/+kvCHB07cZ6ZNd9GnOvjw+S880bR88D6i27XfK6xj4dBhUDxNA2QPPMdgEcky3x4TJ3KA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"manage-users": "node scripts/manageUsers.js",
|
"manage-users": "node scripts/manageUsers.js",
|
||||||
"wipe-company": "node scripts/wipeCompany.js",
|
"wipe-company": "node scripts/wipeCompany.js",
|
||||||
"docs:api": "node scripts/generateApiDocs.js",
|
"docs:api": "node scripts/generateApiDocs.js",
|
||||||
|
"api-graph": "node generate-api-graph.js",
|
||||||
"review:functions": "node scripts/extractFunctions.js",
|
"review:functions": "node scripts/extractFunctions.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||||
"acorn": "^8.19.0",
|
"acorn": "^8.19.0",
|
||||||
"swagger-jsdoc": "^6.3.0"
|
"swagger-jsdoc": "^6.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ function logField(label, value, { skipEmpty = false } = {}) {
|
|||||||
httpLog.debug(prefix + '\n' + rendered.replace(/^/gm, ' '));
|
httpLog.debug(prefix + '\n' + rendered.replace(/^/gm, ' '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logResponse(value) {
|
||||||
|
const rendered = prettyJson(value).replace(/\r?\n/g, '');
|
||||||
|
const prefix = chalk.gray(' response: ');
|
||||||
|
if (rendered.length <= 100) {
|
||||||
|
httpLog.debug(prefix + rendered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cut = rendered.length - 100;
|
||||||
|
httpLog.debug(prefix + rendered.slice(0, 100) + '\u001b[0m' + chalk.gray(` … [${cut} chars cut]`));
|
||||||
|
}
|
||||||
|
|
||||||
function captureResponseBody(res) {
|
function captureResponseBody(res) {
|
||||||
let body;
|
let body;
|
||||||
const originalJson = res.json.bind(res);
|
const originalJson = res.json.bind(res);
|
||||||
@@ -76,7 +87,7 @@ export const loggerMiddleware = (req, res, next) => {
|
|||||||
httpLog.debug(chalk.gray(' authorization: ') + chalk.cyan(req.headers.authorization));
|
httpLog.debug(chalk.gray(' authorization: ') + chalk.cyan(req.headers.authorization));
|
||||||
}
|
}
|
||||||
logField('body', req.body ?? {}, { skipEmpty: true });
|
logField('body', req.body ?? {}, { skipEmpty: true });
|
||||||
logField('response', getBody());
|
logResponse(getBody());
|
||||||
|
|
||||||
// Dump full request/response/error details to a log file on 5xx.
|
// Dump full request/response/error details to a log file on 5xx.
|
||||||
const statusCode = res.statusCode;
|
const statusCode = res.statusCode;
|
||||||
|
|||||||
@@ -84,15 +84,39 @@ async function enrichSingleOrder(store, googleAuthId, order) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEnrichedFromSummary(order) {
|
||||||
|
const grossTotal = Number(order.grossTotal || 0);
|
||||||
|
const paidTotal = Number(order.paidTotal || 0);
|
||||||
|
const orderedQuantity = Number(order.orderedQuantity || 0);
|
||||||
|
const shippedQuantity = Number(order.shippedQuantity || 0);
|
||||||
|
const isPaid = Boolean(order.isPaid);
|
||||||
|
const isShipped = Boolean(order.isShipped);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
shipping: null,
|
||||||
|
shippings: [],
|
||||||
|
payment: null,
|
||||||
|
payments: [],
|
||||||
|
shipments: [],
|
||||||
|
orderTotal: grossTotal,
|
||||||
|
paidTotal,
|
||||||
|
orderedQuantity,
|
||||||
|
shippedQuantity,
|
||||||
|
paid: isPaid,
|
||||||
|
shipped: isShipped,
|
||||||
|
delivered: isShipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function enrichOrderResult(store, googleAuthId, result) {
|
async function enrichOrderResult(store, googleAuthId, result) {
|
||||||
if (!result) return result;
|
if (!result) return result;
|
||||||
|
const enrich = (o) => (o?.grossTotal !== undefined ? formatEnrichedFromSummary(o) : enrichSingleOrder(store, googleAuthId, o));
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
return Promise.all(result.map((o) => enrichSingleOrder(store, googleAuthId, o)));
|
return Promise.all(result.map(enrich));
|
||||||
}
|
}
|
||||||
if (Array.isArray(result.orders)) {
|
if (Array.isArray(result.orders)) {
|
||||||
const enrichedOrders = await Promise.all(
|
const enrichedOrders = await Promise.all(result.orders.map(enrich));
|
||||||
result.orders.map((o) => enrichSingleOrder(store, googleAuthId, o))
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
orders: enrichedOrders,
|
orders: enrichedOrders,
|
||||||
|
|||||||
Reference in New Issue
Block a user