From 2de2bc0d907805fd5f4c60e52bfdbd112737d17e Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 13 Oct 2026 13:21:14 +0200 Subject: [PATCH] u --- AGENTS.md | 1 + README.md | 3 +- api-graph.dot | 173 +++ api-graph.svg | 2509 ++++++++++++++++++++++++++++++++++ generate-api-graph.js | 781 +++++++++++ package-lock.json | 8 + package.json | 2 + src/api/middleware/logger.js | 13 +- src/db/order.js | 32 +- 9 files changed, 3516 insertions(+), 6 deletions(-) create mode 100644 api-graph.dot create mode 100644 api-graph.svg create mode 100644 generate-api-graph.js diff --git a/AGENTS.md b/AGENTS.md index f88ca84..3cf10d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da cp .env.example .env && npm install 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 api-graph # regenerates api-graph.svg + api-graph.dot npm run manage-users # email allowlist ``` diff --git a/README.md b/README.md index 7b41fbd..3da1443 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ Docs are generated from `@openapi` JSDoc on route handlers: ```bash 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 diff --git a/api-graph.dot b/api-graph.dot new file mode 100644 index 0000000..fc9c014 --- /dev/null +++ b/api-graph.dot @@ -0,0 +1,173 @@ +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]; + + subgraph cluster_identity { + label="Identity & Access"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Auth [label=<
Auth
/auth
GET/.well-known/oauth-authorization-serverOAuth 2.0 Authorization Ser…
GET/.well-known/oauth-protected-resourceOAuth 2.0 Protected Resourc…
GET/meCurrent user
POST/loginLogin with Google ID token
POST/logoutLogout
PUT/selected-companySet selected company
>]; + Company [label=<
Company
/company
GET/List companies
POST/Create company
GET/historyGet company history
POST/historyAdd company history entry
POST/importImport company catalog from…
PUT/{id}Update company
DELETE/{id}Delete company
>]; + CompanySetting [label=<
CompanySetting
/companysetting
GET/company/settingList company settings
GET/company/setting/{key}Get company setting by key
PUT/company/setting/{key}Upsert company setting
>]; + Customergroup [label=<
Customergroup
/customergroup
GET/List customer groups
POST/Create customer group
PUT/orderReorder customer groups
PATCH/{id}Update customer group
DELETE/{id}Delete customer group
>]; + Number [label=<
Number
/number
GET/List number sequences
PATCH/{id}Update number sequence
>]; + } + subgraph cluster_crm { + label="Entities & CRM"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Entity [label=<
Entity
/entity
GET/List entities
POST/Create entity
GET/{entityId}/addressList entity addresses
GET/{entityId}/contactList entity contacts
GET/{id}Get entity
GET/next-numberPeek next customer number
POST/{entityId}/addressCreate entity address
POST/{entityId}/contactCreate entity contact
PATCH/{entityId}/address/{id}Update entity address
PATCH/{entityId}/contact/{id}Update entity contact
PATCH/{id}Update entity
DELETE/{entityId}/address/{id}Delete entity address
DELETE/{entityId}/contact/{id}Delete entity contact
DELETE/{id}Delete entity
>]; + } + subgraph cluster_catalog { + label="Catalog & Products"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Article [label=<
Article
/article
POST/Create article in category
GET/{sku}Get article by SKU
GET/{sku}/articlelistGet Stückliste (bundle item…
GET/{sku}/childrenList child articles of a pa…
GET/{sku}/historyGet article history by SKU
GET/{sku}/optionslistGet options list for an art…
GET/export.zipExport articles as ZIP
POST/{sku}/childrenCreate and attach a new chi…
POST/{sku}/imageAdd article image
POST/{sku}/parentToggle parent mode for an a…
POST/{sku}/printPrint article label
PUT/{sku}/articlelistSet Stückliste (bundle item…
PUT/{sku}/brandSet or clear article brand …
PUT/{sku}/categoriesReplace article categories …
PUT/{sku}/childrenAttach an existing article …
PUT/{sku}/eansReplace article EANs by SKU
PUT/{sku}/images/orderReorder article images
PUT/{sku}/optionslistSet options list for an art…
PUT/{sku}/pricesReplace POS prices by SKU (…
PUT/{sku}/track_stockSet article track_stock fla…
PUT/{sku}/weightSet article weight by SKU
PATCH/{sku}Update article by SKU
DELETE/{sku}Delete article by SKU
DELETE/{sku}/children/{childSku}Remove a child article from…
DELETE/{sku}/image/{fullHash}Delete article image
>]; + Category [label=<
Category
/category
POST/Create category
GET/{id}/articlesArticles in category
GET/export.zipExport categories as ZIP
GET/treeCategory tree
POST/{id}/imageAdd category image
PUT/orderReorder sibling categories
PATCH/{id}Update category (rename or …
DELETE/{id}Delete category
DELETE/{id}/imageDelete category image
>]; + Brand [label=<
Brand
/brand
GET/List brands
POST/Create brand
POST/{id}/imageAdd or replace brand image
PATCH/{id}Rename brand
DELETE/{id}Delete brand
DELETE/{id}/imageDelete brand image
>]; + Unit [label=<
Unit
/unit
GET/List units
PUT/Upsert unit
DELETE/Delete unit
>]; + PublicCatalog [label=<
PublicCatalog
/public
GET/{companyId}/articlesList public articles
GET/{companyId}/articles/{sku}Get public article detail
GET/{companyId}/categoriesList public categories
POST/telemetry/crashReceipt crash dump
>]; + } + subgraph cluster_properties { + label="Properties & Attributes"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Property [label=<
Property
/propertyname
GET/List property names
POST/Create property name
GET/article/{sku}/propertyList article properties
GET/category/{id}/propertyList category properties
PUT/article/{sku}/propertyBulk set article properties
PUT/category/{id}/propertyBulk set category properties
PUT/orderReorder property names
PATCH/{id}Update property name
DELETE/{id}Delete property name
DELETE/article/{sku}/property/{propertyId}Delete article property
DELETE/category/{id}/property/{propertyId}Delete category property
>]; + Attribute [label=<
Attribute
/attributename
GET/List attribute names
POST/Create attribute name
GET/{attributeId}/valueList values for an attribute
GET/article/{sku}/attributevalueList article attribute valu…
GET/category/{id}/attributevalueList category attribute val…
POST/{attributeId}/valueCreate value for an attribu…
POST/attributevalue/{id}/imageAdd or replace attribute va…
PUT/{attributeId}/value/orderReorder values of an attrib…
PUT/article/{sku}/attributevalueBulk set article attribute …
PUT/category/{id}/attributevalueBulk set category attribute…
PUT/orderReorder attribute names
PATCH/{id}Update attribute name
PATCH/attributevalue/{id}Update attribute value
DELETE/{id}Delete attribute name
DELETE/attributevalue/{id}Delete attribute value
DELETE/attributevalue/{id}/imageDelete attribute value image
>]; + } + subgraph cluster_purchasing { + label="Suppliers & Purchasing"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Supplier [label=<
Supplier
/supplier
GET/List suppliers
POST/Create supplier
GET/{id}/articlesList supplier articles by S…
POST/{id}/articlesQuery supplier articles by …
PATCH/{id}Update supplier
DELETE/{id}Delete supplier
>]; + ArticleSupplier [label=<
ArticleSupplier
/article/{sku}/supplier
GET/List article suppliers
POST/Assign supplier to article
PUT/orderReorder article suppliers
PATCH/{id}Update article supplier ass…
DELETE/{id}Remove supplier assignment …
>]; + Delivery [label=<
Delivery
/delivery
GET/List deliveries
POST/Create delivery
GET/{id}Get delivery
GET/{id}/file/{fileId}Get delivery attachment fil…
POST/parseParse document (SSE)
PATCH/{id}Update delivery
DELETE/{id}Delete delivery
>]; + } + subgraph cluster_sales { + label="Sales & Fulfillment"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Quote [label=<
Quote
/quote
GET/List quotes
POST/Create quote
GET/{id}Get quote
PATCH/{id}Update quote
DELETE/{id}Delete quote
>]; + Order [label=<
Order
/order
GET/List orders
POST/Create order
GET/{id}Get order
GET/{id}/paymentList payments of an order
GET/{id}/shipmentList shipments of an order
GET/{id}/shippingList shippings of an order
POST/{id}/paymentAdd payment to an order
POST/{id}/shipmentCreate shipment for an order
POST/{id}/shippingCreate shipping for an order
PATCH/{id}Update order
DELETE/{id}Delete order
>]; + Shipment [label=<
Shipment
/shipment
GET/{id}Get shipment by ID
GET/{id}/shippingList packages of a shipment
POST/{id}/shippingCreate package for a shipme…
DELETE/{id}Delete shipment
>]; + Shipping [label=<
Shipping
/shipping
GET/{id}/packageList packages of a shipping
POST/{id}/packageCreate package for a shippi…
PATCH/{id}Update shipping
DELETE/{id}Delete shipping
>]; + Package [label=<
Package
/package
DELETE/{id}Delete package
>]; + } + subgraph cluster_inventory { + label="Inventory & Warehousing"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Stock [label=<
Stock
/stock
POST/adjustAdjust stock manually
>]; + Ledger [label=<
Ledger
/ledger
GET/Query ledger by manifest ar…
POST/Add ledger entry
GET/manifestarticle/{manifestarticleId}Query ledger by manifest ar…
GET/sku/{sku}Query ledger by article SKU
>]; + Warehouse [label=<
Warehouse
/warehouse
GET/List warehouses
POST/Create warehouse
GET/{id}Get warehouse by ID
PUT/{id}Update warehouse
PUT/orderReorder warehouses
PATCH/{id}Update warehouse (partial)
DELETE/{id}Delete warehouse
>]; + } + subgraph cluster_finance { + label="Finance & Payments"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Account [label=<
Account
/account
GET/List accounts
POST/Create account
DELETE/{id}Delete account
>]; + Payment [label=<
Payment
/payment
DELETE/{id}Delete payment
>]; + } + subgraph cluster_media { + label="Media & Storage"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Image [label=<
Image
/image
GET/{fullHash}Full image by hash
GET/smallthumb/{fullHash}Small thumb image by hash
GET/thumb/{fullHash}Thumb image by hash
>]; + Serper [label=<
Serper
/serper
POST/imageCrawl image from URL, crop …
POST/imagesSearch images via Serper.de…
>]; + Vfs [label=<
Vfs
/vfs
GET/List virtual filesystem fil…
GET/{*path}Get a virtual filesystem fi…
POST/{*path}Execute a virtual filesyste…
POST/mkdirCreate directory in virtual…
POST/renameRename or move a virtual fi…
POST/uploadUpload files to virtual fil…
PUT/{*path}Create or overwrite a virtu…
DELETE/{*path}Delete a virtual filesystem…
>]; + } + subgraph cluster_pos { + label="POS & Hardware"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + POSTerminal [label=<
POSTerminal
/posterminal
GET/List POS terminals
PUT/Create POS terminal
PUT/{id}/allEnable all categories on te…
PUT/{id}/category/{categoryId}Enable category on terminal
PATCH/{id}Update POS terminal
DELETE/{id}Delete POS terminal
DELETE/{id}/allDisable all categories on t…
DELETE/{id}/category/{categoryId}Disable category on terminal
>]; + Printer [label=<
Printer
/printer
GET/List printers
POST/Create printer
GET/{id}Get printer by ID
PUT/{id}Update printer
PUT/orderReorder printers
PATCH/{id}Update printer (partial)
DELETE/{id}Delete printer
>]; + LabelTemplate [label=<
LabelTemplate
/labeltemplate
GET/List label templates
POST/Create label template
GET/{id}Get label template by ID
PUT/{id}Update label template
PATCH/{id}Update label template (part…
DELETE/{id}Delete label template
>]; + POSSync [label=<
POSSync
/v1
GET/categoryJTL-POS category synchroniz…
GET/clientPOS discovery and pairing
GET/initPOS sync initialization cou…
GET/pimagePOS product image delivery
GET/productJTL-POS product catalog syn…
>]; + } + subgraph cluster_ai_mcp { + label="AI & Integrations"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + ModelConversation [label=<
ModelConversation
/modelconversation
GET/List conversations
POST/Create conversation
GET/{id}Get conversation
GET/{id}/messageList conversation messages
GET/{id}/streamStream conversation events …
POST/{id}/messagePost a user message
DELETE/{id}Delete conversation
>]; + MCP [label=<
MCP
/mcp
POST/Model Context Protocol (MCP…
GET/authorizationsList active MCP client auth…
GET/authorizeGet MCP authorization promp…
GET/clientsList registered MCP clients
GET/oauth/authorizeOAuth 2.1 Authorization End…
POST/authorizeGrant or deny MCP client au…
POST/oauth/introspectOAuth 2.0 Token Introspecti…
POST/oauth/registerDynamic Client Registration…
POST/oauth/tokenOAuth 2.1 Token Endpoint (R…
DELETE/authorizations/{clientId}Revoke MCP client authoriza…
DELETE/clients/{clientId}Delete registered MCP client
>]; + } + subgraph cluster_system { + label="System & Realtime"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + Events [label=<
Events
/events
GET/Subscribe to update signals…
>]; + Health [label=<
Health
/health
GET/Database health check
>]; + Legend [label=<
API Graph Legend
GETSafe retrieval / query
POSTCreate resource / action
PUTFull replace / collection update
PATCHPartial entity update
DELETERemove resource
in#Inbound arrival port on resource
▪—▶Relational reference / dataflow
>]; + } + + Order:ep_get__order__id_:e -> Entity:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="Order → Entity (entityId)", label=" entityId ", weight=3]; + Order:ep_post__order:e -> Article:in3:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="Order → Article (lines.sku)", label=" lines.sku ", constraint=false, weight=1]; + Order:ep_get__order__id_:e -> Shipment:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="Order → Shipment (shipments)", label=" shipments ", constraint=false, weight=1]; + Order:ep_get__order__id_:e -> Payment:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="Order → Payment (payments)", label=" payments ", constraint=false, weight=1]; + Order:ep_get__order__id_:e -> Shipping:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="Order → Shipping (shippings)", label=" shippings ", constraint=false, weight=1]; + Quote:ep_get__quote__id_:e -> Entity:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="Quote → Entity (entityId)", label=" entityId ", constraint=false, weight=1]; + Quote:ep_post__quote:e -> Article:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="Quote → Article (lines.sku)", label=" lines.sku ", constraint=false, weight=1]; + Quote:ep_post__quote:e -> Order:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="Quote → Order (convert order)", label=" convert order ", weight=3]; + Shipment:ep_get__shipment__id_:w -> Order:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="Shipment → Order (manifestId)", label=" manifestId ", constraint=false, weight=1]; + Shipment:ep_get__shipment__id_:e -> Article:in4:n [color="#58d18a", fontcolor="#58d18a", tooltip="Shipment → Article (lines.sku)", label=" lines.sku ", weight=3]; + Shipment:ep_get__shipment__id__shipping:w -> Shipping:in2:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="Shipment → Shipping (shippings)", label=" shippings ", constraint=false, weight=1]; + Shipping:ep_get__shipping__id__package:e -> Package:in0:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="Shipping → Package (packages)", label=" packages ", weight=3]; + Package:w -> Shipping:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="Package → Shipping (shippingId)", label=" shippingId ", constraint=false, weight=1]; + Package:e -> Entity:in3:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="Package → Entity (addressId)", label=" addressId ", weight=3]; + Delivery:ep_post__delivery:w -> Supplier:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="Delivery → Supplier (supplierId)", label=" supplierId ", constraint=false, weight=1]; + Delivery:ep_post__delivery:e -> Article:in1:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="Delivery → Article (lines.sku)", label=" lines.sku ", constraint=false, weight=1]; + Delivery:ep_get__delivery__id_:e -> Ledger:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="Delivery → Ledger (ledger)", label=" ledger ", weight=3]; + ArticleSupplier:ep_post__article__sku__supplier:e -> Supplier:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="ArticleSupplier → Supplier (entityId)", label=" entityId ", weight=3]; + ArticleSupplier:ep_get__article__sku__supplier:e -> Article:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="ArticleSupplier → Article (sku)", label=" sku ", constraint=false, weight=1]; + Supplier:ep_get__supplier__id__articles:e -> Entity:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="Supplier → Entity (entityId)", label=" entityId ", weight=3]; + Stock:ep_post__stock_adjust:e -> Article:in5:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="Stock → Article (sku)", label=" sku ", constraint=false, weight=1]; + Stock:ep_post__stock_adjust:e -> Warehouse:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="Stock → Warehouse (warehouseId)", label=" warehouseId ", constraint=false, weight=1]; + Stock:ep_post__stock_adjust:e -> Ledger:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="Stock → Ledger (ledger)", label=" ledger ", weight=3]; + Ledger:ep_get__ledger:w -> Article:in9:n [color="#c79bff", fontcolor="#c79bff", tooltip="Ledger → Article (sku)", label=" sku ", weight=3]; + Ledger:ep_get__ledger:w -> Warehouse:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="Ledger → Warehouse (warehouseId)", label=" warehouseId ", constraint=false, weight=1]; + Article:ep_post__article:w -> Category:in1:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="Article → Category (categoryId)", label=" categoryId ", constraint=false, weight=1]; + Article:ep_put__article__sku__brand:w -> Brand:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="Article → Brand (brand)", label=" brand ", constraint=false, weight=1]; + Article:ep_patch__article__sku_:w -> Unit:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="Article → Unit (unitSi)", label=" unitSi ", constraint=false, weight=1]; + Article:ep_get__article__sku_:w -> ArticleSupplier:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="Article → ArticleSupplier (suppliers)", label=" suppliers ", constraint=false, weight=1]; + Article:ep_post__article__sku__image:e -> Image:in2:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="Article → Image (photos)", label=" photos ", constraint=false, weight=1]; + Article:ep_put__article__sku__prices:e -> Customergroup:in1:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="Article → Customergroup (prices)", label=" prices ", constraint=false, weight=1]; + Article:e -> Property:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="Article → Property (properties)", label=" properties ", constraint=false, weight=1]; + Article:e -> Attribute:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="Article → Attribute (attributes)", label=" attributes ", weight=3]; + Article:ep_get__article__sku__children:ne -> Article:in7:n [color="#58d18a", fontcolor="#58d18a", tooltip="Article → Article (bundles & variants)", label=" bundles & variants ", weight=3]; + Category:ep_get__category__id__articles:ne -> Category:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="Category → Category (parentId)", label=" parentId ", weight=3]; + Category:ep_get__category__id__articles:e -> Article:in6:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="Category → Article (articles)", label=" articles ", weight=3]; + Category:ep_post__category__id__image:e -> Image:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="Category → Image (image)", label=" image ", constraint=false, weight=1]; + Category:e -> Property:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="Category → Property (properties)", label=" properties ", constraint=false, weight=1]; + Category:e -> Attribute:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="Category → Attribute (attributes)", label=" attributes ", constraint=false, weight=1]; + Brand:ep_post__brand__id__image:e -> Image:in0:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="Brand → Image (image)", label=" image ", weight=3]; + Property:ep_get__propertyname:e -> Company:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="Property → Company (companyId)", label=" companyId ", weight=3]; + Attribute:ep_post__attributevalue__id__image:e -> Image:in3:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="Attribute → Image (image)", label=" image ", weight=3]; + Payment:e -> Account:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="Payment → Account (accountId)", label=" accountId ", weight=3]; + Payment:w -> Order:in3:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="Payment → Order (manifestId)", label=" manifestId ", constraint=false, weight=1]; + Account:ep_get__account:e -> Company:in4:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="Account → Company (companyId)", label=" companyId ", weight=3]; + Entity:ep_post__entity:e -> Customergroup:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="Entity → Customergroup (customergroupId)", label=" customergroupId ", weight=3]; + Company:e -> Customergroup:in2:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="Company → Customergroup (poscustomergroup)", label=" poscustomergroup ", weight=3]; + Company:w -> Category:in5:n [color="#c79bff", fontcolor="#c79bff", tooltip="Company → Category (root_category)", label=" root_category ", constraint=false, weight=1]; + Company:w -> CompanySetting:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="Company → CompanySetting (settings)", label=" settings ", constraint=false, weight=1]; + Number:ep_get__number:w -> Company:in5:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="Number → Company (companyId)", label=" companyId ", weight=3]; + POSTerminal:ep_get__posterminal:e -> Company:in2:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="POSTerminal → Company (companyId)", label=" companyId ", weight=3]; + POSTerminal:ep_get__posterminal:w -> Category:in3:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="POSTerminal → Category (root_category)", label=" root_category ", constraint=false, weight=1]; + Printer:ep_get__printer:e -> Company:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="Printer → Company (companyId)", label=" companyId ", weight=3]; + Printer:ep_get__printer:w -> LabelTemplate:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="Printer → LabelTemplate (templates)", label=" templates ", constraint=false, weight=1]; + LabelTemplate:ep_get__labeltemplate:e -> Company:in1:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="LabelTemplate → Company (companyId)", label=" companyId ", weight=3]; + POSSync:w -> POSTerminal:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="POSSync → POSTerminal (client bind)", label=" client bind ", constraint=false, weight=1]; + POSSync:ep_get__v1_product:w -> Article:in12:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="POSSync → Article (product sync)", label=" product sync ", constraint=false, weight=1]; + POSSync:ep_get__v1_category:w -> Category:in4:n [color="#58d18a", fontcolor="#58d18a", tooltip="POSSync → Category (category sync)", label=" category sync ", weight=3]; + PublicCatalog:w -> Article:in8:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="PublicCatalog → Article (articles)", label=" articles ", weight=3]; + PublicCatalog:w -> Category:in2:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="PublicCatalog → Category (category)", label=" category ", constraint=false, weight=1]; + ModelConversation:ep_get__modelconversation:e -> Vfs:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="ModelConversation → Vfs (vfs workspace)", label=" vfs workspace ", weight=3]; + MCP:ep_post__mcp:w -> Article:in10:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="MCP → Article (article tools)", label=" article tools ", weight=3]; + MCP:ep_post__mcp:w -> Order:in2:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="MCP → Order (order tools)", label=" order tools ", constraint=false, weight=1]; + Events:ep_get__events:w -> Stock:in0:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="Events → Stock (stock sse)", label=" stock sse ", constraint=false, weight=1]; + Events:ep_get__events:w -> Ledger:in2:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="Events → Ledger (ledger sse)", label=" ledger sse ", constraint=false, weight=1]; + Events:ep_get__events:w -> Article:in11:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="Events → Article (article sse)", label=" article sse ", weight=3]; +} \ No newline at end of file diff --git a/api-graph.svg b/api-graph.svg new file mode 100644 index 0000000..8da0c31 --- /dev/null +++ b/api-graph.svg @@ -0,0 +1,2509 @@ + + + + + + +api + + +cluster_identity + +Identity & Access + + +cluster_crm + +Entities & CRM + + +cluster_catalog + +Catalog & Products + + +cluster_properties + +Properties & Attributes + + +cluster_purchasing + +Suppliers & Purchasing + + +cluster_sales + +Sales & Fulfillment + + +cluster_inventory + +Inventory & Warehousing + + +cluster_finance + +Finance & Payments + + +cluster_media + +Media & Storage + + +cluster_pos + +POS & Hardware + + +cluster_ai_mcp + +AI & Integrations + + +cluster_system + +System & Realtime + + + +Auth + + +Auth +/auth + + + +GET +/.well-known/oauth-authorization-server +OAuth 2.0 Authorization Ser… + + + +GET +/.well-known/oauth-protected-resource +OAuth 2.0 Protected Resourc… + + + +GET +/me +Current user + + + +POST +/login +Login with Google ID token + + + +POST +/logout +Logout + + + +PUT +/selected-company +Set selected company + + + +Company + + + + + + + + +Company +/company + + + +GET +/ +List companies + + + +POST +/ +Create company + + + +GET +/history +Get company history + + + +POST +/history +Add company history entry + + + +POST +/import +Import company catalog from… + + + +PUT +/{id} +Update company + + + +DELETE +/{id} +Delete company + + + +CompanySetting + + + +CompanySetting +/companysetting + + + +GET +/company/setting +List company settings + + + +GET +/company/setting/{key} +Get company setting by key + + + +PUT +/company/setting/{key} +Upsert company setting + + + +Company:w->CompanySetting:n + + + + + +  settings   + + + +Customergroup + + + + + +Customergroup +/customergroup + + + +GET +/ +List customer groups + + + +POST +/ +Create customer group + + + +PUT +/order +Reorder customer groups + + + +PATCH +/{id} +Update customer group + + + +DELETE +/{id} +Delete customer group + + + +Company:e->Customergroup:n + + + + + +  poscustomergroup   + + + +Category + + + + + + + + +Category +/category + + + +POST +/ +Create category + + + +GET +/{id}/articles +Articles in category + + + +GET +/export.zip +Export categories as ZIP + + + +GET +/tree +Category tree + + + +POST +/{id}/image +Add category image + + + +PUT +/order +Reorder sibling categories + + + +PATCH +/{id} +Update category (rename or … + + + +DELETE +/{id} +Delete category + + + +DELETE +/{id}/image +Delete category image + + + +Company:w->Category:n + + + + + +  root_category   + + + +Number + + +Number +/number + + + +GET +/ +List number sequences + + + +PATCH +/{id} +Update number sequence + + + +Number:w->Company:n + + + + + +  companyId   + + + +Entity + + + + + + +Entity +/entity + + + +GET +/ +List entities + + + +POST +/ +Create entity + + + +GET +/{entityId}/address +List entity addresses + + + +GET +/{entityId}/contact +List entity contacts + + + +GET +/{id} +Get entity + + + +GET +/next-number +Peek next customer number + + + +POST +/{entityId}/address +Create entity address + + + +POST +/{entityId}/contact +Create entity contact + + + +PATCH +/{entityId}/address/{id} +Update entity address + + + +PATCH +/{entityId}/contact/{id} +Update entity contact + + + +PATCH +/{id} +Update entity + + + +DELETE +/{entityId}/address/{id} +Delete entity address + + + +DELETE +/{entityId}/contact/{id} +Delete entity contact + + + +DELETE +/{id} +Delete entity + + + +Entity:e->Customergroup:n + + + + + +  customergroupId   + + + +Article + + + + + + + + + + + + + + + +Article +/article + + + +POST +/ +Create article in category + + + +GET +/{sku} +Get article by SKU + + + +GET +/{sku}/articlelist +Get Stückliste (bundle item… + + + +GET +/{sku}/children +List child articles of a pa… + + + +GET +/{sku}/history +Get article history by SKU + + + +GET +/{sku}/optionslist +Get options list for an art… + + + +GET +/export.zip +Export articles as ZIP + + + +POST +/{sku}/children +Create and attach a new chi… + + + +POST +/{sku}/image +Add article image + + + +POST +/{sku}/parent +Toggle parent mode for an a… + + + +POST +/{sku}/print +Print article label + + + +PUT +/{sku}/articlelist +Set Stückliste (bundle item… + + + +PUT +/{sku}/brand +Set or clear article brand … + + + +PUT +/{sku}/categories +Replace article categories … + + + +PUT +/{sku}/children +Attach an existing article … + + + +PUT +/{sku}/eans +Replace article EANs by SKU + + + +PUT +/{sku}/images/order +Reorder article images + + + +PUT +/{sku}/optionslist +Set options list for an art… + + + +PUT +/{sku}/prices +Replace POS prices by SKU (… + + + +PUT +/{sku}/track_stock +Set article track_stock fla… + + + +PUT +/{sku}/weight +Set article weight by SKU + + + +PATCH +/{sku} +Update article by SKU + + + +DELETE +/{sku} +Delete article by SKU + + + +DELETE +/{sku}/children/{childSku} +Remove a child article from… + + + +DELETE +/{sku}/image/{fullHash} +Delete article image + + + +Article:e->Customergroup:n + + + + + +  prices   + + + +Article:ne->Article:n + + + + + +  bundles & variants   + + + +Article:w->Category:n + + + + + +  categoryId   + + + +Brand + + + +Brand +/brand + + + +GET +/ +List brands + + + +POST +/ +Create brand + + + +POST +/{id}/image +Add or replace brand image + + + +PATCH +/{id} +Rename brand + + + +DELETE +/{id} +Delete brand + + + +DELETE +/{id}/image +Delete brand image + + + +Article:w->Brand:n + + + + + +  brand   + + + +Unit + + + +Unit +/unit + + + +GET +/ +List units + + + +PUT +/ +Upsert unit + + + +DELETE +/ +Delete unit + + + +Article:w->Unit:n + + + + + +  unitSi   + + + +Property + + + + +Property +/propertyname + + + +GET +/ +List property names + + + +POST +/ +Create property name + + + +GET +/article/{sku}/property +List article properties + + + +GET +/category/{id}/property +List category properties + + + +PUT +/article/{sku}/property +Bulk set article properties + + + +PUT +/category/{id}/property +Bulk set category properties + + + +PUT +/order +Reorder property names + + + +PATCH +/{id} +Update property name + + + +DELETE +/{id} +Delete property name + + + +DELETE +/article/{sku}/property/{propertyId} +Delete article property + + + +DELETE +/category/{id}/property/{propertyId} +Delete category property + + + +Article:e->Property:n + + + + + +  properties   + + + +Attribute + + + + +Attribute +/attributename + + + +GET +/ +List attribute names + + + +POST +/ +Create attribute name + + + +GET +/{attributeId}/value +List values for an attribute + + + +GET +/article/{sku}/attributevalue +List article attribute valu… + + + +GET +/category/{id}/attributevalue +List category attribute val… + + + +POST +/{attributeId}/value +Create value for an attribu… + + + +POST +/attributevalue/{id}/image +Add or replace attribute va… + + + +PUT +/{attributeId}/value/order +Reorder values of an attrib… + + + +PUT +/article/{sku}/attributevalue +Bulk set article attribute … + + + +PUT +/category/{id}/attributevalue +Bulk set category attribute… + + + +PUT +/order +Reorder attribute names + + + +PATCH +/{id} +Update attribute name + + + +PATCH +/attributevalue/{id} +Update attribute value + + + +DELETE +/{id} +Delete attribute name + + + +DELETE +/attributevalue/{id} +Delete attribute value + + + +DELETE +/attributevalue/{id}/image +Delete attribute value image + + + +Article:e->Attribute:n + + + + + +  attributes   + + + +ArticleSupplier + + + +ArticleSupplier +/article/{sku}/supplier + + + +GET +/ +List article suppliers + + + +POST +/ +Assign supplier to article + + + +PUT +/order +Reorder article suppliers + + + +PATCH +/{id} +Update article supplier ass… + + + +DELETE +/{id} +Remove supplier assignment … + + + +Article:w->ArticleSupplier:n + + + + + +  suppliers   + + + +Image + + + + + + +Image +/image + + + +GET +/{fullHash} +Full image by hash + + + +GET +/smallthumb/{fullHash} +Small thumb image by hash + + + +GET +/thumb/{fullHash} +Thumb image by hash + + + +Article:e->Image:n + + + + + +  photos   + + + +Category:e->Article:n + + + + + +  articles   + + + +Category:ne->Category:n + + + + + +  parentId   + + + +Category:e->Property:n + + + + + +  properties   + + + +Category:e->Attribute:n + + + + + +  attributes   + + + +Category:e->Image:n + + + + + +  image   + + + +Brand:e->Image:n + + + + + +  image   + + + +PublicCatalog + + +PublicCatalog +/public + + + +GET +/{companyId}/articles +List public articles + + + +GET +/{companyId}/articles/{sku} +Get public article detail + + + +GET +/{companyId}/categories +List public categories + + + +POST +/telemetry/crash +Receipt crash dump + + + +PublicCatalog:w->Article:n + + + + + +  articles   + + + +PublicCatalog:w->Category:n + + + + + +  category   + + + +Property:e->Company:n + + + + + +  companyId   + + + +Attribute:e->Image:n + + + + + +  image   + + + +Supplier + + + + +Supplier +/supplier + + + +GET +/ +List suppliers + + + +POST +/ +Create supplier + + + +GET +/{id}/articles +List supplier articles by S… + + + +POST +/{id}/articles +Query supplier articles by … + + + +PATCH +/{id} +Update supplier + + + +DELETE +/{id} +Delete supplier + + + +Supplier:e->Entity:n + + + + + +  entityId   + + + +ArticleSupplier:e->Article:n + + + + + +  sku   + + + +ArticleSupplier:e->Supplier:n + + + + + +  entityId   + + + +Delivery + + +Delivery +/delivery + + + +GET +/ +List deliveries + + + +POST +/ +Create delivery + + + +GET +/{id} +Get delivery + + + +GET +/{id}/file/{fileId} +Get delivery attachment fil… + + + +POST +/parse +Parse document (SSE) + + + +PATCH +/{id} +Update delivery + + + +DELETE +/{id} +Delete delivery + + + +Delivery:e->Article:n + + + + + +  lines.sku   + + + +Delivery:w->Supplier:n + + + + + +  supplierId   + + + +Ledger + + + + + +Ledger +/ledger + + + +GET +/ +Query ledger by manifest ar… + + + +POST +/ +Add ledger entry + + + +GET +/manifestarticle/{manifestarticleId} +Query ledger by manifest ar… + + + +GET +/sku/{sku} +Query ledger by article SKU + + + +Delivery:e->Ledger:n + + + + + +  ledger   + + + +Quote + + +Quote +/quote + + + +GET +/ +List quotes + + + +POST +/ +Create quote + + + +GET +/{id} +Get quote + + + +PATCH +/{id} +Update quote + + + +DELETE +/{id} +Delete quote + + + +Quote:e->Entity:n + + + + + +  entityId   + + + +Quote:e->Article:n + + + + + +  lines.sku   + + + +Order + + + + + + +Order +/order + + + +GET +/ +List orders + + + +POST +/ +Create order + + + +GET +/{id} +Get order + + + +GET +/{id}/payment +List payments of an order + + + +GET +/{id}/shipment +List shipments of an order + + + +GET +/{id}/shipping +List shippings of an order + + + +POST +/{id}/payment +Add payment to an order + + + +POST +/{id}/shipment +Create shipment for an order + + + +POST +/{id}/shipping +Create shipping for an order + + + +PATCH +/{id} +Update order + + + +DELETE +/{id} +Delete order + + + +Quote:e->Order:n + + + + + +  convert order   + + + +Order:e->Entity:n + + + + + +  entityId   + + + +Order:e->Article:n + + + + + +  lines.sku   + + + +Shipment + + + +Shipment +/shipment + + + +GET +/{id} +Get shipment by ID + + + +GET +/{id}/shipping +List packages of a shipment + + + +POST +/{id}/shipping +Create package for a shipme… + + + +DELETE +/{id} +Delete shipment + + + +Order:e->Shipment:n + + + + + +  shipments   + + + +Shipping + + + + + +Shipping +/shipping + + + +GET +/{id}/package +List packages of a shipping + + + +POST +/{id}/package +Create package for a shippi… + + + +PATCH +/{id} +Update shipping + + + +DELETE +/{id} +Delete shipping + + + +Order:e->Shipping:n + + + + + +  shippings   + + + +Payment + + + +Payment +/payment + + + +DELETE +/{id} +Delete payment + + + +Order:e->Payment:n + + + + + +  payments   + + + +Shipment:e->Article:n + + + + + +  lines.sku   + + + +Shipment:w->Order:n + + + + + +  manifestId   + + + +Shipment:w->Shipping:n + + + + + +  shippings   + + + +Package + + + +Package +/package + + + +DELETE +/{id} +Delete package + + + +Shipping:e->Package:n + + + + + +  packages   + + + +Package:e->Entity:n + + + + + +  addressId   + + + +Package:w->Shipping:n + + + + + +  shippingId   + + + +Stock + + + +Stock +/stock + + + +POST +/adjust +Adjust stock manually + + + +Stock:e->Article:n + + + + + +  sku   + + + +Stock:e->Ledger:n + + + + + +  ledger   + + + +Warehouse + + + + +Warehouse +/warehouse + + + +GET +/ +List warehouses + + + +POST +/ +Create warehouse + + + +GET +/{id} +Get warehouse by ID + + + +PUT +/{id} +Update warehouse + + + +PUT +/order +Reorder warehouses + + + +PATCH +/{id} +Update warehouse (partial) + + + +DELETE +/{id} +Delete warehouse + + + +Stock:e->Warehouse:n + + + + + +  warehouseId   + + + +Ledger:w->Article:n + + + + + +  sku   + + + +Ledger:w->Warehouse:n + + + + + +  warehouseId   + + + +Account + + + +Account +/account + + + +GET +/ +List accounts + + + +POST +/ +Create account + + + +DELETE +/{id} +Delete account + + + +Account:e->Company:n + + + + + +  companyId   + + + +Payment:w->Order:n + + + + + +  manifestId   + + + +Payment:e->Account:n + + + + + +  accountId   + + + +Serper + + +Serper +/serper + + + +POST +/image +Crawl image from URL, crop … + + + +POST +/images +Search images via Serper.de… + + + +Vfs + + + +Vfs +/vfs + + + +GET +/ +List virtual filesystem fil… + + + +GET +/{*path} +Get a virtual filesystem fi… + + + +POST +/{*path} +Execute a virtual filesyste… + + + +POST +/mkdir +Create directory in virtual… + + + +POST +/rename +Rename or move a virtual fi… + + + +POST +/upload +Upload files to virtual fil… + + + +PUT +/{*path} +Create or overwrite a virtu… + + + +DELETE +/{*path} +Delete a virtual filesystem… + + + +POSTerminal + + + +POSTerminal +/posterminal + + + +GET +/ +List POS terminals + + + +PUT +/ +Create POS terminal + + + +PUT +/{id}/all +Enable all categories on te… + + + +PUT +/{id}/category/{categoryId} +Enable category on terminal + + + +PATCH +/{id} +Update POS terminal + + + +DELETE +/{id} +Delete POS terminal + + + +DELETE +/{id}/all +Disable all categories on t… + + + +DELETE +/{id}/category/{categoryId} +Disable category on terminal + + + +POSTerminal:e->Company:n + + + + + +  companyId   + + + +POSTerminal:w->Category:n + + + + + +  root_category   + + + +Printer + + +Printer +/printer + + + +GET +/ +List printers + + + +POST +/ +Create printer + + + +GET +/{id} +Get printer by ID + + + +PUT +/{id} +Update printer + + + +PUT +/order +Reorder printers + + + +PATCH +/{id} +Update printer (partial) + + + +DELETE +/{id} +Delete printer + + + +Printer:e->Company:n + + + + + +  companyId   + + + +LabelTemplate + + + +LabelTemplate +/labeltemplate + + + +GET +/ +List label templates + + + +POST +/ +Create label template + + + +GET +/{id} +Get label template by ID + + + +PUT +/{id} +Update label template + + + +PATCH +/{id} +Update label template (part… + + + +DELETE +/{id} +Delete label template + + + +Printer:w->LabelTemplate:n + + + + +  templates   + + + +LabelTemplate:e->Company:n + + + + + +  companyId   + + + +POSSync + + +POSSync +/v1 + + + +GET +/category +JTL-POS category synchroniz… + + + +GET +/client +POS discovery and pairing + + + +GET +/init +POS sync initialization cou… + + + +GET +/pimage +POS product image delivery + + + +GET +/product +JTL-POS product catalog syn… + + + +POSSync:w->Article:n + + + + + +  product sync   + + + +POSSync:w->Category:n + + + + + +  category sync   + + + +POSSync:w->POSTerminal:n + + + + + +  client bind   + + + +ModelConversation + + +ModelConversation +/modelconversation + + + +GET +/ +List conversations + + + +POST +/ +Create conversation + + + +GET +/{id} +Get conversation + + + +GET +/{id}/message +List conversation messages + + + +GET +/{id}/stream +Stream conversation events … + + + +POST +/{id}/message +Post a user message + + + +DELETE +/{id} +Delete conversation + + + +ModelConversation:e->Vfs:n + + + + + +  vfs workspace   + + + +MCP + + +MCP +/mcp + + + +POST +/ +Model Context Protocol (MCP… + + + +GET +/authorizations +List active MCP client auth… + + + +GET +/authorize +Get MCP authorization promp… + + + +GET +/clients +List registered MCP clients + + + +GET +/oauth/authorize +OAuth 2.1 Authorization End… + + + +POST +/authorize +Grant or deny MCP client au… + + + +POST +/oauth/introspect +OAuth 2.0 Token Introspecti… + + + +POST +/oauth/register +Dynamic Client Registration… + + + +POST +/oauth/token +OAuth 2.1 Token Endpoint (R… + + + +DELETE +/authorizations/{clientId} +Revoke MCP client authoriza… + + + +DELETE +/clients/{clientId} +Delete registered MCP client + + + +MCP:w->Article:n + + + + + +  article tools   + + + +MCP:w->Order:n + + + + + +  order tools   + + + +Events + + +Events +/events + + + +GET +/ +Subscribe to update signals… + + + +Events:w->Article:n + + + + + +  article sse   + + + +Events:w->Stock:n + + + + + +  stock sse   + + + +Events:w->Ledger:n + + + + + +  ledger sse   + + + +Health + + +Health +/health + + + +GET +/ +Database health check + + + +Legend + + + +API Graph Legend + + +GET + +Safe retrieval / query + + +POST + +Create resource / action + + +PUT + +Full replace / collection update + + +PATCH + +Partial entity update + + +DELETE + +Remove resource + +in# + +Inbound arrival port on resource + +▪—▶ + +Relational reference / dataflow + + + diff --git a/generate-api-graph.js b/generate-api-graph.js new file mode 100644 index 0000000..8c18280 --- /dev/null +++ b/generate-api-graph.js @@ -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, '"'); +} + +/** + * 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 ( + `` + + `${method}` + + `${desc}` + ); + }; + const row = (swatch, text) => + `${swatch}` + + `${text}`; + + return ( + `<` + + `` + + 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( + `in#`, + 'Inbound arrival port on resource' + ) + + row( + `▪—▶`, + 'Relational reference / dataflow' + ) + + `
` + + `API Graph Legend
>` + ); +} + +function renderResourceNode(res, inboundCount = 0) { + if (res.id === 'Legend') { + return buildLegendLabel(); + } + + const titleFont = + `${escapeHtml(res.id)}` + + `
${escapeHtml(res.basePath)}`; + + let headerInner; + if (inboundCount > 0) { + const portCells = []; + for (let i = 0; i < inboundCount; i++) { + portCells.push( + `` + ); + } + headerInner = + `` + + `${portCells.join('')}` + + `` + + `
${titleFont}
`; + } else { + headerInner = titleFont; + } + + const rows = [ + `${headerInner}`, + ]; + + 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( + `` + + `` + + `` + + `` + + `` + + `` + + `
` + + `${escapeHtml(ep.method)}` + + `${escapeHtml(ep.subpath)}` + + `${escapeHtml(ep.summary)}
` + ); + } + + return ( + `<` + + rows.join('') + + `
>` + ); +} + +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( + /\s*(\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}\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `` + ); + } + ); +} + +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( + /]*?)>/, + `` +); + +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`); +} diff --git a/package-lock.json b/package-lock.json index c9705d3..5aa88e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "zod": "^4.6.7" }, "devDependencies": { + "@hpcc-js/wasm-graphviz": "^1.29.2", "acorn": "^8.19.0", "swagger-jsdoc": "^6.3.0" } @@ -212,6 +213,13 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index be9a7f1..0f0615e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "manage-users": "node scripts/manageUsers.js", "wipe-company": "node scripts/wipeCompany.js", "docs:api": "node scripts/generateApiDocs.js", + "api-graph": "node generate-api-graph.js", "review:functions": "node scripts/extractFunctions.js", "test": "echo \"Error: no test specified\" && exit 1" }, @@ -44,6 +45,7 @@ "zod": "^4.6.7" }, "devDependencies": { + "@hpcc-js/wasm-graphviz": "^1.29.2", "acorn": "^8.19.0", "swagger-jsdoc": "^6.3.0" }, diff --git a/src/api/middleware/logger.js b/src/api/middleware/logger.js index 4bb1a56..dfef4ed 100644 --- a/src/api/middleware/logger.js +++ b/src/api/middleware/logger.js @@ -15,6 +15,17 @@ function logField(label, value, { skipEmpty = false } = {}) { 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) { let body; 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)); } logField('body', req.body ?? {}, { skipEmpty: true }); - logField('response', getBody()); + logResponse(getBody()); // Dump full request/response/error details to a log file on 5xx. const statusCode = res.statusCode; diff --git a/src/db/order.js b/src/db/order.js index 87a2cff..7c96269 100644 --- a/src/db/order.js +++ b/src/db/order.js @@ -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) { if (!result) return result; + const enrich = (o) => (o?.grossTotal !== undefined ? formatEnrichedFromSummary(o) : enrichSingleOrder(store, googleAuthId, o)); 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)) { - const enrichedOrders = await Promise.all( - result.orders.map((o) => enrichSingleOrder(store, googleAuthId, o)) - ); + const enrichedOrders = await Promise.all(result.orders.map(enrich)); return { ...result, orders: enrichedOrders,