This commit is contained in:
seb
2026-10-13 13:21:14 +02:00
parent 8acca998bc
commit 2de2bc0d90
9 changed files with 3516 additions and 6 deletions

View File

@@ -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
``` ```

View File

@@ -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

File diff suppressed because one or more lines are too long

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
View 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 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">&#9642;&#8212;&#9654;</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+)?&#45;&gt;\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}&#45;&gt;${_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
View File

@@ -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",

View File

@@ -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"
}, },

View File

@@ -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;

View File

@@ -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,