Compare commits
12 Commits
06dc4629da
...
2de2bc0d90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de2bc0d90 | ||
|
|
8acca998bc | ||
|
|
97540fb745 | ||
|
|
2f92199d7f | ||
|
|
3d009ba216 | ||
|
|
f743ace6af | ||
|
|
cc77bf4377 | ||
|
|
77367b7716 | ||
|
|
3748020b03 | ||
|
|
c35126bb11 | ||
|
|
713c31a21e | ||
|
|
358a023b10 |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
173
api-graph.dot
Normal file
173
api-graph.dot
Normal file
File diff suppressed because one or more lines are too long
2509
api-graph.svg
Normal file
2509
api-graph.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 289 KiB |
1066
artikeltabelle.csv
1066
artikeltabelle.csv
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
|
||||
| File | Endpoint |
|
||||
|------|----------|
|
||||
| [`routes/.well-known/oauth-authorization-server.md`](routes/.well-known/oauth-authorization-server.md) | `GET /.well-known/oauth-authorization-server` |
|
||||
| [`routes/.well-known/oauth-protected-resource.md`](routes/.well-known/oauth-protected-resource.md) | `GET /.well-known/oauth-protected-resource` |
|
||||
| [`routes/account.get.md`](routes/account.get.md) | `GET /account` |
|
||||
| [`routes/account.post.md`](routes/account.post.md) | `POST /account` |
|
||||
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` |
|
||||
@@ -155,6 +157,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` |
|
||||
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` |
|
||||
| [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
|
||||
| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` |
|
||||
| [`routes/mcp/authorizations.md`](routes/mcp/authorizations.md) | `GET /mcp/authorizations` |
|
||||
| [`routes/mcp/authorizations/_clientId.md`](routes/mcp/authorizations/_clientId.md) | `DELETE /mcp/authorizations/{clientId}` |
|
||||
| [`routes/mcp/authorize.get.md`](routes/mcp/authorize.get.md) | `GET /mcp/authorize` |
|
||||
| [`routes/mcp/authorize.post.md`](routes/mcp/authorize.post.md) | `POST /mcp/authorize` |
|
||||
| [`routes/mcp/clients.md`](routes/mcp/clients.md) | `GET /mcp/clients` |
|
||||
| [`routes/mcp/clients/_clientId.md`](routes/mcp/clients/_clientId.md) | `DELETE /mcp/clients/{clientId}` |
|
||||
| [`routes/modelconversation.get.md`](routes/modelconversation.get.md) | `GET /modelconversation` |
|
||||
| [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` |
|
||||
| [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` |
|
||||
@@ -164,6 +173,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/modelconversation/_id/stream.md`](routes/modelconversation/_id/stream.md) | `GET /modelconversation/{id}/stream` |
|
||||
| [`routes/number/list.md`](routes/number/list.md) | `GET /number` |
|
||||
| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` |
|
||||
| [`routes/oauth/authorize.md`](routes/oauth/authorize.md) | `GET /oauth/authorize` |
|
||||
| [`routes/oauth/introspect.md`](routes/oauth/introspect.md) | `POST /oauth/introspect` |
|
||||
| [`routes/oauth/register.md`](routes/oauth/register.md) | `POST /oauth/register` |
|
||||
| [`routes/oauth/token.md`](routes/oauth/token.md) | `POST /oauth/token` |
|
||||
| [`routes/order/list.md`](routes/order/list.md) | `GET /order` |
|
||||
| [`routes/order/create.md`](routes/order/create.md) | `POST /order` |
|
||||
| [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` |
|
||||
@@ -171,6 +184,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/order/update.md`](routes/order/update.md) | `PATCH /order/{id}` |
|
||||
| [`routes/order/_id/payment.get.md`](routes/order/_id/payment.get.md) | `GET /order/{id}/payment` |
|
||||
| [`routes/order/_id/payment.post.md`](routes/order/_id/payment.post.md) | `POST /order/{id}/payment` |
|
||||
| [`routes/order/_id/shipment.get.md`](routes/order/_id/shipment.get.md) | `GET /order/{id}/shipment` |
|
||||
| [`routes/order/_id/shipment.post.md`](routes/order/_id/shipment.post.md) | `POST /order/{id}/shipment` |
|
||||
| [`routes/order/_id/shipping.get.md`](routes/order/_id/shipping.get.md) | `GET /order/{id}/shipping` |
|
||||
| [`routes/order/_id/shipping.post.md`](routes/order/_id/shipping.post.md) | `POST /order/{id}/shipping` |
|
||||
| [`routes/package/_id.md`](routes/package/_id.md) | `DELETE /package/{id}` |
|
||||
@@ -206,6 +221,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
|
||||
| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
|
||||
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
|
||||
| [`routes/shipment/_id.delete.md`](routes/shipment/_id.delete.md) | `DELETE /shipment/{id}` |
|
||||
| [`routes/shipment/_id.get.md`](routes/shipment/_id.get.md) | `GET /shipment/{id}` |
|
||||
| [`routes/shipment/_id/shipping.get.md`](routes/shipment/_id/shipping.get.md) | `GET /shipment/{id}/shipping` |
|
||||
| [`routes/shipment/_id/shipping.post.md`](routes/shipment/_id/shipping.post.md) | `POST /shipment/{id}/shipping` |
|
||||
| [`routes/shipping/_id.delete.md`](routes/shipping/_id.delete.md) | `DELETE /shipping/{id}` |
|
||||
| [`routes/shipping/_id.patch.md`](routes/shipping/_id.patch.md) | `PATCH /shipping/{id}` |
|
||||
| [`routes/shipping/_id/package.get.md`](routes/shipping/_id/package.get.md) | `GET /shipping/{id}/package` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 210 API endpoints across all modules.
|
||||
Overview of all 229 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -9,58 +9,69 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
||||
|
||||
### Reusable types
|
||||
|
||||
- `T1` = `{ article: T2 }`
|
||||
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T32, brand: T29|null, categoryIds: int[], photos: T27[], eans: str[], prices: T40[], suppliers: T13[] }`
|
||||
- `T3` = `{ order: T4 }`
|
||||
- `T4` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, shipping?: T39|null, shippings?: T39[], payment?: T35|null, payments?: T35[], paid?: bool, delivered?: bool }`
|
||||
- `T1` = `{ order: T2 }`
|
||||
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str, shipping?: T33|null, shippings?: T33[], payment?: T39|null, payments?: T39[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
|
||||
- `T3` = `{ article: T4 }`
|
||||
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T36, brand: T34|null, categoryIds: int[], photos: T31[], eans: str[], prices: T42[], suppliers: T14[] }`
|
||||
- `T5` = `{ delivery: T6 }`
|
||||
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T22[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T44 }`
|
||||
- `T7` = `{ labeltemplate: T8 }`
|
||||
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T18, thumb?: str|null }`
|
||||
- `T9` = `{ quote: T12 }`
|
||||
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T11` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||
- `T12` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str }`
|
||||
- `T13` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }`
|
||||
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T15` = `{ ledger: T16[] }`
|
||||
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T33[], articleCount?: int }`
|
||||
- `T18` = `{ objects: T23[] }`
|
||||
- `T19` = `{ objects: T24[] }`
|
||||
- `T20` = `{ printer: T25 }`
|
||||
- `T21` = `{ entity: T28 }`
|
||||
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T24` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T25` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T26` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T27` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T28` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T29` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T30` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
|
||||
- `T31` = `{ article: T37 }`
|
||||
- `T32` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T33` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T34` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T35` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T36` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- `T37` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T38` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T39` = `{ id: int, manifestId: int, number: str, type: str, state: str }`
|
||||
- `T40` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T41` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T42` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T43` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T44` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T45` = `{ warehouse: T47 }`
|
||||
- `T46` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T47` = `{ id: int, name: str, sort: int }`
|
||||
- `T48` = `{ id: int, categoryIds: int[] }`
|
||||
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T25[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T46 }`
|
||||
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T25[], shippings: T33[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
|
||||
- `T8` = `{ labeltemplate: T9 }`
|
||||
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T21, thumb?: str|null }`
|
||||
- `T10` = `{ quote: T13 }`
|
||||
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T12` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||
- `T13` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str }`
|
||||
- `T14` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }`
|
||||
- `T15` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T16` = `{ ledger: T17[] }`
|
||||
- `T17` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[], articleCount?: int }`
|
||||
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
|
||||
- `T20` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
||||
- `T21` = `{ objects: T26[] }`
|
||||
- `T22` = `{ objects: T27[] }`
|
||||
- `T23` = `{ printer: T29 }`
|
||||
- `T24` = `{ entity: T32 }`
|
||||
- `T25` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T26` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T27` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T28` = `{ shipping: T33 }`
|
||||
- `T29` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T30` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T31` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T32` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T33` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T34` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T35` = `{ article: T40 }`
|
||||
- `T36` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T37` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T38` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T39` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T40` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T41` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T42` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T43` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T44` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T45` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T46` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T47` = `{ warehouse: T49 }`
|
||||
- `T48` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T49` = `{ id: int, name: str, sort: int }`
|
||||
- `T50` = `{ id: int, categoryIds: int[] }`
|
||||
|
||||
### Routes
|
||||
|
||||
|
||||
### Section: /.well-known
|
||||
|
||||
#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /account
|
||||
|
||||
#### List accounts — `GET /account`
|
||||
@@ -77,7 +88,7 @@ _none_
|
||||
|
||||
#### Create article in category — `POST /article`
|
||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||
- Returns: `201 T31`
|
||||
- Returns: `201 T35`
|
||||
#### Export articles as ZIP — `GET /article/export.zip`
|
||||
- Params: `query: categoryId?: int`
|
||||
- Returns: `200 zip`
|
||||
@@ -86,10 +97,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get article by SKU — `GET /article/{sku}`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Update article by SKU — `PATCH /article/{sku}`
|
||||
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
|
||||
@@ -104,25 +115,25 @@ _none_
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
|
||||
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
|
||||
- Params: `sku: str, body: { brandId: int|null }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
|
||||
- Params: `sku: str, body: { categoryIds: int[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### List child articles of a parent article — `GET /article/{sku}/children`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
||||
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||
- Returns: `201 T31`
|
||||
- Returns: `201 T35`
|
||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
||||
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||
- Returns: `200 { article: T37, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
- Returns: `200 { article: T40, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||
- Returns: `200 { success: bool, deleted: bool }`
|
||||
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
|
||||
- Params: `sku: str, body: { eans: str[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Get article history by SKU — `GET /article/{sku}/history`
|
||||
- Params: `sku: str, query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
|
||||
@@ -143,10 +154,10 @@ _none_
|
||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||
- Returns: `200 T31`
|
||||
- Returns: `200 T35`
|
||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Print article label — `POST /article/{sku}/print`
|
||||
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
|
||||
- Returns: `200 { success: bool }`
|
||||
@@ -161,10 +172,10 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List article suppliers — `GET /article/{sku}/supplier`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { suppliers: T13[] }`
|
||||
- Returns: `200 { suppliers: T14[] }`
|
||||
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
||||
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||
- Returns: `201 { supplier: T13 }`
|
||||
- Returns: `201 { supplier: T14 }`
|
||||
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
||||
- Params: `sku: str, body: { ids: int[] }`
|
||||
- Returns: `200 { ids: int[] }`
|
||||
@@ -173,31 +184,31 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
||||
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||
- Returns: `200 { supplier: T13 }`
|
||||
- Returns: `200 { supplier: T14 }`
|
||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||
- Params: `sku: str, body: { track_stock: bool }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Set article weight by SKU — `PUT /article/{sku}/weight`
|
||||
- Params: `sku: str, body: { weight: num }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
|
||||
### Section: /attributename
|
||||
|
||||
#### List attribute names — `GET /attributename`
|
||||
_none_
|
||||
- Returns: `200 { attributenames: T17[] }`
|
||||
- Returns: `200 { attributenames: T18[] }`
|
||||
#### Create attribute name — `POST /attributename`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { attributename: T17 }`
|
||||
- Returns: `201 { attributename: T18 }`
|
||||
#### Reorder attribute names — `PUT /attributename/order`
|
||||
- Params: `body: { attributenameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int`
|
||||
- Returns: `200 { values: T33[] }`
|
||||
- Returns: `200 { values: T37[] }`
|
||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||
- Returns: `201 { attributevalue: T33 }`
|
||||
- Returns: `201 { attributevalue: T37 }`
|
||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -206,7 +217,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute name — `PATCH /attributename/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { attributename: T17 }`
|
||||
- Returns: `200 { attributename: T18 }`
|
||||
|
||||
### Section: /attributevalue
|
||||
|
||||
@@ -215,28 +226,28 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||
- Returns: `200 { attributevalue: T33 }`
|
||||
- Returns: `200 { attributevalue: T37 }`
|
||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T41[] } }`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
|
||||
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T41[] } }`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
|
||||
|
||||
### Section: /auth
|
||||
|
||||
#### Login with Google ID token — `POST /auth/login`
|
||||
- Params: `body: { idToken: str }`
|
||||
- Returns: `200 { message: str, user: T38 }`
|
||||
- Returns: `200 { message: str, user: T41 }`
|
||||
#### Logout — `POST /auth/logout`
|
||||
_none_
|
||||
- Returns: `200 { message: str }`
|
||||
#### Current user — `GET /auth/me`
|
||||
_none_
|
||||
- Returns: `200 { user: T38, googleClientId: str }`
|
||||
- Returns: `200 { user: T41, googleClientId: str }`
|
||||
#### Set selected company — `PUT /auth/selected-company`
|
||||
- Params: `body: { companyId: int }`
|
||||
- Returns: `200 { user: T38 }`
|
||||
- Returns: `200 { user: T41 }`
|
||||
|
||||
### Section: /brand
|
||||
|
||||
@@ -245,7 +256,7 @@ _none_
|
||||
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
||||
#### Create brand — `POST /brand`
|
||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T41[], gsprpinfo?: str|null } }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T43[], gsprpinfo?: str|null } }`
|
||||
#### Delete brand — `DELETE /brand/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -254,10 +265,10 @@ _none_
|
||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T41[] } }`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
|
||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T41[] } }`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
|
||||
|
||||
### Section: /category
|
||||
|
||||
@@ -272,7 +283,7 @@ _none_
|
||||
- Returns: `200 { categoryIds: int[] }`
|
||||
#### Category tree — `GET /category/tree`
|
||||
_none_
|
||||
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T29|null }[] }`
|
||||
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, articleCount?: int }[], articleCount: int }`
|
||||
#### Delete category — `DELETE /category/{id}`
|
||||
- Params: `id: int, query: recursive?: bool`
|
||||
- Returns: `204 void`
|
||||
@@ -281,7 +292,7 @@ _none_
|
||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||
#### Articles in category — `GET /category/{id}/articles`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T29|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T34|null }[] }`
|
||||
#### List category attribute values — `GET /category/{id}/attributevalue`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
||||
@@ -308,10 +319,10 @@ _none_
|
||||
|
||||
#### List companies — `GET /company`
|
||||
_none_
|
||||
- Returns: `200 { companies: T36[] }`
|
||||
- Returns: `200 { companies: T20[] }`
|
||||
#### Create company — `POST /company`
|
||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `201 { company: T36, user: T38 }`
|
||||
- Returns: `201 { company: T20, user: T41 }`
|
||||
#### Get company history — `GET /company/history`
|
||||
- Params: `query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
||||
@@ -335,7 +346,7 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update company — `PUT /company/{id}`
|
||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `200 { company: T36 }`
|
||||
- Returns: `200 { company: T20 }`
|
||||
|
||||
### Section: /customergroup
|
||||
|
||||
@@ -344,7 +355,7 @@ _none_
|
||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||
#### Create customer group — `POST /customergroup`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { customergroup: T47 }`
|
||||
- Returns: `201 { customergroup: T49 }`
|
||||
#### Reorder customer groups — `PUT /customergroup/order`
|
||||
- Params: `body: { customergroupIds: int[] }`
|
||||
- Returns: `200 { customergroupIds: int[] }`
|
||||
@@ -353,15 +364,15 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update customer group — `PATCH /customergroup/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { customergroup: T47 }`
|
||||
- Returns: `200 { customergroup: T49 }`
|
||||
|
||||
### Section: /delivery
|
||||
|
||||
#### List deliveries — `GET /delivery`
|
||||
_none_
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T44 }[] }`
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T46 }[] }`
|
||||
#### Create delivery — `POST /delivery`
|
||||
- Params: `body: { externalNumber: str, date: str, lines: T26[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Params: `body: { externalNumber: str, date: str, lines: T30[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `201 T5`
|
||||
#### Parse document (SSE) — `POST /delivery/parse`
|
||||
- Params: `form: { file: str }`
|
||||
@@ -382,7 +393,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T5`
|
||||
#### Update delivery — `PATCH /delivery/{id}`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T26[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T30[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `200 T5`
|
||||
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int, query: download?: bool`
|
||||
@@ -392,46 +403,46 @@ _none_
|
||||
|
||||
#### List entities — `GET /entity`
|
||||
- Params: `query: customergroupId?: int, role?: str`
|
||||
- Returns: `200 { entities: T28[] }`
|
||||
- Returns: `200 { entities: T32[] }`
|
||||
#### Create entity — `POST /entity`
|
||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||
- Returns: `201 T21`
|
||||
- Returns: `201 T24`
|
||||
#### Peek next customer number — `GET /entity/next-number`
|
||||
_none_
|
||||
- Returns: `200 { number: str }`
|
||||
#### List entity addresses — `GET /entity/{entityId}/address`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { addresses: T14[] }`
|
||||
- Returns: `200 { addresses: T15[] }`
|
||||
#### Create entity address — `POST /entity/{entityId}/address`
|
||||
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `201 { address: T14 }`
|
||||
- Returns: `201 { address: T15 }`
|
||||
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `200 { address: T14 }`
|
||||
- Returns: `200 { address: T15 }`
|
||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { contacts: T42[] }`
|
||||
- Returns: `200 { contacts: T44[] }`
|
||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||
- Returns: `201 { contact: T42 }`
|
||||
- Returns: `201 { contact: T44 }`
|
||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||
- Returns: `200 { contact: T42 }`
|
||||
- Returns: `200 { contact: T44 }`
|
||||
#### Delete entity — `DELETE /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get entity — `GET /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T21`
|
||||
- Returns: `200 T24`
|
||||
#### Update entity — `PATCH /entity/{id}`
|
||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||
- Returns: `200 T21`
|
||||
- Returns: `200 T24`
|
||||
|
||||
### Section: /events
|
||||
|
||||
@@ -473,50 +484,74 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||
#### Create label template — `POST /labeltemplate`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T19 }`
|
||||
- Returns: `201 T7`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T22 }`
|
||||
- Returns: `201 T8`
|
||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get label template by ID — `GET /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T7`
|
||||
- Returns: `200 T8`
|
||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
|
||||
- Returns: `200 T7`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
|
||||
- Returns: `200 T8`
|
||||
#### Update label template — `PUT /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
|
||||
- Returns: `200 T7`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
|
||||
- Returns: `200 T8`
|
||||
|
||||
### Section: /ledger
|
||||
|
||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- Returns: `200 T15`
|
||||
- Returns: `200 T16`
|
||||
#### Add ledger entry — `POST /ledger`
|
||||
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
||||
- Returns: `201 { ledger: T16 }`
|
||||
- Returns: `201 { ledger: T17 }`
|
||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 T15`
|
||||
- Returns: `200 T16`
|
||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||
- Params: `sku: str, query: transaction_type?: str`
|
||||
- Returns: `200 T15`
|
||||
- Returns: `200 T16`
|
||||
|
||||
### Section: /mcp
|
||||
|
||||
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### List active MCP client authorizations — `GET /mcp/authorizations`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
|
||||
- Params: `clientId: str`
|
||||
- Returns: `200`
|
||||
#### Get MCP authorization prompt details — `GET /mcp/authorize`
|
||||
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
|
||||
- Returns: `200`
|
||||
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
|
||||
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
|
||||
- Returns: `200`
|
||||
#### List registered MCP clients — `GET /mcp/clients`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
|
||||
- Params: `clientId: str`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /modelconversation
|
||||
|
||||
#### List conversations — `GET /modelconversation`
|
||||
_none_
|
||||
- Returns: `200 { conversations: T30[] }`
|
||||
- Returns: `200 { conversations: T19[] }`
|
||||
#### Create conversation — `POST /modelconversation`
|
||||
- Params: `body: { provider: str, model: str }`
|
||||
- Returns: `201 { conversation: T30 }`
|
||||
- Returns: `201 { conversation: T19 }`
|
||||
#### Delete conversation — `DELETE /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get conversation — `GET /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { conversation: T30 }`
|
||||
- Returns: `200 { conversation: T19 }`
|
||||
#### List conversation messages — `GET /modelconversation/{id}/message`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
||||
@@ -536,35 +571,56 @@ _none_
|
||||
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
|
||||
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
|
||||
|
||||
### Section: /oauth
|
||||
|
||||
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
|
||||
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
|
||||
_no response body_
|
||||
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register`
|
||||
- Params: `body: { redirect_uris: str[], client_name?: str }`
|
||||
- Returns: `201`
|
||||
#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token`
|
||||
- Params: `application/x-www-form-urlencoded`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /order
|
||||
|
||||
#### List orders — `GET /order`
|
||||
- Params: `query: offset?: int, limit?: int`
|
||||
- Returns: `200 { orders: T4[], total: int, offset: int, limit: int }`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
||||
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
|
||||
#### Create order — `POST /order`
|
||||
- Params: `body: { number: str, date: str, lines: T26[], entityId?: int|null }`
|
||||
- Returns: `201 T3`
|
||||
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
|
||||
- Returns: `201 T1`
|
||||
#### Delete order — `DELETE /order/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get order — `GET /order/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T3`
|
||||
- Returns: `200 T1`
|
||||
#### Update order — `PATCH /order/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T26[] }`
|
||||
- Returns: `200 T3`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
|
||||
- Returns: `200 T1`
|
||||
#### List payments of an order — `GET /order/{id}/payment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { payments: T35[] }`
|
||||
- Returns: `200 { payments: T39[] }`
|
||||
#### Add payment to an order — `POST /order/{id}/payment`
|
||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||
- Returns: `201 { payment: T35 }`
|
||||
- Returns: `201 { payment: T39 }`
|
||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipments: T7[] }`
|
||||
#### Create shipment for an order — `POST /order/{id}/shipment`
|
||||
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
|
||||
- Returns: `201 { shipment: T7 }`
|
||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T39[] }`
|
||||
- Returns: `200 { shippings: T33[] }`
|
||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||
- Returns: `201 { shipping: T39 }`
|
||||
- Returns: `201 T28`
|
||||
|
||||
### Section: /package
|
||||
|
||||
@@ -582,37 +638,37 @@ _none_
|
||||
|
||||
#### List POS terminals — `GET /posterminal`
|
||||
_none_
|
||||
- Returns: `200 { posterminals: T11[] }`
|
||||
- Returns: `200 { posterminals: T12[] }`
|
||||
#### Create POS terminal — `PUT /posterminal`
|
||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
||||
- Returns: `200 { posterminal: T11 }`
|
||||
- Returns: `200 { posterminal: T12 }`
|
||||
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update POS terminal — `PATCH /posterminal/{id}`
|
||||
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
|
||||
- Returns: `200 { posterminal: T11 }`
|
||||
- Returns: `200 { posterminal: T12 }`
|
||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T48`
|
||||
- Returns: `200 T50`
|
||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T48`
|
||||
- Returns: `200 T50`
|
||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T48`
|
||||
- Returns: `200 T50`
|
||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T48`
|
||||
- Returns: `200 T50`
|
||||
|
||||
### Section: /printer
|
||||
|
||||
#### List printers — `GET /printer`
|
||||
_none_
|
||||
- Returns: `200 { printers: T25[] }`
|
||||
- Returns: `200 { printers: T29[] }`
|
||||
#### Create printer — `POST /printer`
|
||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `201 T20`
|
||||
- Returns: `201 T23`
|
||||
#### Reorder printers — `PUT /printer/order`
|
||||
- Params: `body: { printerIds: int[] }`
|
||||
- Returns: `200 { printerIds: int[] }`
|
||||
@@ -621,22 +677,22 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get printer by ID — `GET /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T23`
|
||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T23`
|
||||
#### Update printer — `PUT /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T23`
|
||||
|
||||
### Section: /propertyname
|
||||
|
||||
#### List property names — `GET /propertyname`
|
||||
_none_
|
||||
- Returns: `200 { propertynames: T34[] }`
|
||||
- Returns: `200 { propertynames: T38[] }`
|
||||
#### Create property name — `POST /propertyname`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { propertyname: T34 }`
|
||||
- Returns: `201 { propertyname: T38 }`
|
||||
#### Reorder property names — `PUT /propertyname/order`
|
||||
- Params: `body: { propertynameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -645,7 +701,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update property name — `PATCH /propertyname/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { propertyname: T34 }`
|
||||
- Returns: `200 { propertyname: T38 }`
|
||||
|
||||
### Section: /public
|
||||
|
||||
@@ -668,17 +724,17 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
|
||||
#### Create quote — `POST /quote`
|
||||
- Params: `body: { number: str, date: str, lines: T26[], entityId?: int|null }`
|
||||
- Returns: `201 T9`
|
||||
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
|
||||
- Returns: `201 T10`
|
||||
#### Delete quote — `DELETE /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get quote — `GET /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T9`
|
||||
- Returns: `200 T10`
|
||||
#### Update quote — `PATCH /quote/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T26[] }`
|
||||
- Returns: `200 T9`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
|
||||
- Returns: `200 T10`
|
||||
|
||||
### Section: /serper
|
||||
|
||||
@@ -695,14 +751,29 @@ _none_
|
||||
- `done`: `{ "total": 100 }`
|
||||
- `error`: `{ "error": "…" }`
|
||||
|
||||
### Section: /shipment
|
||||
|
||||
#### Delete shipment — `DELETE /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get shipment by ID — `GET /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipment: T7 }`
|
||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T33[] }`
|
||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||
- Returns: `201 T28`
|
||||
|
||||
### Section: /shipping
|
||||
|
||||
#### Delete shipping — `DELETE /shipping/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update shipping — `PATCH /shipping/{id}`
|
||||
- Params: `id: int, body: { type?: str, state?: str, warehouseId?: int }`
|
||||
- Returns: `200 { shipping: T39 }`
|
||||
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||
- Returns: `200 T28`
|
||||
#### List packages of a shipping — `GET /shipping/{id}/package`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
||||
@@ -720,28 +791,28 @@ _none_
|
||||
|
||||
#### Adjust stock manually — `POST /stock/adjust`
|
||||
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T17[] }`
|
||||
|
||||
### Section: /supplier
|
||||
|
||||
#### List suppliers — `GET /supplier`
|
||||
_none_
|
||||
- Returns: `200 { suppliers: T43[] }`
|
||||
- Returns: `200 { suppliers: T45[] }`
|
||||
#### Create supplier — `POST /supplier`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { supplier: T43 }`
|
||||
- Returns: `201 { supplier: T45 }`
|
||||
#### Delete supplier — `DELETE /supplier/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update supplier — `PATCH /supplier/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { supplier: T43 }`
|
||||
- Returns: `200 { supplier: T45 }`
|
||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||
- Params: `id: int, query: skus?: string`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
|
||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||
- Params: `id: int, body?: { skus?: str[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T46|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
|
||||
|
||||
### Section: /thumb
|
||||
|
||||
@@ -756,10 +827,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### List units — `GET /unit`
|
||||
_none_
|
||||
- Returns: `200 { units: T32[] }`
|
||||
- Returns: `200 { units: T36[] }`
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: T32 }`
|
||||
- Returns: `200 { unit: T36 }`
|
||||
|
||||
### Section: /v1
|
||||
|
||||
@@ -810,10 +881,10 @@ _none_
|
||||
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T47[] }`
|
||||
- Returns: `200 { warehouses: T49[] }`
|
||||
#### Create warehouse — `POST /warehouse`
|
||||
- Params: `body: { name: str, sort?: int }`
|
||||
- Returns: `201 T45`
|
||||
- Returns: `201 T47`
|
||||
#### Reorder warehouses — `PUT /warehouse/order`
|
||||
- Params: `body: { warehouseIds: int[] }`
|
||||
- Returns: `200 { warehouseIds: int[] }`
|
||||
@@ -822,10 +893,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T45`
|
||||
- Returns: `200 T47`
|
||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T45`
|
||||
- Returns: `200 T47`
|
||||
#### Update warehouse — `PUT /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T45`
|
||||
- Returns: `200 T47`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview
|
||||
|
||||
Overview of all 163 API endpoints across all modules.
|
||||
Overview of all 182 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -9,53 +9,64 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
||||
|
||||
### Reusable types
|
||||
|
||||
- `T1` = `{ article: T2 }`
|
||||
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T30, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T36[], suppliers: T12[] }`
|
||||
- `T3` = `{ order: T4 }`
|
||||
- `T4` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T21[], createdAt?: str, shipping?: T35|null, shippings?: T35[], payment?: T33|null, payments?: T33[], paid?: bool, delivered?: bool }`
|
||||
- `T1` = `{ order: T2 }`
|
||||
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str, shipping?: T30|null, shippings?: T30[], payment?: T36|null, payments?: T36[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
|
||||
- `T3` = `{ article: T4 }`
|
||||
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T33, brand: T31|null, categoryIds: int[], photos: T28[], eans: str[], prices: T38[], suppliers: T13[] }`
|
||||
- `T5` = `{ delivery: T6 }`
|
||||
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T21[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
|
||||
- `T7` = `{ labeltemplate: T8 }`
|
||||
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T17, thumb?: str|null }`
|
||||
- `T9` = `{ quote: T11 }`
|
||||
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T21[], createdAt?: str }`
|
||||
- `T12` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }`
|
||||
- `T13` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T14` = `{ ledger: T15[] }`
|
||||
- `T15` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T16` = `{ id: int, name: str, sort: int, exclusive: bool, values: T31[], articleCount?: int }`
|
||||
- `T17` = `{ objects: T22[] }`
|
||||
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T22[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T42 }`
|
||||
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T22[], shippings: T30[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
|
||||
- `T8` = `{ labeltemplate: T9 }`
|
||||
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T18, thumb?: str|null }`
|
||||
- `T10` = `{ quote: T12 }`
|
||||
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T12` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T22[], createdAt?: str }`
|
||||
- `T13` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }`
|
||||
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T15` = `{ ledger: T16[] }`
|
||||
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[], articleCount?: int }`
|
||||
- `T18` = `{ objects: T23[] }`
|
||||
- `T19` = `{ printer: T24 }`
|
||||
- `T20` = `{ entity: T27 }`
|
||||
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T19` = `{ objects: T24[] }`
|
||||
- `T20` = `{ printer: T26 }`
|
||||
- `T21` = `{ entity: T29 }`
|
||||
- `T22` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T24` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T25` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T26` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T29` = `{ article: T34 }`
|
||||
- `T30` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T31` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T34` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T35` = `{ id: int, manifestId: int, number: str, type: str, state: str }`
|
||||
- `T36` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T41` = `{ warehouse: T43 }`
|
||||
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T43` = `{ id: int, name: str, sort: int }`
|
||||
- `T24` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T25` = `{ shipping: T30 }`
|
||||
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T27` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T28` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T29` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T30` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T31` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T32` = `{ article: T37 }`
|
||||
- `T33` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T34` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T35` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T36` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T37` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T38` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T39` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T40` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T41` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T42` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T43` = `{ warehouse: T45 }`
|
||||
- `T44` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T45` = `{ id: int, name: str, sort: int }`
|
||||
|
||||
### Routes
|
||||
|
||||
|
||||
### Section: /.well-known
|
||||
|
||||
#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /account
|
||||
|
||||
#### List accounts — `GET /account`
|
||||
@@ -72,16 +83,16 @@ _none_
|
||||
|
||||
#### Create article in category — `POST /article`
|
||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||
- Returns: `201 T29`
|
||||
- Returns: `201 T32`
|
||||
#### Delete article by SKU — `DELETE /article/{sku}`
|
||||
- Params: `sku: str, query: cascade?: bool`
|
||||
- Returns: `204 void`
|
||||
#### Get article by SKU — `GET /article/{sku}`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Update article by SKU — `PATCH /article/{sku}`
|
||||
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
|
||||
@@ -96,25 +107,25 @@ _none_
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
|
||||
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
|
||||
- Params: `sku: str, body: { brandId: int|null }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
|
||||
- Params: `sku: str, body: { categoryIds: int[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### List child articles of a parent article — `GET /article/{sku}/children`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
||||
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||
- Returns: `201 T29`
|
||||
- Returns: `201 T32`
|
||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
||||
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||
- Returns: `200 { article: T34, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
- Returns: `200 { article: T37, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||
- Returns: `200 { success: bool, deleted: bool }`
|
||||
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
|
||||
- Params: `sku: str, body: { eans: str[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Get article history by SKU — `GET /article/{sku}/history`
|
||||
- Params: `sku: str, query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
|
||||
@@ -132,10 +143,10 @@ _none_
|
||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||
- Returns: `200 T29`
|
||||
- Returns: `200 T32`
|
||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Print article label — `POST /article/{sku}/print`
|
||||
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
|
||||
- Returns: `200 { success: bool }`
|
||||
@@ -150,10 +161,10 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List article suppliers — `GET /article/{sku}/supplier`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { suppliers: T12[] }`
|
||||
- Returns: `200 { suppliers: T13[] }`
|
||||
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
||||
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||
- Returns: `201 { supplier: T12 }`
|
||||
- Returns: `201 { supplier: T13 }`
|
||||
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
||||
- Params: `sku: str, body: { ids: int[] }`
|
||||
- Returns: `200 { ids: int[] }`
|
||||
@@ -162,31 +173,31 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
||||
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||
- Returns: `200 { supplier: T12 }`
|
||||
- Returns: `200 { supplier: T13 }`
|
||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||
- Params: `sku: str, body: { track_stock: bool }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
#### Set article weight by SKU — `PUT /article/{sku}/weight`
|
||||
- Params: `sku: str, body: { weight: num }`
|
||||
- Returns: `200 T1`
|
||||
- Returns: `200 T3`
|
||||
|
||||
### Section: /attributename
|
||||
|
||||
#### List attribute names — `GET /attributename`
|
||||
_none_
|
||||
- Returns: `200 { attributenames: T16[] }`
|
||||
- Returns: `200 { attributenames: T17[] }`
|
||||
#### Create attribute name — `POST /attributename`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { attributename: T16 }`
|
||||
- Returns: `201 { attributename: T17 }`
|
||||
#### Reorder attribute names — `PUT /attributename/order`
|
||||
- Params: `body: { attributenameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int`
|
||||
- Returns: `200 { values: T31[] }`
|
||||
- Returns: `200 { values: T34[] }`
|
||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||
- Returns: `201 { attributevalue: T31 }`
|
||||
- Returns: `201 { attributevalue: T34 }`
|
||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -195,7 +206,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute name — `PATCH /attributename/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { attributename: T16 }`
|
||||
- Returns: `200 { attributename: T17 }`
|
||||
|
||||
### Section: /attributevalue
|
||||
|
||||
@@ -204,13 +215,13 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||
- Returns: `200 { attributevalue: T31 }`
|
||||
- Returns: `200 { attributevalue: T34 }`
|
||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T39[] } }`
|
||||
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
|
||||
|
||||
### Section: /auth
|
||||
|
||||
@@ -228,7 +239,7 @@ _none_
|
||||
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
||||
#### Create brand — `POST /brand`
|
||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T39[], gsprpinfo?: str|null } }`
|
||||
#### Delete brand — `DELETE /brand/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -237,10 +248,10 @@ _none_
|
||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T39[] } }`
|
||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T39[] } }`
|
||||
|
||||
### Section: /category
|
||||
|
||||
@@ -252,7 +263,7 @@ _none_
|
||||
- Returns: `200 { categoryIds: int[] }`
|
||||
#### Category tree — `GET /category/tree`
|
||||
_none_
|
||||
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }[], articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T28|null }[] }`
|
||||
- Returns: `200 { languageId: int, tree: { id: int, sort: int, posterminalIds: int[], parentId?: int|null, name?: str|null, slug?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, articleCount?: int }[], articleCount: int }`
|
||||
#### Delete category — `DELETE /category/{id}`
|
||||
- Params: `id: int, query: recursive?: bool`
|
||||
- Returns: `204 void`
|
||||
@@ -261,7 +272,7 @@ _none_
|
||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||
#### Articles in category — `GET /category/{id}/articles`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T28|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T31|null }[] }`
|
||||
#### List category attribute values — `GET /category/{id}/attributevalue`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
||||
@@ -285,7 +296,7 @@ _none_
|
||||
|
||||
#### List companies — `GET /company`
|
||||
_none_
|
||||
- Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }`
|
||||
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }[] }`
|
||||
#### Get company history — `GET /company/history`
|
||||
- Params: `query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
||||
@@ -306,7 +317,7 @@ _none_
|
||||
- Returns: `200 { setting: { key: str, value: str|null } }`
|
||||
#### Update company — `PUT /company/{id}`
|
||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `200 { company: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num } }`
|
||||
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num } }`
|
||||
|
||||
### Section: /customergroup
|
||||
|
||||
@@ -315,7 +326,7 @@ _none_
|
||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||
#### Create customer group — `POST /customergroup`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { customergroup: T43 }`
|
||||
- Returns: `201 { customergroup: T45 }`
|
||||
#### Reorder customer groups — `PUT /customergroup/order`
|
||||
- Params: `body: { customergroupIds: int[] }`
|
||||
- Returns: `200 { customergroupIds: int[] }`
|
||||
@@ -324,15 +335,15 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update customer group — `PATCH /customergroup/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { customergroup: T43 }`
|
||||
- Returns: `200 { customergroup: T45 }`
|
||||
|
||||
### Section: /delivery
|
||||
|
||||
#### List deliveries — `GET /delivery`
|
||||
_none_
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[] }`
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T42 }[] }`
|
||||
#### Create delivery — `POST /delivery`
|
||||
- Params: `body: { externalNumber: str, date: str, lines: T25[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Params: `body: { externalNumber: str, date: str, lines: T27[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `201 T5`
|
||||
#### Delete delivery — `DELETE /delivery/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -341,7 +352,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T5`
|
||||
#### Update delivery — `PATCH /delivery/{id}`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T25[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T27[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `200 T5`
|
||||
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int, query: download?: bool`
|
||||
@@ -351,46 +362,46 @@ _none_
|
||||
|
||||
#### List entities — `GET /entity`
|
||||
- Params: `query: customergroupId?: int, role?: str`
|
||||
- Returns: `200 { entities: T27[] }`
|
||||
- Returns: `200 { entities: T29[] }`
|
||||
#### Create entity — `POST /entity`
|
||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||
- Returns: `201 T20`
|
||||
- Returns: `201 T21`
|
||||
#### Peek next customer number — `GET /entity/next-number`
|
||||
_none_
|
||||
- Returns: `200 { number: str }`
|
||||
#### List entity addresses — `GET /entity/{entityId}/address`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { addresses: T13[] }`
|
||||
- Returns: `200 { addresses: T14[] }`
|
||||
#### Create entity address — `POST /entity/{entityId}/address`
|
||||
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `201 { address: T13 }`
|
||||
- Returns: `201 { address: T14 }`
|
||||
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `200 { address: T13 }`
|
||||
- Returns: `200 { address: T14 }`
|
||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { contacts: T38[] }`
|
||||
- Returns: `200 { contacts: T40[] }`
|
||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||
- Returns: `201 { contact: T38 }`
|
||||
- Returns: `201 { contact: T40 }`
|
||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||
- Returns: `200 { contact: T38 }`
|
||||
- Returns: `200 { contact: T40 }`
|
||||
#### Delete entity — `DELETE /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get entity — `GET /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T21`
|
||||
#### Update entity — `PATCH /entity/{id}`
|
||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T21`
|
||||
|
||||
### Section: /labeltemplate
|
||||
|
||||
@@ -398,35 +409,59 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||
#### Create label template — `POST /labeltemplate`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T18 }`
|
||||
- Returns: `201 T7`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T19 }`
|
||||
- Returns: `201 T8`
|
||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get label template by ID — `GET /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T7`
|
||||
- Returns: `200 T8`
|
||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
|
||||
- Returns: `200 T7`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
|
||||
- Returns: `200 T8`
|
||||
#### Update label template — `PUT /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
|
||||
- Returns: `200 T7`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T19 }`
|
||||
- Returns: `200 T8`
|
||||
|
||||
### Section: /ledger
|
||||
|
||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- Returns: `200 T14`
|
||||
- Returns: `200 T15`
|
||||
#### Add ledger entry — `POST /ledger`
|
||||
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
||||
- Returns: `201 { ledger: T15 }`
|
||||
- Returns: `201 { ledger: T16 }`
|
||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 T14`
|
||||
- Returns: `200 T15`
|
||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||
- Params: `sku: str, query: transaction_type?: str`
|
||||
- Returns: `200 T14`
|
||||
- Returns: `200 T15`
|
||||
|
||||
### Section: /mcp
|
||||
|
||||
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### List active MCP client authorizations — `GET /mcp/authorizations`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
|
||||
- Params: `clientId: str`
|
||||
- Returns: `200`
|
||||
#### Get MCP authorization prompt details — `GET /mcp/authorize`
|
||||
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
|
||||
- Returns: `200`
|
||||
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
|
||||
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
|
||||
- Returns: `200`
|
||||
#### List registered MCP clients — `GET /mcp/clients`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
|
||||
- Params: `clientId: str`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /number
|
||||
|
||||
@@ -437,35 +472,56 @@ _none_
|
||||
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
|
||||
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
|
||||
|
||||
### Section: /oauth
|
||||
|
||||
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
|
||||
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
|
||||
_no response body_
|
||||
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register`
|
||||
- Params: `body: { redirect_uris: str[], client_name?: str }`
|
||||
- Returns: `201`
|
||||
#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token`
|
||||
- Params: `application/x-www-form-urlencoded`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /order
|
||||
|
||||
#### List orders — `GET /order`
|
||||
- Params: `query: offset?: int, limit?: int`
|
||||
- Returns: `200 { orders: T4[], total: int, offset: int, limit: int }`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
||||
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
|
||||
#### Create order — `POST /order`
|
||||
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
|
||||
- Returns: `201 T3`
|
||||
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
|
||||
- Returns: `201 T1`
|
||||
#### Delete order — `DELETE /order/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get order — `GET /order/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T3`
|
||||
- Returns: `200 T1`
|
||||
#### Update order — `PATCH /order/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
|
||||
- Returns: `200 T3`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
|
||||
- Returns: `200 T1`
|
||||
#### List payments of an order — `GET /order/{id}/payment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { payments: T33[] }`
|
||||
- Returns: `200 { payments: T36[] }`
|
||||
#### Add payment to an order — `POST /order/{id}/payment`
|
||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||
- Returns: `201 { payment: T33 }`
|
||||
- Returns: `201 { payment: T36 }`
|
||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipments: T7[] }`
|
||||
#### Create shipment for an order — `POST /order/{id}/shipment`
|
||||
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
|
||||
- Returns: `201 { shipment: T7 }`
|
||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T35[] }`
|
||||
- Returns: `200 { shippings: T30[] }`
|
||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||
- Returns: `201 { shipping: T35 }`
|
||||
- Returns: `201 T25`
|
||||
|
||||
### Section: /package
|
||||
|
||||
@@ -483,10 +539,10 @@ _none_
|
||||
|
||||
#### List printers — `GET /printer`
|
||||
_none_
|
||||
- Returns: `200 { printers: T24[] }`
|
||||
- Returns: `200 { printers: T26[] }`
|
||||
#### Create printer — `POST /printer`
|
||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `201 T19`
|
||||
- Returns: `201 T20`
|
||||
#### Reorder printers — `PUT /printer/order`
|
||||
- Params: `body: { printerIds: int[] }`
|
||||
- Returns: `200 { printerIds: int[] }`
|
||||
@@ -495,22 +551,22 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get printer by ID — `GET /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T19`
|
||||
- Returns: `200 T20`
|
||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T19`
|
||||
- Returns: `200 T20`
|
||||
#### Update printer — `PUT /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T19`
|
||||
- Returns: `200 T20`
|
||||
|
||||
### Section: /propertyname
|
||||
|
||||
#### List property names — `GET /propertyname`
|
||||
_none_
|
||||
- Returns: `200 { propertynames: T32[] }`
|
||||
- Returns: `200 { propertynames: T35[] }`
|
||||
#### Create property name — `POST /propertyname`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { propertyname: T32 }`
|
||||
- Returns: `201 { propertyname: T35 }`
|
||||
#### Reorder property names — `PUT /propertyname/order`
|
||||
- Params: `body: { propertynameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -519,7 +575,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update property name — `PATCH /propertyname/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { propertyname: T32 }`
|
||||
- Returns: `200 { propertyname: T35 }`
|
||||
|
||||
### Section: /quote
|
||||
|
||||
@@ -527,17 +583,32 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
|
||||
#### Create quote — `POST /quote`
|
||||
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
|
||||
- Returns: `201 T9`
|
||||
- Params: `body: { number: str, date: str, lines: T27[], entityId?: int|null }`
|
||||
- Returns: `201 T10`
|
||||
#### Delete quote — `DELETE /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get quote — `GET /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T9`
|
||||
- Returns: `200 T10`
|
||||
#### Update quote — `PATCH /quote/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
|
||||
- Returns: `200 T9`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T27[] }`
|
||||
- Returns: `200 T10`
|
||||
|
||||
### Section: /shipment
|
||||
|
||||
#### Delete shipment — `DELETE /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get shipment by ID — `GET /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipment: T7 }`
|
||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T30[] }`
|
||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||
- Returns: `201 T25`
|
||||
|
||||
### Section: /shipping
|
||||
|
||||
@@ -545,8 +616,8 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update shipping — `PATCH /shipping/{id}`
|
||||
- Params: `id: int, body: { type?: str, state?: str, warehouseId?: int }`
|
||||
- Returns: `200 { shipping: T35 }`
|
||||
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||
- Returns: `200 T25`
|
||||
#### List packages of a shipping — `GET /shipping/{id}/package`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
||||
@@ -564,28 +635,28 @@ _none_
|
||||
|
||||
#### Adjust stock manually — `POST /stock/adjust`
|
||||
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
|
||||
|
||||
### Section: /supplier
|
||||
|
||||
#### List suppliers — `GET /supplier`
|
||||
_none_
|
||||
- Returns: `200 { suppliers: T39[] }`
|
||||
- Returns: `200 { suppliers: T41[] }`
|
||||
#### Create supplier — `POST /supplier`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { supplier: T39 }`
|
||||
- Returns: `201 { supplier: T41 }`
|
||||
#### Delete supplier — `DELETE /supplier/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update supplier — `PATCH /supplier/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { supplier: T39 }`
|
||||
- Returns: `200 { supplier: T41 }`
|
||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||
- Params: `id: int, query: skus?: string`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
|
||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||
- Params: `id: int, body?: { skus?: str[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T44|null }[] }`
|
||||
|
||||
### Section: /unit
|
||||
|
||||
@@ -594,19 +665,19 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### List units — `GET /unit`
|
||||
_none_
|
||||
- Returns: `200 { units: T30[] }`
|
||||
- Returns: `200 { units: T33[] }`
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: T30 }`
|
||||
- Returns: `200 { unit: T33 }`
|
||||
|
||||
### Section: /warehouse
|
||||
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T43[] }`
|
||||
- Returns: `200 { warehouses: T45[] }`
|
||||
#### Create warehouse — `POST /warehouse`
|
||||
- Params: `body: { name: str, sort?: int }`
|
||||
- Returns: `201 T41`
|
||||
- Returns: `201 T43`
|
||||
#### Reorder warehouses — `PUT /warehouse/order`
|
||||
- Params: `body: { warehouseIds: int[] }`
|
||||
- Returns: `200 { warehouseIds: int[] }`
|
||||
@@ -615,13 +686,13 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T41`
|
||||
- Returns: `200 T43`
|
||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T41`
|
||||
- Returns: `200 T43`
|
||||
#### Update warehouse — `PUT /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T41`
|
||||
- Returns: `200 T43`
|
||||
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
|
||||
|
||||
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:
|
||||
|
||||
15
docs/api/routes/.well-known/oauth-authorization-server.md
Normal file
15
docs/api/routes/.well-known/oauth-authorization-server.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `GET /.well-known/oauth-authorization-server`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
|
||||
Returns OAuth 2.0 authorization server endpoints and capabilities.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Authorization Server Metadata
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
15
docs/api/routes/.well-known/oauth-protected-resource.md
Normal file
15
docs/api/routes/.well-known/oauth-protected-resource.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `GET /.well-known/oauth-protected-resource`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
||||
|
||||
Returns protected resource metadata discovering the authorization server for the MCP endpoint.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Protected Resource Metadata
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
@@ -8,7 +8,7 @@ Returns the category tree for the authenticated user's selected company.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Tree and articles for the selected company
|
||||
Tree and article count for the selected company
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -24,39 +24,13 @@ Tree and articles for the selected company
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…",
|
||||
"articleCount": 1,
|
||||
"posterminalIds": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"slug": "…",
|
||||
"net": 1,
|
||||
"taxRate": 1,
|
||||
"track_stock": true,
|
||||
"optionsCount": 1,
|
||||
"articleListCount": 1,
|
||||
"childCount": 1,
|
||||
"parentArticleId": 1,
|
||||
"parentSku": "…",
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…",
|
||||
"brand": {
|
||||
"id": 1,
|
||||
"name": "…",
|
||||
"fullHash": "…",
|
||||
"thumbW": 1,
|
||||
"thumbH": 1,
|
||||
"thumbHash": "…"
|
||||
}
|
||||
}
|
||||
]
|
||||
"articleCount": 1
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -32,10 +32,13 @@ Created; selected company updated
|
||||
{
|
||||
"company": {
|
||||
"id": 1,
|
||||
"iso639": "…",
|
||||
"name": "…",
|
||||
"rootCategoryId": 1,
|
||||
"languageId": 1,
|
||||
"iso639": "…",
|
||||
"taxrate": 1,
|
||||
"reducedtaxrate": 1
|
||||
"reducedtaxrate": 1,
|
||||
"taxfreerate": 1
|
||||
},
|
||||
"user": {
|
||||
"id": 1,
|
||||
|
||||
@@ -15,10 +15,13 @@ Company list
|
||||
"companies": [
|
||||
{
|
||||
"id": 1,
|
||||
"iso639": "…",
|
||||
"name": "…",
|
||||
"rootCategoryId": 1,
|
||||
"languageId": 1,
|
||||
"iso639": "…",
|
||||
"taxrate": 1,
|
||||
"reducedtaxrate": 1
|
||||
"reducedtaxrate": 1,
|
||||
"taxfreerate": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,10 +38,13 @@ Updated
|
||||
{
|
||||
"company": {
|
||||
"id": 1,
|
||||
"iso639": "…",
|
||||
"name": "…",
|
||||
"rootCategoryId": 1,
|
||||
"languageId": 1,
|
||||
"iso639": "…",
|
||||
"taxrate": 1,
|
||||
"reducedtaxrate": 1
|
||||
"reducedtaxrate": 1,
|
||||
"taxfreerate": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
21
docs/api/routes/mcp.md
Normal file
21
docs/api/routes/mcp.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `POST /mcp`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Model Context Protocol (MCP) Streamable HTTP endpoint
|
||||
|
||||
Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization.
|
||||
|
||||
## Response `200`
|
||||
|
||||
MCP JSON-RPC response
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | Missing or invalid authorization token |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
21
docs/api/routes/mcp/authorizations.md
Normal file
21
docs/api/routes/mcp/authorizations.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `GET /mcp/authorizations`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List active MCP client authorizations
|
||||
|
||||
Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of active user authorizations
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | User not authenticated |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
28
docs/api/routes/mcp/authorizations/_clientId.md
Normal file
28
docs/api/routes/mcp/authorizations/_clientId.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# `DELETE /mcp/authorizations/{clientId}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Revoke MCP client authorizations
|
||||
|
||||
Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `clientId` | yes | string | |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Authorizations revoked
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Missing clientId |
|
||||
| 401 | User not authenticated |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
35
docs/api/routes/mcp/authorize.get.md
Normal file
35
docs/api/routes/mcp/authorize.get.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# `GET /mcp/authorize`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get MCP authorization prompt details
|
||||
|
||||
Fetches client information and current user context for the frontend MCP authorization consent screen.
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `client_id` | yes | string | |
|
||||
| `redirect_uri` | yes | string | |
|
||||
| `response_type` | no | string | |
|
||||
| `code_challenge` | no | string | |
|
||||
| `code_challenge_method` | no | string | |
|
||||
| `state` | no | string | |
|
||||
| `scope` | no | string | |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Client and user consent metadata
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid request or unregistered redirect_uri |
|
||||
| 401 | User not authenticated |
|
||||
| 404 | Client not found |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
47
docs/api/routes/mcp/authorize.post.md
Normal file
47
docs/api/routes/mcp/authorize.post.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# `POST /mcp/authorize`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Grant or deny MCP client authorization
|
||||
|
||||
Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `client_id` | yes | string | |
|
||||
| `redirect_uri` | yes | string | |
|
||||
| `code_challenge` | no | string | |
|
||||
| `code_challenge_method` | no | string | |
|
||||
| `state` | no | string | |
|
||||
| `scope` | no | string | |
|
||||
| `action` | yes | string | |
|
||||
|
||||
```json
|
||||
{
|
||||
"client_id": "…",
|
||||
"redirect_uri": "…",
|
||||
"code_challenge": "…",
|
||||
"code_challenge_method": "…",
|
||||
"state": "…",
|
||||
"scope": "…",
|
||||
"action": "allow"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `200`
|
||||
|
||||
Success with redirect URL containing code or error
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid request parameters |
|
||||
| 401 | User not authenticated |
|
||||
| 404 | Client not found |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
21
docs/api/routes/mcp/clients.md
Normal file
21
docs/api/routes/mcp/clients.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# `GET /mcp/clients`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List registered MCP clients
|
||||
|
||||
Lists all registered OAuth MCP clients.
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of registered clients
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | User not authenticated |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
28
docs/api/routes/mcp/clients/_clientId.md
Normal file
28
docs/api/routes/mcp/clients/_clientId.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# `DELETE /mcp/clients/{clientId}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Delete registered MCP client
|
||||
|
||||
Deletes an MCP client registration.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `clientId` | yes | string | |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Client deleted
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Missing clientId |
|
||||
| 401 | User not authenticated |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
@@ -20,7 +20,9 @@ List of conversations
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
"state": "idle",
|
||||
"started_by": "…",
|
||||
"first_message": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ Created conversation
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
"state": "idle",
|
||||
"started_by": "…",
|
||||
"first_message": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -25,7 +25,9 @@ Conversation details
|
||||
"model": "…",
|
||||
"created_at": "…",
|
||||
"updated_at": "…",
|
||||
"state": "idle"
|
||||
"state": "idle",
|
||||
"started_by": "…",
|
||||
"first_message": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
28
docs/api/routes/oauth/authorize.md
Normal file
28
docs/api/routes/oauth/authorize.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# `GET /oauth/authorize`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
||||
|
||||
Redirects browser to frontend authorization consent page.
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `client_id` | no | string | |
|
||||
| `redirect_uri` | no | string | |
|
||||
| `response_type` | no | string | |
|
||||
| `code_challenge` | no | string | |
|
||||
| `code_challenge_method` | no | string | |
|
||||
| `state` | no | string | |
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 302 | Redirects to frontend /oauth/authorize |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
15
docs/api/routes/oauth/introspect.md
Normal file
15
docs/api/routes/oauth/introspect.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `POST /oauth/introspect`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
OAuth 2.0 Token Introspection (RFC 7662)
|
||||
|
||||
Verifies the validity of an issued access token.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Token status and metadata
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
31
docs/api/routes/oauth/register.md
Normal file
31
docs/api/routes/oauth/register.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# `POST /oauth/register`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
Dynamic Client Registration (RFC 7591)
|
||||
|
||||
Registers a new OAuth MCP client dynamically.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `client_name` | no | string | |
|
||||
| `redirect_uris` | yes | string[] | |
|
||||
|
||||
```json
|
||||
{
|
||||
"client_name": "…",
|
||||
"redirect_uris": [
|
||||
"…"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Registered client credentials
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
30
docs/api/routes/oauth/token.md
Normal file
30
docs/api/routes/oauth/token.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# `POST /oauth/token`
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636)
|
||||
|
||||
Exchanges authorization code with PKCE for an access token, or refreshes an expired token.
|
||||
|
||||
## Body
|
||||
|
||||
`application/x-www-form-urlencoded`
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "…",
|
||||
"redirect_uri": "…",
|
||||
"client_id": "…",
|
||||
"code_verifier": "…",
|
||||
"refresh_token": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Response `200`
|
||||
|
||||
Issued access token and refresh token
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
71
docs/api/routes/order/_id/shipment.get.md
Normal file
71
docs/api/routes/order/_id/shipment.get.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# `GET /order/{id}/shipment`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List shipments of an order
|
||||
|
||||
Lists all shipment manifests belonging to the specified order.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Order ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of shipments
|
||||
|
||||
```json
|
||||
{
|
||||
"shipments": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Order not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
108
docs/api/routes/order/_id/shipment.post.md
Normal file
108
docs/api/routes/order/_id/shipment.post.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `POST /order/{id}/shipment`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create shipment for an order
|
||||
|
||||
Creates a new partial or full shipment manifest for the order.
|
||||
Validates that article positions belong to the order and cumulative shipped quantities
|
||||
do not exceed ordered quantities.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Order ID |
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `date` | no | string | |
|
||||
| `externalNumber` | no | string | |
|
||||
| `lines` | yes | object[] | |
|
||||
| `lines[].sku` | yes | string | |
|
||||
| `lines[].amount` | yes | number | |
|
||||
| `lines[].name` | no | string | |
|
||||
| `lines[].pu` | no | number | |
|
||||
| `lines[].net` | no | number | |
|
||||
| `lines[].rebate` | no | number | |
|
||||
| `lines[].taxrate` | no | number | |
|
||||
| `lines[].comment` | no | string | |
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"externalNumber": "…",
|
||||
"lines": [
|
||||
{
|
||||
"sku": "…",
|
||||
"amount": 1,
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created shipment
|
||||
|
||||
```json
|
||||
{
|
||||
"shipment": {
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Validation error or article not in order — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Order not found — `{"error":"…"}` |
|
||||
| 409 | Cumulative shipment amount exceeds ordered amount — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
@@ -23,6 +23,8 @@ List of shippings
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ Created shipping
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ Created order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
},
|
||||
@@ -75,6 +77,8 @@ Created order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
@@ -96,7 +100,49 @@ Created order
|
||||
}
|
||||
],
|
||||
"paid": true,
|
||||
"delivered": true
|
||||
"shipped": true,
|
||||
"delivered": true,
|
||||
"orderTotal": 1,
|
||||
"paidTotal": 1,
|
||||
"orderedQuantity": 1,
|
||||
"shippedQuantity": 1,
|
||||
"shipments": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,6 +42,8 @@ Order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
},
|
||||
@@ -50,6 +52,8 @@ Order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
@@ -71,7 +75,49 @@ Order
|
||||
}
|
||||
],
|
||||
"paid": true,
|
||||
"delivered": true
|
||||
"shipped": true,
|
||||
"delivered": true,
|
||||
"orderTotal": 1,
|
||||
"paidTotal": 1,
|
||||
"orderedQuantity": 1,
|
||||
"shippedQuantity": 1,
|
||||
"shipments": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,6 +15,10 @@ Results are paged, include line items, shipping, payment, and provide total unpa
|
||||
|-------|----------|------|-------------|
|
||||
| `offset` | no | integer | Number of orders to skip |
|
||||
| `limit` | no | integer | Number of orders to return |
|
||||
| `search` | no | string | Free-text search matching order number, external number, or customer name |
|
||||
| `onlyUnpaid` | no | boolean | When true, returns only unpaid orders (including overpaid orders) |
|
||||
| `onlyUndelivered` | no | boolean | When true, returns only undelivered orders |
|
||||
| `seekMonths` | no | integer | Negative integer for cutoff months ago (e.g. -1 for orders older than 1 month) |
|
||||
|
||||
## Response `200`
|
||||
|
||||
@@ -47,6 +51,8 @@ Order list
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
},
|
||||
@@ -55,6 +61,8 @@ Order list
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
@@ -76,7 +84,49 @@ Order list
|
||||
}
|
||||
],
|
||||
"paid": true,
|
||||
"delivered": true
|
||||
"shipped": true,
|
||||
"delivered": true,
|
||||
"orderTotal": 1,
|
||||
"paidTotal": 1,
|
||||
"orderedQuantity": 1,
|
||||
"shippedQuantity": 1,
|
||||
"shipments": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
@@ -89,7 +139,7 @@ Order list
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid offset, invalid limit, or no company selected — `{"error":"…"}` |
|
||||
| 400 | Invalid query parameters, invalid offset, invalid limit, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Company not found — `{"error":"…"}` |
|
||||
|
||||
@@ -61,6 +61,8 @@ Updated order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
},
|
||||
@@ -69,6 +71,8 @@ Updated order
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
@@ -90,7 +94,49 @@ Updated order
|
||||
}
|
||||
],
|
||||
"paid": true,
|
||||
"delivered": true
|
||||
"shipped": true,
|
||||
"delivered": true,
|
||||
"orderTotal": 1,
|
||||
"paidTotal": 1,
|
||||
"orderedQuantity": 1,
|
||||
"shippedQuantity": 1,
|
||||
"shipments": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
32
docs/api/routes/shipment/_id.delete.md
Normal file
32
docs/api/routes/shipment/_id.delete.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# `DELETE /shipment/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Delete shipment
|
||||
|
||||
Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Shipment ID |
|
||||
|
||||
## Response `204`
|
||||
|
||||
Shipment deleted
|
||||
|
||||
_No content_
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Shipment not found — `{"error":"…"}` |
|
||||
| 409 | Shipped shipment cannot be deleted — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
69
docs/api/routes/shipment/_id.get.md
Normal file
69
docs/api/routes/shipment/_id.get.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `GET /shipment/{id}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get shipment by ID
|
||||
|
||||
Retrieves shipment header, article positions, and attached packages.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Shipment ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Shipment details
|
||||
|
||||
```json
|
||||
{
|
||||
"shipment": {
|
||||
"id": 1,
|
||||
"number": "…",
|
||||
"type": "shipment",
|
||||
"entityId": 1,
|
||||
"externalNumber": "…",
|
||||
"date": "…",
|
||||
"createdAt": "…",
|
||||
"lineCount": 1,
|
||||
"packageCount": 1,
|
||||
"lines": [
|
||||
{
|
||||
"id": 1,
|
||||
"sku": "…",
|
||||
"name": "…",
|
||||
"pu": 1,
|
||||
"amount": 1,
|
||||
"net": 1,
|
||||
"rebate": 1,
|
||||
"taxrate": 1,
|
||||
"comment": "…"
|
||||
}
|
||||
],
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Shipment not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
45
docs/api/routes/shipment/_id/shipping.get.md
Normal file
45
docs/api/routes/shipment/_id/shipping.get.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# `GET /shipment/{id}/shipping`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List packages of a shipment
|
||||
|
||||
Lists all packages (shippings) belonging to the specified shipment manifest.
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Shipment ID |
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of packages
|
||||
|
||||
```json
|
||||
{
|
||||
"shippings": [
|
||||
{
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid ID or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Shipment not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
66
docs/api/routes/shipment/_id/shipping.post.md
Normal file
66
docs/api/routes/shipment/_id/shipping.post.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# `POST /shipment/{id}/shipping`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create package for a shipment
|
||||
|
||||
Creates a physical package (shipping) for the shipment manifest.
|
||||
Accepts carrier and parcel tracking code. If state is `shipped`,
|
||||
deducts stock for the shipment's lines via ledger movements.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `id` | yes | integer | Shipment ID |
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `type` | yes | string | |
|
||||
| `state` | yes | string | |
|
||||
| `trackingCode` | no | string | Carrier parcel tracking code |
|
||||
| `carrier` | no | string | Carrier name (e.g. DHL, UPS, DPD) |
|
||||
| `warehouseId` | no | integer | Optional warehouse ID for stock deduction when state is shipped |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "…",
|
||||
"state": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"warehouseId": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Response `201`
|
||||
|
||||
Created package
|
||||
|
||||
```json
|
||||
{
|
||||
"shipping": {
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Validation error, manifest is not a shipment, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 404 | Shipment or warehouse not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
@@ -20,12 +20,16 @@ via ledger and article history is written. Once shipped, it cannot be undone.
|
||||
|-------|----------|------|-------------|
|
||||
| `type` | no | string | |
|
||||
| `state` | no | string | |
|
||||
| `trackingCode` | no | string | Carrier parcel tracking code |
|
||||
| `carrier` | no | string | Carrier name (e.g. DHL, UPS, DPD) |
|
||||
| `warehouseId` | no | integer | Warehouse ID for stock deduction when transitioning to shipped |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "…",
|
||||
"state": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"warehouseId": 1
|
||||
}
|
||||
```
|
||||
@@ -40,6 +44,8 @@ Updated shipping
|
||||
"id": 1,
|
||||
"manifestId": 1,
|
||||
"number": "…",
|
||||
"trackingCode": "…",
|
||||
"carrier": "…",
|
||||
"type": "…",
|
||||
"state": "…"
|
||||
}
|
||||
|
||||
781
generate-api-graph.js
Normal file
781
generate-api-graph.js
Normal file
@@ -0,0 +1,781 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||||
import { getOpenApiSpec } from './src/api/openapiSpec.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outputPath = join(__dirname, 'api-graph.svg');
|
||||
const dotOutputPath = join(__dirname, 'api-graph.dot');
|
||||
|
||||
const COLORS = {
|
||||
background: '#1e2430',
|
||||
tableBorder: '#3f4c5f',
|
||||
headerBg: '#33415c',
|
||||
headerText: '#e2e8f0',
|
||||
rowBg: '#2a3242',
|
||||
rowBgAlt: '#272e3d',
|
||||
keyBg: '#2b3a52',
|
||||
text: '#d8dee9',
|
||||
muted: '#8b95a7',
|
||||
clusterFill: '#202a3a',
|
||||
};
|
||||
|
||||
const METHOD_COLORS = {
|
||||
GET: { fg: '#38bdf8', bg: '#182b40' },
|
||||
POST: { fg: '#4ade80', bg: '#143826' },
|
||||
PUT: { fg: '#fbbf24', bg: '#3b2c12' },
|
||||
PATCH: { fg: '#c084fc', bg: '#301c44' },
|
||||
DELETE: { fg: '#f87171', bg: '#3a181e' },
|
||||
};
|
||||
|
||||
const EDGE_COLORS = [
|
||||
'#ff6b6b',
|
||||
'#6bdf9a',
|
||||
'#5aa7ff',
|
||||
'#f5c15c',
|
||||
'#c79bff',
|
||||
'#ff9f5a',
|
||||
'#5cd6d6',
|
||||
'#ff7fc2',
|
||||
'#8f95ff',
|
||||
'#58d18a',
|
||||
'#ff8f66',
|
||||
'#4fc4e8',
|
||||
'#a98cff',
|
||||
'#ff6f6f',
|
||||
'#4ecb8f',
|
||||
'#5aa7e6',
|
||||
'#ff7aa8',
|
||||
'#e0b25a',
|
||||
'#56c8c8',
|
||||
'#ff5f9e',
|
||||
'#ffb25a',
|
||||
'#56d68a',
|
||||
'#66b3ff',
|
||||
'#c79bff',
|
||||
];
|
||||
|
||||
const CLUSTERS = [
|
||||
{
|
||||
id: 'identity',
|
||||
label: 'Identity & Access',
|
||||
resources: ['Auth', 'Company', 'CompanySetting', 'Customergroup', 'Number'],
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
label: 'Entities & CRM',
|
||||
resources: ['Entity'],
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
label: 'Catalog & Products',
|
||||
resources: ['Article', 'Category', 'Brand', 'Unit', 'PublicCatalog'],
|
||||
},
|
||||
{
|
||||
id: 'properties',
|
||||
label: 'Properties & Attributes',
|
||||
resources: ['Property', 'Attribute'],
|
||||
},
|
||||
{
|
||||
id: 'purchasing',
|
||||
label: 'Suppliers & Purchasing',
|
||||
resources: ['Supplier', 'ArticleSupplier', 'Delivery'],
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
label: 'Sales & Fulfillment',
|
||||
resources: ['Quote', 'Order', 'Shipment', 'Shipping', 'Package'],
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
label: 'Inventory & Warehousing',
|
||||
resources: ['Stock', 'Ledger', 'Warehouse'],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finance & Payments',
|
||||
resources: ['Account', 'Payment'],
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
label: 'Media & Storage',
|
||||
resources: ['Image', 'Serper', 'Vfs'],
|
||||
},
|
||||
{
|
||||
id: 'pos',
|
||||
label: 'POS & Hardware',
|
||||
resources: ['POSTerminal', 'Printer', 'LabelTemplate', 'POSSync'],
|
||||
},
|
||||
{
|
||||
id: 'ai_mcp',
|
||||
label: 'AI & Integrations',
|
||||
resources: ['ModelConversation', 'MCP'],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System & Realtime',
|
||||
resources: ['Events', 'Health', 'Telemetry', 'Legend'],
|
||||
},
|
||||
];
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an OpenAPI endpoint to its logical API resource card.
|
||||
*/
|
||||
function assignResource(apiPath, method, tag) {
|
||||
if (apiPath.startsWith('/auth') || apiPath.startsWith('/.well-known')) return 'Auth';
|
||||
if (apiPath.startsWith('/company/setting') || apiPath.startsWith('/companysetting')) return 'CompanySetting';
|
||||
if (apiPath.startsWith('/company')) return 'Company';
|
||||
if (apiPath.startsWith('/customergroup')) return 'Customergroup';
|
||||
if (apiPath.startsWith('/number')) return 'Number';
|
||||
if (apiPath.startsWith('/entity')) return 'Entity';
|
||||
if (apiPath.startsWith('/propertyname')) return 'Property';
|
||||
if (apiPath.startsWith('/attributename') || apiPath.startsWith('/attributevalue')) return 'Attribute';
|
||||
if (apiPath.startsWith('/article/') && apiPath.includes('/supplier')) return 'ArticleSupplier';
|
||||
if (apiPath.startsWith('/article/') && apiPath.includes('/property')) return 'Property';
|
||||
if (apiPath.startsWith('/article/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||||
if (apiPath.startsWith('/article')) return 'Article';
|
||||
if (apiPath.startsWith('/category/') && apiPath.includes('/property')) return 'Property';
|
||||
if (apiPath.startsWith('/category/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||||
if (apiPath.startsWith('/category')) return 'Category';
|
||||
if (apiPath.startsWith('/brand')) return 'Brand';
|
||||
if (apiPath.startsWith('/unit')) return 'Unit';
|
||||
if (apiPath.startsWith('/public')) return 'PublicCatalog';
|
||||
if (apiPath.startsWith('/supplier')) return 'Supplier';
|
||||
if (apiPath.startsWith('/delivery')) return 'Delivery';
|
||||
if (apiPath.startsWith('/quote')) return 'Quote';
|
||||
if (apiPath.startsWith('/order')) return 'Order';
|
||||
if (apiPath.startsWith('/shipment')) return 'Shipment';
|
||||
if (apiPath.startsWith('/shipping')) return 'Shipping';
|
||||
if (apiPath.startsWith('/package')) return 'Package';
|
||||
if (apiPath.startsWith('/stock')) return 'Stock';
|
||||
if (apiPath.startsWith('/ledger')) return 'Ledger';
|
||||
if (apiPath.startsWith('/warehouse')) return 'Warehouse';
|
||||
if (apiPath.startsWith('/account')) return 'Account';
|
||||
if (apiPath.startsWith('/payment')) return 'Payment';
|
||||
if (apiPath.startsWith('/image') || apiPath.startsWith('/thumb') || apiPath.startsWith('/pic')) return 'Image';
|
||||
if (apiPath.startsWith('/serper')) return 'Serper';
|
||||
if (apiPath.startsWith('/vfs')) return 'Vfs';
|
||||
if (apiPath.startsWith('/posterminal')) return 'POSTerminal';
|
||||
if (apiPath.startsWith('/printer')) return 'Printer';
|
||||
if (apiPath.startsWith('/labeltemplate')) return 'LabelTemplate';
|
||||
if (apiPath.startsWith('/v1')) return 'POSSync';
|
||||
if (apiPath.startsWith('/modelconversation')) return 'ModelConversation';
|
||||
if (apiPath.startsWith('/mcp')) return 'MCP';
|
||||
if (apiPath.startsWith('/events')) return 'Events';
|
||||
if (apiPath.startsWith('/health')) return 'Health';
|
||||
if (apiPath.startsWith('/telemetry')) return 'Telemetry';
|
||||
|
||||
return tag || 'Other';
|
||||
}
|
||||
|
||||
function getResourceBasePath(resourceId) {
|
||||
const map = {
|
||||
Auth: '/auth',
|
||||
Company: '/company',
|
||||
CompanySetting: '/companysetting',
|
||||
Customergroup: '/customergroup',
|
||||
Number: '/number',
|
||||
Entity: '/entity',
|
||||
Article: '/article',
|
||||
Category: '/category',
|
||||
Brand: '/brand',
|
||||
Unit: '/unit',
|
||||
PublicCatalog: '/public',
|
||||
Property: '/propertyname',
|
||||
Attribute: '/attributename',
|
||||
Supplier: '/supplier',
|
||||
ArticleSupplier: '/article/{sku}/supplier',
|
||||
Delivery: '/delivery',
|
||||
Quote: '/quote',
|
||||
Order: '/order',
|
||||
Shipment: '/shipment',
|
||||
Shipping: '/shipping',
|
||||
Package: '/package',
|
||||
Stock: '/stock',
|
||||
Ledger: '/ledger',
|
||||
Warehouse: '/warehouse',
|
||||
Account: '/account',
|
||||
Payment: '/payment',
|
||||
Image: '/image',
|
||||
Serper: '/serper',
|
||||
Vfs: '/vfs',
|
||||
POSTerminal: '/posterminal',
|
||||
Printer: '/printer',
|
||||
LabelTemplate: '/labeltemplate',
|
||||
POSSync: '/v1',
|
||||
ModelConversation: '/modelconversation',
|
||||
MCP: '/mcp',
|
||||
Events: '/events',
|
||||
Health: '/health',
|
||||
Telemetry: '/telemetry',
|
||||
Legend: '',
|
||||
};
|
||||
return map[resourceId] ?? `/${resourceId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function cleanSubpath(apiPath, basePath) {
|
||||
if (apiPath === basePath) return '/';
|
||||
if (apiPath.startsWith(basePath)) {
|
||||
const sub = apiPath.slice(basePath.length);
|
||||
return sub.startsWith('/') ? sub : `/${sub}`;
|
||||
}
|
||||
return apiPath;
|
||||
}
|
||||
|
||||
function truncate(str, max = 28) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||||
}
|
||||
|
||||
function sanitizePort(name) {
|
||||
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
}
|
||||
|
||||
function parseApi(spec) {
|
||||
const resources = new Map();
|
||||
|
||||
for (const [apiPath, pathItem] of Object.entries(spec.paths || {})) {
|
||||
for (const [method, op] of Object.entries(pathItem || {})) {
|
||||
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) continue;
|
||||
if (!op || typeof op !== 'object') continue;
|
||||
|
||||
const methodUpper = method.toUpperCase();
|
||||
const tag = Array.isArray(op.tags) && op.tags[0] ? op.tags[0] : '';
|
||||
const resourceId = assignResource(apiPath, methodUpper, tag);
|
||||
const basePath = getResourceBasePath(resourceId);
|
||||
|
||||
if (!resources.has(resourceId)) {
|
||||
resources.set(resourceId, {
|
||||
id: resourceId,
|
||||
basePath,
|
||||
endpoints: [],
|
||||
});
|
||||
}
|
||||
|
||||
const res = resources.get(resourceId);
|
||||
const subpath = cleanSubpath(apiPath, basePath);
|
||||
const summary = op.summary || op.description || '';
|
||||
const portId = sanitizePort(`ep_${methodUpper.toLowerCase()}_${apiPath}`);
|
||||
|
||||
res.endpoints.push({
|
||||
method: methodUpper,
|
||||
path: apiPath,
|
||||
subpath,
|
||||
portId,
|
||||
summary: truncate(summary, 28),
|
||||
operation: op,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort endpoints within each resource: root subpath first, then by method rank, then path
|
||||
const methodRank = { GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5 };
|
||||
for (const res of resources.values()) {
|
||||
res.endpoints.sort((a, b) => {
|
||||
if (a.subpath === '/' && b.subpath !== '/') return -1;
|
||||
if (b.subpath === '/' && a.subpath !== '/') return 1;
|
||||
const rankDiff = (methodRank[a.method] ?? 99) - (methodRank[b.method] ?? 99);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return a.subpath.localeCompare(b.subpath);
|
||||
});
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
function findEndpointPort(res, method, pathSnippet) {
|
||||
if (!res || !res.endpoints) return null;
|
||||
const match = res.endpoints.find((ep) => {
|
||||
if (method && ep.method !== method.toUpperCase()) return false;
|
||||
if (pathSnippet) {
|
||||
return ep.path.includes(pathSnippet) || ep.subpath.includes(pathSnippet);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return match?.portId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds high-fidelity relationships between API resources.
|
||||
*/
|
||||
function buildApiEdges(resources) {
|
||||
const edgeDefs = [
|
||||
// Sales & Fulfillment
|
||||
{ from: 'Order', to: 'Entity', label: 'entityId', method: 'GET', path: '/order/{id}' },
|
||||
{ from: 'Order', to: 'Article', label: 'lines.sku', method: 'POST', path: '/order' },
|
||||
{ from: 'Order', to: 'Shipment', label: 'shipments', method: 'GET', path: '/order/{id}' },
|
||||
{ from: 'Order', to: 'Payment', label: 'payments', method: 'GET', path: '/order/{id}' },
|
||||
{ from: 'Order', to: 'Shipping', label: 'shippings', method: 'GET', path: '/order/{id}' },
|
||||
{ from: 'Quote', to: 'Entity', label: 'entityId', method: 'GET', path: '/quote/{id}' },
|
||||
{ from: 'Quote', to: 'Article', label: 'lines.sku', method: 'POST', path: '/quote' },
|
||||
{ from: 'Quote', to: 'Order', label: 'convert order', method: 'POST', path: '/quote' },
|
||||
{ from: 'Shipment', to: 'Order', label: 'manifestId', method: 'GET', path: '/shipment/{id}' },
|
||||
{ from: 'Shipment', to: 'Article', label: 'lines.sku', method: 'GET', path: '/shipment/{id}' },
|
||||
{ from: 'Shipment', to: 'Shipping', label: 'shippings', method: 'GET', path: '/shipment/{id}/shipping' },
|
||||
{ from: 'Shipping', to: 'Package', label: 'packages', method: 'GET', path: '/shipping/{id}' },
|
||||
{ from: 'Package', to: 'Shipping', label: 'shippingId', method: 'POST', path: '/package' },
|
||||
{ from: 'Package', to: 'Entity', label: 'addressId', method: 'POST', path: '/package' },
|
||||
|
||||
// Purchasing & Goods receipt
|
||||
{ from: 'Delivery', to: 'Supplier', label: 'supplierId', method: 'POST', path: '/delivery' },
|
||||
{ from: 'Delivery', to: 'Article', label: 'lines.sku', method: 'POST', path: '/delivery' },
|
||||
{ from: 'Delivery', to: 'Ledger', label: 'ledger', method: 'GET', path: '/delivery/{id}' },
|
||||
{ from: 'ArticleSupplier', to: 'Supplier', label: 'entityId', method: 'POST', path: '/supplier' },
|
||||
{ from: 'ArticleSupplier', to: 'Article', label: 'sku', method: 'GET', path: '/supplier' },
|
||||
{ from: 'Supplier', to: 'Entity', label: 'entityId', method: 'GET', path: '/supplier/{id}' },
|
||||
|
||||
// Inventory & Warehousing
|
||||
{ from: 'Stock', to: 'Article', label: 'sku', method: 'POST', path: '/stock' },
|
||||
{ from: 'Stock', to: 'Warehouse', label: 'warehouseId', method: 'POST', path: '/stock' },
|
||||
{ from: 'Stock', to: 'Ledger', label: 'ledger', method: 'POST', path: '/stock' },
|
||||
{ from: 'Ledger', to: 'Article', label: 'sku', method: 'GET', path: '/ledger' },
|
||||
{ from: 'Ledger', to: 'Warehouse', label: 'warehouseId', method: 'GET', path: '/ledger' },
|
||||
|
||||
// Catalog & Products
|
||||
{ from: 'Article', to: 'Category', label: 'categoryId', method: 'POST', path: '/article' },
|
||||
{ from: 'Article', to: 'Brand', label: 'brand', method: 'PUT', path: '/brand' },
|
||||
{ from: 'Article', to: 'Unit', label: 'unitSi', method: 'PATCH', path: '/article/{sku}' },
|
||||
{ from: 'Article', to: 'ArticleSupplier', label: 'suppliers', method: 'GET', path: '/article/{sku}' },
|
||||
{ from: 'Article', to: 'Image', label: 'photos', method: 'POST', path: '/image' },
|
||||
{ from: 'Article', to: 'Customergroup', label: 'prices', method: 'PUT', path: '/prices' },
|
||||
{ from: 'Article', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||||
{ from: 'Article', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||||
{ from: 'Article', to: 'Article', label: 'bundles & variants', method: 'GET', path: '/children' },
|
||||
|
||||
{ from: 'Category', to: 'Category', label: 'parentId', method: 'GET', path: '/category' },
|
||||
{ from: 'Category', to: 'Article', label: 'articles', method: 'GET', path: '/category/{id}/article' },
|
||||
{ from: 'Category', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||
{ from: 'Category', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||||
{ from: 'Category', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||||
|
||||
{ from: 'Brand', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||
|
||||
// Properties & Attributes
|
||||
{ from: 'Property', to: 'Company', label: 'companyId', method: 'GET', path: '/propertyname' },
|
||||
{ from: 'Attribute', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||||
|
||||
// Finance & Payments
|
||||
{ from: 'Payment', to: 'Account', label: 'accountId', method: 'POST', path: '/payment' },
|
||||
{ from: 'Payment', to: 'Order', label: 'manifestId', method: 'POST', path: '/payment' },
|
||||
{ from: 'Account', to: 'Company', label: 'companyId', method: 'GET', path: '/account' },
|
||||
|
||||
// CRM & Company
|
||||
{ from: 'Entity', to: 'Customergroup', label: 'customergroupId', method: 'POST', path: '/entity' },
|
||||
{ from: 'Company', to: 'Customergroup', label: 'poscustomergroup', method: 'GET', path: '/company/{id}' },
|
||||
{ from: 'Company', to: 'Category', label: 'root_category', method: 'GET', path: '/company/{id}' },
|
||||
{ from: 'Company', to: 'CompanySetting', label: 'settings', method: 'GET', path: '/company/{id}' },
|
||||
{ from: 'Number', to: 'Company', label: 'companyId', method: 'GET', path: '/number' },
|
||||
|
||||
// POS & Hardware
|
||||
{ from: 'POSTerminal', to: 'Company', label: 'companyId', method: 'GET', path: '/posterminal' },
|
||||
{ from: 'POSTerminal', to: 'Category', label: 'root_category', method: 'GET', path: '/posterminal' },
|
||||
{ from: 'Printer', to: 'Company', label: 'companyId', method: 'GET', path: '/printer' },
|
||||
{ from: 'Printer', to: 'LabelTemplate', label: 'templates', method: 'GET', path: '/printer' },
|
||||
{ from: 'LabelTemplate', to: 'Company', label: 'companyId', method: 'GET', path: '/labeltemplate' },
|
||||
{ from: 'POSSync', to: 'POSTerminal', label: 'client bind', method: 'POST', path: '/client' },
|
||||
{ from: 'POSSync', to: 'Article', label: 'product sync', method: 'GET', path: '/product' },
|
||||
{ from: 'POSSync', to: 'Category', label: 'category sync', method: 'GET', path: '/category' },
|
||||
|
||||
// AI, Tools & Public
|
||||
{ from: 'PublicCatalog', to: 'Article', label: 'articles', method: 'GET', path: '/product' },
|
||||
{ from: 'PublicCatalog', to: 'Category', label: 'category', method: 'GET', path: '/category' },
|
||||
{ from: 'ModelConversation', to: 'Vfs', label: 'vfs workspace', method: 'GET', path: '/modelconversation' },
|
||||
{ from: 'MCP', to: 'Article', label: 'article tools', method: 'POST', path: '/mcp' },
|
||||
{ from: 'MCP', to: 'Order', label: 'order tools', method: 'POST', path: '/mcp' },
|
||||
{ from: 'Events', to: 'Stock', label: 'stock sse', method: 'GET', path: '/events' },
|
||||
{ from: 'Events', to: 'Ledger', label: 'ledger sse', method: 'GET', path: '/events' },
|
||||
{ from: 'Events', to: 'Article', label: 'article sse', method: 'GET', path: '/events' },
|
||||
];
|
||||
|
||||
const edges = [];
|
||||
for (const def of edgeDefs) {
|
||||
if (!resources.has(def.from) || !resources.has(def.to)) continue;
|
||||
const resFrom = resources.get(def.from);
|
||||
const resolvedPort = findEndpointPort(resFrom, def.method, def.path);
|
||||
|
||||
edges.push({
|
||||
from: def.from,
|
||||
to: def.to,
|
||||
label: def.label,
|
||||
port: resolvedPort,
|
||||
tooltip: `${def.from} → ${def.to} (${def.label})`,
|
||||
inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
function edgeKey(edge) {
|
||||
return `${edge.from}|${edge.to}|${edge.label}`;
|
||||
}
|
||||
|
||||
function assignEdgeColors(edges) {
|
||||
const colors = new Map();
|
||||
let next = 0;
|
||||
for (const edge of edges) {
|
||||
const key = edgeKey(edge);
|
||||
if (!colors.has(key)) {
|
||||
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
|
||||
next++;
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spanning ranking forest: only one primary outgoing edge per resource
|
||||
* is used for rank constraints. Other edges get constraint=false to avoid
|
||||
* vertical over-stretching.
|
||||
*/
|
||||
function selectRankingEdgeKeys(edges) {
|
||||
const ranking = new Set();
|
||||
const bySource = new Map();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.from === edge.to) continue;
|
||||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||
bySource.get(edge.from).push(edge);
|
||||
}
|
||||
|
||||
for (const group of bySource.values()) {
|
||||
group.sort((a, b) => a.label.localeCompare(b.label));
|
||||
ranking.add(edgeKey(group[0]));
|
||||
}
|
||||
|
||||
return ranking;
|
||||
}
|
||||
|
||||
function buildLegendLabel() {
|
||||
const methodRow = (method, desc) => {
|
||||
const c = METHOD_COLORS[method];
|
||||
return (
|
||||
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="38">` +
|
||||
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${method}</B></FONT></TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
|
||||
);
|
||||
};
|
||||
const row = (swatch, text) =>
|
||||
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
|
||||
|
||||
return (
|
||||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
|
||||
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
|
||||
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
|
||||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>API Graph Legend</B></FONT></TD></TR>` +
|
||||
methodRow('GET', 'Safe retrieval / query') +
|
||||
methodRow('POST', 'Create resource / action') +
|
||||
methodRow('PUT', 'Full replace / collection update') +
|
||||
methodRow('PATCH', 'Partial entity update') +
|
||||
methodRow('DELETE', 'Remove resource') +
|
||||
row(
|
||||
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||||
'Inbound arrival port on resource'
|
||||
) +
|
||||
row(
|
||||
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||||
'Relational reference / dataflow'
|
||||
) +
|
||||
`</TABLE>>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderResourceNode(res, inboundCount = 0) {
|
||||
if (res.id === 'Legend') {
|
||||
return buildLegendLabel();
|
||||
}
|
||||
|
||||
const titleFont =
|
||||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(res.id)}</B></FONT>` +
|
||||
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(res.basePath)}</FONT>`;
|
||||
|
||||
let headerInner;
|
||||
if (inboundCount > 0) {
|
||||
const portCells = [];
|
||||
for (let i = 0; i < inboundCount; i++) {
|
||||
portCells.push(
|
||||
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
|
||||
);
|
||||
}
|
||||
headerInner =
|
||||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||
`<TR>${portCells.join('')}</TR>` +
|
||||
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
|
||||
`</TABLE>`;
|
||||
} else {
|
||||
headerInner = titleFont;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
|
||||
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
|
||||
];
|
||||
|
||||
for (const [i, ep] of res.endpoints.entries()) {
|
||||
const mColor = METHOD_COLORS[ep.method] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||||
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||||
|
||||
rows.push(
|
||||
`<TR><TD PORT="${escapeHtml(ep.portId)}" COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||||
`<TR>` +
|
||||
`<TD BGCOLOR="${mColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="38">` +
|
||||
`<FONT COLOR="${mColor.fg}" POINT-SIZE="9"><B>${escapeHtml(ep.method)}</B></FONT></TD>` +
|
||||
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||||
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(ep.subpath)}</B></FONT></TD>` +
|
||||
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||||
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(ep.summary)}</FONT></TD>` +
|
||||
`</TR></TABLE></TD></TR>`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
|
||||
rows.join('') +
|
||||
`</TABLE>>`
|
||||
);
|
||||
}
|
||||
|
||||
function assignEdgeAnchors(edges, positions) {
|
||||
const headPort = new Map();
|
||||
const tailCompass = new Map();
|
||||
const inboundCounts = new Map();
|
||||
|
||||
const byTarget = new Map();
|
||||
const bySource = new Map();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
|
||||
byTarget.get(edge.to).push(edge);
|
||||
|
||||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||||
bySource.get(edge.from).push(edge);
|
||||
}
|
||||
|
||||
for (const [target, group] of byTarget) {
|
||||
group.sort((a, b) => {
|
||||
const ax = positions?.get(a.from)?.x ?? 0;
|
||||
const bx = positions?.get(b.from)?.x ?? 0;
|
||||
if (ax !== bx) return ax - bx;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
inboundCounts.set(target, group.length);
|
||||
group.forEach((edge, i) => {
|
||||
headPort.set(edgeKey(edge), `in${i}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of bySource.values()) {
|
||||
group.forEach((edge, i) => {
|
||||
if (edge.from === edge.to) {
|
||||
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
|
||||
return;
|
||||
}
|
||||
const from = positions?.get(edge.from);
|
||||
const to = positions?.get(edge.to);
|
||||
const fromX = from?.x ?? 0;
|
||||
const toX = to?.x ?? fromX;
|
||||
const goEast = toX >= fromX;
|
||||
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
|
||||
});
|
||||
}
|
||||
|
||||
return { headPort, tailCompass, inboundCounts };
|
||||
}
|
||||
|
||||
function buildDot(resources, edges, positions, anchors) {
|
||||
const edgeColors = assignEdgeColors(edges);
|
||||
const rankingKeys = selectRankingEdgeKeys(edges);
|
||||
const { headPort, tailCompass, inboundCounts } =
|
||||
anchors ?? assignEdgeAnchors(edges, positions);
|
||||
|
||||
const lines = [
|
||||
'digraph api {',
|
||||
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
|
||||
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||||
' compound=true, mclimit=1.0];',
|
||||
' node [shape=plaintext, fontname="Helvetica"];',
|
||||
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
|
||||
'',
|
||||
];
|
||||
|
||||
const clusteredResources = new Set();
|
||||
|
||||
for (const cluster of CLUSTERS) {
|
||||
const presentInCluster = cluster.resources.filter((id) => resources.has(id));
|
||||
if (presentInCluster.length === 0) continue;
|
||||
|
||||
lines.push(` subgraph cluster_${cluster.id} {`);
|
||||
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
|
||||
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
|
||||
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
|
||||
|
||||
for (const resId of presentInCluster) {
|
||||
clusteredResources.add(resId);
|
||||
const res = resources.get(resId);
|
||||
const inCount = inboundCounts.get(resId) ?? 0;
|
||||
const label = renderResourceNode(res, inCount);
|
||||
lines.push(` ${resId} [label=${label}];`);
|
||||
}
|
||||
lines.push(' }');
|
||||
}
|
||||
|
||||
// Any unclustered resources
|
||||
for (const [resId, res] of resources) {
|
||||
if (clusteredResources.has(resId)) continue;
|
||||
const inCount = inboundCounts.get(resId) ?? 0;
|
||||
const label = renderResourceNode(res, inCount);
|
||||
lines.push(` ${resId} [label=${label}];`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = edgeKey(edge);
|
||||
const color = edgeColors.get(key) ?? '#5aa7ff';
|
||||
const isRanking = rankingKeys.has(key);
|
||||
const hp = headPort.get(key) ?? 'in0';
|
||||
const tc = tailCompass.get(key) ?? 'e';
|
||||
|
||||
const attrs = [
|
||||
`color="${color}"`,
|
||||
`fontcolor="${color}"`,
|
||||
`tooltip="${escapeHtml(edge.tooltip)}"`,
|
||||
`label=" ${escapeHtml(edge.label)} "`,
|
||||
];
|
||||
|
||||
if (!isRanking && edge.from !== edge.to) {
|
||||
attrs.push('constraint=false', 'weight=1');
|
||||
} else {
|
||||
attrs.push('weight=3');
|
||||
}
|
||||
|
||||
// Source port from endpoint if available, else exit compass
|
||||
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
|
||||
const toPort = `:${hp}:n`;
|
||||
|
||||
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function parsePlainPositions(plain) {
|
||||
const positions = new Map();
|
||||
for (const line of plain.split('\n')) {
|
||||
if (!line.startsWith('node ')) continue;
|
||||
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
||||
if (!match) continue;
|
||||
positions.set(match[1], {
|
||||
x: Number(match[2]),
|
||||
y: Number(match[3]),
|
||||
width: Number(match[4]),
|
||||
height: Number(match[5]),
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function fixSelfLoopSvg(svg) {
|
||||
return svg.replace(
|
||||
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
|
||||
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
|
||||
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
|
||||
if (pts.length < 2) return match;
|
||||
|
||||
const [x0, y0] = pts[0];
|
||||
const [x1, y1] = pts[pts.length - 1];
|
||||
const east = Math.max(x0, x1) + 36;
|
||||
const north = Math.min(y0, y1) - 28;
|
||||
|
||||
const tx = x1 - east;
|
||||
const ty = y1 - north;
|
||||
const len = Math.hypot(tx, ty) || 1;
|
||||
const dx = tx / len;
|
||||
const dy = ty / len;
|
||||
const arrowLen = 9;
|
||||
const arrowHalf = 3.5;
|
||||
const baseX = x1 - dx * arrowLen;
|
||||
const baseY = y1 - dy * arrowLen;
|
||||
const px = -dy * arrowHalf;
|
||||
const py = dx * arrowHalf;
|
||||
|
||||
const fmt = (n) => (Math.round(n * 100) / 100).toString();
|
||||
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
|
||||
const newPointsFmt =
|
||||
`${fmt(x1)},${fmt(y1)} ` +
|
||||
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
|
||||
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
|
||||
`${fmt(x1)},${fmt(y1)}`;
|
||||
|
||||
return (
|
||||
`<g id="${edgeId}" class="edge">\n` +
|
||||
`<title>${_table}->${_table}</title>\n` +
|
||||
`<g id="${aId}"><a ${aAttrs}>\n` +
|
||||
`<path${pathAttrs} d="${newDFmt}"/>\n` +
|
||||
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
|
||||
`</a>\n` +
|
||||
`</g>\n` +
|
||||
`</g>`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Generating Dash API Graph…');
|
||||
const spec = getOpenApiSpec(true);
|
||||
const resources = parseApi(spec);
|
||||
|
||||
// Add Legend pseudo-resource
|
||||
resources.set('Legend', {
|
||||
id: 'Legend',
|
||||
basePath: '',
|
||||
endpoints: [],
|
||||
});
|
||||
|
||||
const edges = buildApiEdges(resources);
|
||||
|
||||
console.log(`Parsed ${resources.size - 1} API resources and ${edges.length} relationships`);
|
||||
|
||||
const graphviz = await Graphviz.load();
|
||||
|
||||
// Pass 1: Initial layout to learn coordinates
|
||||
console.log('Layout pass 1/2: computing node positions…');
|
||||
const initialDot = buildDot(resources, edges, null, null);
|
||||
const plain = graphviz.layout(initialDot, 'plain', 'dot');
|
||||
const positions = parsePlainPositions(plain);
|
||||
|
||||
// Pass 2: Anchor assignment using learned positions
|
||||
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
|
||||
const anchors = assignEdgeAnchors(edges, positions);
|
||||
const finalDot = buildDot(resources, edges, positions, anchors);
|
||||
|
||||
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
|
||||
const fixedSvg = fixSelfLoopSvg(rawSvg);
|
||||
|
||||
// Ensure background color is set on svg tag
|
||||
const finalSvg = fixedSvg.replace(
|
||||
/<svg([^>]*?)>/,
|
||||
`<svg$1 style="background-color:${COLORS.background};">`
|
||||
);
|
||||
|
||||
writeFileSync(dotOutputPath, finalDot, 'utf8');
|
||||
writeFileSync(outputPath, finalSvg, 'utf8');
|
||||
|
||||
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||||
console.log(`Written ${dotOutputPath}`);
|
||||
console.log(`Written ${outputPath}`);
|
||||
if (dim) {
|
||||
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
|
||||
}
|
||||
371
mcp.md
Normal file
371
mcp.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# Model Context Protocol (MCP) in Dashback
|
||||
|
||||
This document explains the architecture, database schema, OAuth 2.1 authentication flow, and endpoint implementation of the Model Context Protocol (MCP) server in Dashback.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
The Dashback backend provides an MCP server running over **Streamable HTTP** at `/mcp`. It exposes the internal **`dashback_request`** tool (the same tool provided to the local agent via `src/tools/dashback-request.js`) to external MCP clients such as:
|
||||
- Visual Studio Code / GitHub Copilot
|
||||
- Claude Desktop
|
||||
- Cursor / Windsurf
|
||||
|
||||
All operations executed via `dashback_request` are cryptographically authenticated and scoped to the authorizing Dashback user (`google_auth_id`) and their selected company.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication: OAuth 2.1 Specification Compliance
|
||||
|
||||
MCP remote HTTP transports require standardized authorization following [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13). Dashback implements a full, built-in OAuth 2.1 authorization server supporting:
|
||||
|
||||
| Standard | Purpose | Endpoint(s) |
|
||||
|---|---|---|
|
||||
| **RFC 9728** | Protected Resource Metadata (PRM) | `/.well-known/oauth-protected-resource`<br>`/.well-known/oauth-protected-resource/mcp` |
|
||||
| **RFC 8414** / OIDC | Authorization Server Metadata | `/.well-known/oauth-authorization-server`<br>`/.well-known/openid-configuration` |
|
||||
| **RFC 7591** | Dynamic Client Registration (DCR) | `POST /oauth/register` |
|
||||
| **RFC 6749** & **RFC 7636** | Authorization Code Flow with PKCE (`S256`) | `GET /oauth/authorize`<br>`POST /oauth/authorize` |
|
||||
| **RFC 6749** | Token Exchange & Refresh | `POST /oauth/token` |
|
||||
| **RFC 7662** | Token Introspection | `POST /oauth/introspect` |
|
||||
| **RFC 8252** §7.3 | Native App Loopback Interface Redirection | Port-agnostic redirect URI matching for `127.0.0.1` and `localhost` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Schema (`dashdbschema`)
|
||||
|
||||
Three new tables were added to both **MSSQL** (`src/providers/mssql/schema.js`) and **SQLite** (`src/providers/sqlite3/schema.js`):
|
||||
|
||||
### 3.1. `mcp_client`
|
||||
Stores dynamically registered MCP clients (e.g., VS Code instances).
|
||||
- `id` (INTEGER / INT IDENTITY, PK)
|
||||
- `client_id` (NVARCHAR(100) / TEXT, UNIQUE NOT NULL)
|
||||
- `client_secret` (NVARCHAR(255) / TEXT, NULL)
|
||||
- `client_name` (NVARCHAR(255) / TEXT, NOT NULL)
|
||||
- `redirect_uris` (NVARCHAR(MAX) / TEXT, NOT NULL, JSON array or URI list)
|
||||
- `grant_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'authorization_code,refresh_token'`)
|
||||
- `response_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'code'`)
|
||||
- `scope` (NVARCHAR(255) / TEXT, NULL, default `'mcp:tools'`)
|
||||
- `created_at` (DATETIME2 / TEXT)
|
||||
|
||||
### 3.2. `mcp_auth_code`
|
||||
Stores short-lived authorization codes (5-minute TTL) during the PKCE handshake.
|
||||
- `code` (NVARCHAR(128) / TEXT, PK)
|
||||
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
|
||||
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
|
||||
- `redirect_uri` (NVARCHAR(2048) / TEXT, NOT NULL)
|
||||
- `code_challenge` (NVARCHAR(255) / TEXT, NOT NULL)
|
||||
- `code_challenge_method` (NVARCHAR(32) / TEXT, NOT NULL, e.g. `'S256'`)
|
||||
- `scope` (NVARCHAR(255) / TEXT, NULL)
|
||||
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
|
||||
- `used` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
|
||||
- `created_at` (DATETIME2 / TEXT)
|
||||
|
||||
### 3.3. `mcp_token`
|
||||
Stores refresh tokens (30-day TTL) for long-lived MCP connections.
|
||||
- `id` (INTEGER / INT IDENTITY, PK)
|
||||
- `refresh_token` (NVARCHAR(128) / TEXT, UNIQUE NOT NULL)
|
||||
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
|
||||
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
|
||||
- `scope` (NVARCHAR(255) / TEXT, NULL)
|
||||
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
|
||||
- `revoked` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
|
||||
- `created_at` (DATETIME2 / TEXT)
|
||||
|
||||
### Store Methods (`libdashstore`)
|
||||
Under `src/store/mcp/` in `dashdbschema`:
|
||||
- `store.mcp.registerClient(input)`
|
||||
- `store.mcp.getClient(clientId)`
|
||||
- `store.mcp.listClients()`
|
||||
- `store.mcp.deleteClient(clientId)`
|
||||
- `store.mcp.createAuthCode(input)`
|
||||
- `store.mcp.getAuthCode(code)`
|
||||
- `store.mcp.consumeAuthCode(code)`
|
||||
- `store.mcp.createRefreshToken(input)`
|
||||
- `store.mcp.getRefreshToken(refreshToken)`
|
||||
- `store.mcp.revokeRefreshToken(refreshToken)`
|
||||
|
||||
Wrapped with execution timing logs in `dashback` via `src/db/mcp.js` and `STORE_NAMESPACES`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints & Flow Breakdown
|
||||
|
||||
All routes are defined in [`src/api/routes/mcp.js`](src/api/routes/mcp.js).
|
||||
|
||||
### Step 1: Protected Resource Discovery
|
||||
When an MCP client connects to `POST /mcp` without credentials:
|
||||
- Server responds with **`401 Unauthorized`** and header:
|
||||
```http
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="http://<host>:4208/.well-known/oauth-protected-resource"
|
||||
```
|
||||
- The client queries `GET /.well-known/oauth-protected-resource`, receiving:
|
||||
```json
|
||||
{
|
||||
"resource": "http://<host>:4208/mcp",
|
||||
"authorization_servers": ["http://<host>:4208"],
|
||||
"scopes_supported": ["mcp:tools"],
|
||||
"resource_name": "Dashback MCP Server"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Authorization Server Discovery
|
||||
The client queries `GET /.well-known/oauth-authorization-server`, receiving:
|
||||
```json
|
||||
{
|
||||
"issuer": "http://<host>:4208",
|
||||
"authorization_endpoint": "http://<host>:4208/oauth/authorize",
|
||||
"token_endpoint": "http://<host>:4208/oauth/token",
|
||||
"registration_endpoint": "http://<host>:4208/oauth/register",
|
||||
"introspection_endpoint": "http://<host>:4208/oauth/introspect",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": ["mcp:tools"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Dynamic Client Registration
|
||||
The client registers with `POST /oauth/register`:
|
||||
```json
|
||||
{
|
||||
"client_name": "Visual Studio Code",
|
||||
"redirect_uris": [
|
||||
"https://vscode.dev/redirect",
|
||||
"http://127.0.0.1/",
|
||||
"http://127.0.0.1:33418/"
|
||||
],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"]
|
||||
}
|
||||
```
|
||||
The server stores the client in `mcp_client` and returns status `201 Created` with a unique `client_id` (UUID).
|
||||
|
||||
### Step 4: User Authorization & Consent via Frontend
|
||||
When the MCP client directs the user's browser to authenticate:
|
||||
- The MCP client reads `authorization_endpoint` from `/.well-known/oauth-authorization-server`, which resolves to `http://<host>:4209/oauth/authorize`.
|
||||
- If an agent or client hits `GET /oauth/authorize` directly on the backend, the backend redirects (HTTP 302) to `${frontendUrl}/oauth/authorize?<queryParams>`.
|
||||
- The user is authenticated in the Dashfront frontend SPA.
|
||||
- The frontend calls `GET /api/mcp/authorize?<queryParams>` to fetch client details and user context for the consent dialog.
|
||||
- When the user clicks **Authorize**, the frontend calls `POST /api/mcp/authorize` (`action: "allow"`).
|
||||
- The server generates a single-use authorization code (in `mcp_auth_code`) and returns `{ ok: true, redirectUrl: "http://127.0.0.1:<port>/?code=<code>&state=<state>" }`.
|
||||
- The frontend sets `window.location.href = data.redirectUrl`, delivering the code to the client loopback listener.
|
||||
- **Loopback Port Matching (RFC 8252 §7.3)**: Native applications bind to ephemeral ports (e.g. `54754`). The server uses `isRedirectUriAllowed()` to permit any port on `127.0.0.1` and `localhost` when the registered URI is on loopback.
|
||||
|
||||
### Step 5: Token Exchange
|
||||
The MCP client sends a POST request with PKCE verifier to `POST /oauth/token`:
|
||||
```http
|
||||
POST /oauth/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code&code=<code>&client_id=<id>&redirect_uri=...&code_verifier=<verifier>
|
||||
```
|
||||
The server:
|
||||
1. Validates `code`, client ID, expiration, and unused status.
|
||||
2. Computes `BASE64URL(SHA256(code_verifier))` and compares it against `code_challenge`.
|
||||
3. Marks the code as used in `mcp_auth_code`.
|
||||
4. Issues a signed JWT access token (1-hour TTL) with payload `{ sub: userId, id: userId, client_id, scope: "mcp:tools", aud: "<host>/mcp" }`.
|
||||
5. Issues a 30-day random hex refresh token stored in `mcp_token`.
|
||||
|
||||
### Step 6: MCP Session Handling (`/mcp`)
|
||||
The client connects to `/mcp` with `Authorization: Bearer <access_token>`.
|
||||
- **`requireBearerAuth`**: Validates the JWT and verifies that the `userId` exists and is active in `google_auth`.
|
||||
- **`StreamableHTTPServerTransport`** (`@modelcontextprotocol/sdk`):
|
||||
- `POST /mcp`: Handles session `initialize` (generating a session UUID and returning `Mcp-Session-Id` header) and subsequent JSON-RPC tool calls.
|
||||
- `GET /mcp`: Handles server-sent events (SSE) streaming connections.
|
||||
- `DELETE /mcp`: Closes the transport session.
|
||||
|
||||
---
|
||||
|
||||
## 5. The Exposed Tool: `dashback_request`
|
||||
|
||||
The MCP server registers the exact same tool exposed to the internal local agent (`src/tools/dashback-request.js`):
|
||||
|
||||
```javascript
|
||||
server.registerTool('dashback_request', {
|
||||
title: 'Dashback API Request',
|
||||
description: 'Perform HTTP requests against the Dashback API',
|
||||
inputSchema: {
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||
path: z.string().describe('e.g. /articles, /orders, /category'),
|
||||
query: z.record(z.string(), z.any()).nullable().optional(),
|
||||
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
},
|
||||
}, async ({ method = 'GET', path, query = null, body = null, note }) => {
|
||||
// Generates a 5-minute JWT signed with env.jwtSecret scoped to the authenticated user's ID
|
||||
// Performs fetch against http://127.0.0.1:<port>/api<path>
|
||||
// Returns status and truncated body (up to 20,000 characters)
|
||||
});
|
||||
```
|
||||
|
||||
Because the internal fetch uses a JWT containing the user's `google_auth_id`, all Dashback route-level permissions, company isolation (`req.user.selectedCompanyId`), audit logging, and data scoping apply automatically.
|
||||
|
||||
---
|
||||
|
||||
## 6. How to Connect from VS Code / GitHub Copilot
|
||||
|
||||
### Configuration
|
||||
Add the server to your VS Code MCP configuration (either in `.vscode/mcp.json` or your global VS Code settings):
|
||||
|
||||
```json
|
||||
{
|
||||
"dashback": {
|
||||
"url": "http://127.0.0.1:4208/mcp",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
```
|
||||
*(If connecting from another device over LAN, use `http://<server-ip>:4208/mcp`)*.
|
||||
|
||||
### First Connection Flow:
|
||||
1. Open VS Code with Copilot Chat.
|
||||
2. VS Code contacts `/mcp`, triggers OAuth 2.1 discovery, and asks you to open a browser for authorization.
|
||||
3. Click **Open** and press **Authorize** on the Dashback consent screen.
|
||||
4. VS Code receives the tokens and displays `#dashback_request` in Copilot Chat tools.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend Developer Implementation Guide
|
||||
|
||||
The frontend (`dashfront`, port 4209) needs two views / features:
|
||||
1. **Consent Screen Route (`/oauth/authorize`)**: Where external MCP clients send users to approve access.
|
||||
2. **Authorizations Management (Settings/Security)**: Where users view authorized MCP clients and revoke access.
|
||||
|
||||
### 7.1. Consent Screen Route: `/oauth/authorize`
|
||||
|
||||
When an MCP client initiates authentication, the user is navigated to:
|
||||
```
|
||||
http://<host>:4209/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256&state=...&scope=mcp:tools
|
||||
```
|
||||
|
||||
#### Step 1: Ensure User Authentication
|
||||
- Check if the user is authenticated in Dashfront.
|
||||
- If not logged in, redirect to `/login` preserving the full URL as `returnTo` so they return here after Google SSO.
|
||||
|
||||
#### Step 2: Fetch Authorization Prompt Metadata
|
||||
The frontend makes an authenticated GET request:
|
||||
```http
|
||||
GET /api/mcp/authorize?<all_query_params_from_url>
|
||||
```
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"client": {
|
||||
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
|
||||
"clientName": "Visual Studio Code",
|
||||
"scope": "mcp:tools"
|
||||
},
|
||||
"user": {
|
||||
"id": 1,
|
||||
"email": "user@example.com"
|
||||
},
|
||||
"params": {
|
||||
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
|
||||
"redirectUri": "http://127.0.0.1:54754/",
|
||||
"codeChallenge": "...",
|
||||
"codeChallengeMethod": "S256",
|
||||
"state": "...",
|
||||
"scope": "mcp:tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Render Consent UI
|
||||
Display a clean dialog or card:
|
||||
- Application requesting access: `client.clientName`
|
||||
- Authorizing account: `user.email`
|
||||
- Permissions requested: `dashback_request` API tools (`mcp:tools`)
|
||||
- Action buttons:
|
||||
- **Authorize** (Allow)
|
||||
- **Cancel** (Deny)
|
||||
|
||||
#### Step 4: Submit Consent Decision
|
||||
When the user clicks **Authorize**:
|
||||
```http
|
||||
POST /api/mcp/authorize
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "<params.clientId>",
|
||||
"redirect_uri": "<params.redirectUri>",
|
||||
"code_challenge": "<params.codeChallenge>",
|
||||
"code_challenge_method": "<params.codeChallengeMethod>",
|
||||
"state": "<params.state>",
|
||||
"scope": "<params.scope>",
|
||||
"action": "allow"
|
||||
}
|
||||
```
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"redirectUrl": "http://127.0.0.1:54754/?code=<auth_code>&state=<state>"
|
||||
}
|
||||
```
|
||||
The frontend immediately navigates the browser:
|
||||
```javascript
|
||||
window.location.href = res.data.redirectUrl;
|
||||
```
|
||||
|
||||
When the user clicks **Cancel**:
|
||||
```http
|
||||
POST /api/mcp/authorize
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "<params.clientId>",
|
||||
"redirect_uri": "<params.redirectUri>",
|
||||
"state": "<params.state>",
|
||||
"action": "deny"
|
||||
}
|
||||
```
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"redirectUrl": "http://127.0.0.1:54754/?error=access_denied&state=<state>"
|
||||
}
|
||||
```
|
||||
Navigate: `window.location.href = res.data.redirectUrl;`.
|
||||
|
||||
---
|
||||
|
||||
### 7.2. Authorizations Management (Settings / Security)
|
||||
|
||||
In the user profile or security settings, users can view active MCP client authorizations and revoke them.
|
||||
|
||||
#### List Active Authorizations
|
||||
```http
|
||||
GET /api/mcp/authorizations
|
||||
```
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"authorizations": [
|
||||
{
|
||||
"clientDbId": 3,
|
||||
"clientId": "0a2e6bbb-05a5-4b03-bed1-9b853b5aec34",
|
||||
"clientName": "Visual Studio Code",
|
||||
"scope": "mcp:tools",
|
||||
"clientCreatedAt": "2026-10-12T20:36:46.036Z",
|
||||
"authorizedAt": "2026-10-12T20:39:28.148Z",
|
||||
"tokenCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Revoke an Authorization
|
||||
When the user clicks **Revoke** / **Delete**:
|
||||
```http
|
||||
DELETE /api/mcp/authorizations/:clientId
|
||||
```
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Authorizations revoked"
|
||||
}
|
||||
```
|
||||
This immediately revokes all active refresh tokens for that client and the user. Any future token refresh requests by that client will be rejected with `invalid_grant`.
|
||||
|
||||
220
package-lock.json
generated
220
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"chalk": "^6.0.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -32,9 +33,11 @@
|
||||
"sharp": "^0.35.5",
|
||||
"skelehash-wasm": "^0.1.0",
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
"vm2": "^3.12.2",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||
"acorn": "^8.19.0",
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
}
|
||||
@@ -198,6 +201,25 @@
|
||||
"lodash.uniq": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz",
|
||||
"integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
@@ -756,6 +778,55 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
|
||||
"integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
@@ -1046,7 +1117,6 @@
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1074,6 +1144,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
@@ -1887,6 +1974,23 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
@@ -2458,6 +2562,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exceljs": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||
@@ -2530,6 +2655,25 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
||||
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -2568,7 +2712,6 @@
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3082,6 +3225,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.13",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
|
||||
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -3294,6 +3446,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
|
||||
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -3704,6 +3865,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
@@ -3746,9 +3916,14 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -4354,6 +4529,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -4598,6 +4782,15 @@
|
||||
"png-js": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkijs": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz",
|
||||
@@ -4966,7 +5159,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6443,6 +6635,24 @@
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"chalk": "^6.0.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -39,9 +41,11 @@
|
||||
"sharp": "^0.35.5",
|
||||
"skelehash-wasm": "^0.1.0",
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"vm2": "^3.12.2"
|
||||
"vm2": "^3.12.2",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hpcc-js/wasm-graphviz": "^1.29.2",
|
||||
"acorn": "^8.19.0",
|
||||
"swagger-jsdoc": "^6.3.0"
|
||||
},
|
||||
|
||||
@@ -72,6 +72,17 @@ function isAllowedCorsOrigin(origin) {
|
||||
function corsMiddleware(req, res, next) {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path.startsWith('/oauth')) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
@@ -121,7 +132,7 @@ function corsMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
async function buildApiApp() {
|
||||
export async function buildApiApp() {
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
@@ -133,6 +144,7 @@ async function buildApiApp() {
|
||||
});
|
||||
app.use(corsMiddleware);
|
||||
app.use(express.json({ limit: env.bodyLimit }));
|
||||
app.use(express.urlencoded({ extended: true, limit: env.bodyLimit }));
|
||||
app.use(cookieParser());
|
||||
app.use(loggerMiddleware);
|
||||
app.use(authMiddleware);
|
||||
|
||||
@@ -63,6 +63,12 @@ export const authMiddleware = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path === '/oauth/register' || req.path === '/oauth/token' || req.path === '/oauth/introspect') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const isOAuthAuthorize = req.path === '/oauth/authorize';
|
||||
|
||||
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice(7).trim()
|
||||
: null;
|
||||
@@ -70,7 +76,7 @@ export const authMiddleware = async (req, res, next) => {
|
||||
|
||||
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
|
||||
if (!token) {
|
||||
if (isDocs) return next();
|
||||
if (isDocs || isOAuthAuthorize) return next();
|
||||
return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
@@ -118,7 +124,7 @@ export const authMiddleware = async (req, res, next) => {
|
||||
next();
|
||||
} catch {
|
||||
if (req.cookies.token) clearTokenCookie(res);
|
||||
if (isDocs) return next();
|
||||
if (isDocs || isOAuthAuthorize) return next();
|
||||
return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,14 +42,23 @@
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* iso639:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* rootCategoryId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* languageId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* iso639:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* taxrate:
|
||||
* type: number
|
||||
* reducedtaxrate:
|
||||
* type: number
|
||||
* taxfreerate:
|
||||
* type: number
|
||||
* SuccessResponse:
|
||||
* type: object
|
||||
* required: [ok]
|
||||
@@ -513,6 +522,9 @@
|
||||
* thumbHash:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* articleCount:
|
||||
* type: integer
|
||||
* description: Number of articles assigned directly to this category
|
||||
* posterminalIds:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -1011,8 +1023,28 @@
|
||||
* $ref: '#/components/schemas/Payment'
|
||||
* paid:
|
||||
* type: boolean
|
||||
* description: True when paidTotal equals orderTotal
|
||||
* shipped:
|
||||
* type: boolean
|
||||
* description: True when shippedQuantity equals orderedQuantity
|
||||
* delivered:
|
||||
* type: boolean
|
||||
* orderTotal:
|
||||
* type: number
|
||||
* description: Gross sum across order positions
|
||||
* paidTotal:
|
||||
* type: number
|
||||
* description: Sum of all payments connected to order
|
||||
* orderedQuantity:
|
||||
* type: number
|
||||
* description: Total ordered items
|
||||
* shippedQuantity:
|
||||
* type: number
|
||||
* description: Total shipped items across order shipments
|
||||
* shipments:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Shipment'
|
||||
* ArticleImage:
|
||||
* type: object
|
||||
* required: [picId, sort, full, thumb, hash, thumbHash]
|
||||
@@ -1114,6 +1146,12 @@
|
||||
* type: string
|
||||
* enum: [idle, busy]
|
||||
* description: Messages can only be posted while `idle`
|
||||
* started_by:
|
||||
* type: [integer, 'null']
|
||||
* description: Id of the conversation that started this one as a subagent, or null for a top-level conversation. Deleting a conversation also deletes every conversation below it in this chain.
|
||||
* first_message:
|
||||
* type: [string, 'null']
|
||||
* description: Plain text of the conversation's first user message (XML stripped, max 50 chars, ellipsis if cut), or null if there is none yet.
|
||||
* ModelConversationCreateInput:
|
||||
* type: object
|
||||
* required: [provider, model]
|
||||
@@ -1191,10 +1229,53 @@
|
||||
* number:
|
||||
* type: string
|
||||
* description: Shipping number from the `shipping` number sequence
|
||||
* trackingCode:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: Carrier parcel tracking code
|
||||
* carrier:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: Carrier name (e.g. DHL, UPS, DPD)
|
||||
* type:
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* Shipment:
|
||||
* type: object
|
||||
* required: [id, number, type, date, lines, shippings]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* number:
|
||||
* type: string
|
||||
* description: Shipment compound number `<orderNumber><shipmentPrefix><y>`
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [shipment]
|
||||
* entityId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* externalNumber:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* date:
|
||||
* type: string
|
||||
* createdAt:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* lineCount:
|
||||
* type: integer
|
||||
* packageCount:
|
||||
* type: integer
|
||||
* lines:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ManifestLine'
|
||||
* shippings:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Shipping'
|
||||
* Package:
|
||||
* type: object
|
||||
* required: [id, shippingId, entityaddressId, number, type, state]
|
||||
|
||||
@@ -36,6 +36,8 @@ export const openApiOptions = {
|
||||
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
|
||||
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
|
||||
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
|
||||
{ name: 'Shipment', description: 'Company-scoped shipment manifests (Lieferscheine)' },
|
||||
{ name: 'Shipping', description: 'Shipment packages, carriers, and tracking codes' },
|
||||
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
|
||||
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
|
||||
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
|
||||
|
||||
@@ -74,7 +74,7 @@ export function register(app, basePath = '') {
|
||||
* x-markdown: category/tree.md
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tree and articles for the selected company
|
||||
* description: Tree and article count for the selected company
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -86,10 +86,9 @@ export function register(app, basePath = '') {
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/CategoryTreeNode'
|
||||
* articles:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/CategoryArticle'
|
||||
* articleCount:
|
||||
* type: integer
|
||||
* description: Total number of parent articles across the category tree
|
||||
* 400:
|
||||
* description: No company selected
|
||||
* content:
|
||||
|
||||
1102
src/api/routes/mcp.js
Normal file
1102
src/api/routes/mcp.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ import * as db from '../../db/modelconversation.js';
|
||||
import env from '../../config/env.js';
|
||||
import { RETIRED_MODELS, resolveDefaultModel } from '../../tools/llm-key.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { getOrCreateManager, removeManager } from '../../services/conversationManager.js';
|
||||
import { getOrCreateManager, removeManager, cleanUserMessageContent } from '../../services/conversationManager.js';
|
||||
|
||||
const FIRST_MESSAGE_MAX_CHARS = 50;
|
||||
|
||||
// #region Helpers
|
||||
|
||||
@@ -22,6 +24,50 @@ function parsePositiveInt(value, field) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes XML elements together with their content (e.g. `<USERENV><ROUTE>…</ROUTE></USERENV>`)
|
||||
* and any leftover self-closing tags.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function stripXmlElements(text) {
|
||||
const elementPattern = /<([A-Za-z][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/g;
|
||||
let previous;
|
||||
let stripped = text;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped.replace(elementPattern, '');
|
||||
} while (stripped !== previous);
|
||||
return stripped.replace(/<[A-Za-z][\w:.-]*(?:\s[^>]*)?\/>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the stored first user message payload (`{"content": ...}`) into short plain text
|
||||
* for conversation lists: JSON content only, XML elements stripped, outer whitespace trimmed.
|
||||
* @param {string|null} storedMessage
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function buildFirstMessagePreview(storedMessage) {
|
||||
if (!storedMessage) return null;
|
||||
let rawContent;
|
||||
try {
|
||||
rawContent = JSON.parse(storedMessage).content;
|
||||
} catch {
|
||||
rawContent = storedMessage;
|
||||
}
|
||||
const text = stripXmlElements(cleanUserMessageContent(rawContent)).trim();
|
||||
return text.length > FIRST_MESSAGE_MAX_CHARS ? `${text.slice(0, FIRST_MESSAGE_MAX_CHARS)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the store's raw `first_user_message` with the plain-text `first_message` preview.
|
||||
* @param {{ first_user_message: string|null }} row
|
||||
* @returns {object}
|
||||
*/
|
||||
function buildConversationResponse({ first_user_message: storedFirstMessage, ...conversation }) {
|
||||
return { ...conversation, first_message: buildFirstMessagePreview(storedFirstMessage) };
|
||||
}
|
||||
|
||||
function modelConversationErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
@@ -92,7 +138,8 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
|
||||
try {
|
||||
const conversations = await db.list(req.user.id);
|
||||
const rows = await db.list(req.user.id);
|
||||
const conversations = rows.map(buildConversationResponse);
|
||||
res.status(200).json({ conversations });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
@@ -245,7 +292,7 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
|
||||
try {
|
||||
const conversation = await db.get(req.user.id, parsedId.id);
|
||||
const conversation = buildConversationResponse(await db.get(req.user.id, parsedId.id));
|
||||
res.status(200).json({ conversation });
|
||||
} catch (error) {
|
||||
const status = modelConversationErrorStatus(error);
|
||||
|
||||
@@ -22,6 +22,7 @@ function orderErrorStatus(error) {
|
||||
if (error.message === 'Invalid lines') return 400;
|
||||
if (error.message === 'Invalid offset') return 400;
|
||||
if (error.message === 'Invalid limit') return 400;
|
||||
if (error.message === 'Invalid seekMonths') return 400;
|
||||
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
|
||||
return 500;
|
||||
}
|
||||
@@ -58,10 +59,32 @@ export function register(app, basePath = '') {
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* default: 50
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* description: Number of orders to return
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Free-text search matching order number, external number, or customer name
|
||||
* - in: query
|
||||
* name: onlyUnpaid
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: When true, returns only unpaid orders (including overpaid orders)
|
||||
* - in: query
|
||||
* name: onlyUndelivered
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: When true, returns only undelivered orders
|
||||
* - in: query
|
||||
* name: seekMonths
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Negative integer for cutoff months ago (e.g. -1 for orders older than 1 month)
|
||||
* x-markdown: order/list.md
|
||||
* responses:
|
||||
* 200:
|
||||
@@ -78,7 +101,7 @@ export function register(app, basePath = '') {
|
||||
* $ref: '#/components/schemas/Order'
|
||||
* total:
|
||||
* type: integer
|
||||
* description: Total number of unpaged orders
|
||||
* description: Total number of filtered orders
|
||||
* offset:
|
||||
* type: integer
|
||||
* description: Number of orders skipped
|
||||
@@ -86,7 +109,7 @@ export function register(app, basePath = '') {
|
||||
* type: integer
|
||||
* description: Number of orders returned
|
||||
* 400:
|
||||
* description: Invalid offset, invalid limit, or no company selected
|
||||
* description: Invalid query parameters, invalid offset, invalid limit, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -200,7 +223,7 @@ export function register(app, basePath = '') {
|
||||
const rawLimit = req.query.limit;
|
||||
|
||||
let offset = 0;
|
||||
let limit = 20;
|
||||
let limit = 50;
|
||||
|
||||
if (rawOffset !== undefined) {
|
||||
offset = parseInt(rawOffset, 10);
|
||||
@@ -216,8 +239,31 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
}
|
||||
|
||||
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
|
||||
? req.query.search.trim()
|
||||
: undefined;
|
||||
|
||||
const onlyUnpaid = req.query.onlyUnpaid === 'true';
|
||||
const onlyUndelivered = req.query.onlyUndelivered === 'true';
|
||||
|
||||
let seekMonths = undefined;
|
||||
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
|
||||
const parsedSeek = parseInt(req.query.seekMonths, 10);
|
||||
if (!Number.isInteger(parsedSeek)) {
|
||||
return res.status(400).json({ error: 'seekMonths must be an integer' });
|
||||
}
|
||||
seekMonths = parsedSeek;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.list(req.user.id, { offset, limit });
|
||||
const result = await db.list(req.user.id, {
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
onlyUnpaid: onlyUnpaid ? true : undefined,
|
||||
onlyUndelivered: onlyUndelivered ? true : undefined,
|
||||
seekMonths,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
const status = orderErrorStatus(error);
|
||||
|
||||
386
src/api/routes/shipment.js
Normal file
386
src/api/routes/shipment.js
Normal file
@@ -0,0 +1,386 @@
|
||||
import * as db from '../../db/shipment.js';
|
||||
import { emit } from '../sse.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, field) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { error: `${field} is required` };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function shipmentErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message?.endsWith(' not found')) return 404;
|
||||
if (
|
||||
error.message?.endsWith(' required') ||
|
||||
error.message?.startsWith('Article (') ||
|
||||
error.message?.includes('Invalid lines')
|
||||
) {
|
||||
return 400;
|
||||
}
|
||||
if (
|
||||
error.message?.startsWith('Shipment amount exceeds') ||
|
||||
error.message?.includes('cannot be deleted') ||
|
||||
error.message?.includes('cannot be undone') ||
|
||||
error.message?.includes('already shipped')
|
||||
) {
|
||||
return 409;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
function emitShipmentEvents(companyId) {
|
||||
emit('order', { companyId });
|
||||
emit('shipment', { companyId });
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Registers shipment routes (shipments belong to an order).
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /order/{id}/shipment:
|
||||
* get:
|
||||
* tags: [Shipment]
|
||||
* summary: List shipments of an order
|
||||
* description: Lists all shipment manifests belonging to the specified order.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Order ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of shipments
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* shipments:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Shipment'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Order not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region GET /order/:id/shipment
|
||||
|
||||
app.get(`${basePath}/order/:id/shipment`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const shipments = await db.listByOrderId(req.user.id, parsedId.id);
|
||||
res.status(200).json({ shipments });
|
||||
} catch (error) {
|
||||
const status = shipmentErrorStatus(error);
|
||||
if (status === 500) console.error('List shipments failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /order/{id}/shipment:
|
||||
* post:
|
||||
* tags: [Shipment]
|
||||
* summary: Create shipment for an order
|
||||
* description: |
|
||||
* Creates a new partial or full shipment manifest for the order.
|
||||
* Validates that article positions belong to the order and cumulative shipped quantities
|
||||
* do not exceed ordered quantities.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Order ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [lines]
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* externalNumber:
|
||||
* type: string
|
||||
* lines:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required: [sku, amount]
|
||||
* properties:
|
||||
* sku:
|
||||
* type: string
|
||||
* amount:
|
||||
* type: number
|
||||
* name:
|
||||
* type: string
|
||||
* pu:
|
||||
* type: number
|
||||
* net:
|
||||
* type: number
|
||||
* rebate:
|
||||
* type: number
|
||||
* taxrate:
|
||||
* type: number
|
||||
* comment:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created shipment
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* shipment:
|
||||
* $ref: '#/components/schemas/Shipment'
|
||||
* 400:
|
||||
* description: Validation error or article not in order
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Order not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Cumulative shipment amount exceeds ordered amount
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region POST /order/:id/shipment
|
||||
|
||||
app.post(`${basePath}/order/:id/shipment`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const shipment = await db.create(req.user.id, {
|
||||
...(req.body ?? {}),
|
||||
orderId: parsedId.id,
|
||||
});
|
||||
emitShipmentEvents(selected.companyId);
|
||||
res.status(201).json({ shipment });
|
||||
} catch (error) {
|
||||
const status = shipmentErrorStatus(error);
|
||||
if (status === 500) console.error('Create shipment failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /shipment/{id}:
|
||||
* get:
|
||||
* tags: [Shipment]
|
||||
* summary: Get shipment by ID
|
||||
* description: Retrieves shipment header, article positions, and attached packages.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Shipment ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shipment details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* shipment:
|
||||
* $ref: '#/components/schemas/Shipment'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Shipment not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region GET /shipment/:id
|
||||
|
||||
app.get(`${basePath}/shipment/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const shipment = await db.getById(req.user.id, parsedId.id);
|
||||
res.status(200).json({ shipment });
|
||||
} catch (error) {
|
||||
const status = shipmentErrorStatus(error);
|
||||
if (status === 500) console.error('Get shipment failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /shipment/{id}:
|
||||
* delete:
|
||||
* tags: [Shipment]
|
||||
* summary: Delete shipment
|
||||
* description: Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Shipment ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Shipment deleted
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Shipment not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 409:
|
||||
* description: Shipped shipment cannot be deleted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region DELETE /shipment/:id
|
||||
|
||||
app.delete(`${basePath}/shipment/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.remove(req.user.id, parsedId.id);
|
||||
emitShipmentEvents(selected.companyId);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
const status = shipmentErrorStatus(error);
|
||||
if (status === 500) console.error('Delete shipment failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
}
|
||||
@@ -23,7 +23,12 @@ function shippingErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message?.endsWith(' not found')) return 404;
|
||||
if (error.message?.endsWith(' required')) return 400;
|
||||
if (
|
||||
error.message?.endsWith(' required') ||
|
||||
error.message?.includes('not a shipment')
|
||||
) {
|
||||
return 400;
|
||||
}
|
||||
if (
|
||||
error.message?.includes('cannot be deleted') ||
|
||||
error.message?.includes('cannot be undone') ||
|
||||
@@ -227,6 +232,178 @@ export function register(app, basePath = '') {
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /shipment/{id}/shipping:
|
||||
* get:
|
||||
* tags: [Shipping]
|
||||
* summary: List packages of a shipment
|
||||
* description: Lists all packages (shippings) belonging to the specified shipment manifest.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Shipment ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of packages
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* shippings:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Shipping'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Shipment not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region GET /shipment/:id/shipping
|
||||
|
||||
app.get(`${basePath}/shipment/:id/shipping`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const shippings = await db.listByManifestId(req.user.id, parsedId.id);
|
||||
res.status(200).json({ shippings });
|
||||
} catch (error) {
|
||||
const status = shippingErrorStatus(error);
|
||||
if (status === 500) console.error('List shipment packages failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /shipment/{id}/shipping:
|
||||
* post:
|
||||
* tags: [Shipping]
|
||||
* summary: Create package for a shipment
|
||||
* description: |
|
||||
* Creates a physical package (shipping) for the shipment manifest.
|
||||
* Accepts carrier and parcel tracking code. If state is `shipped`,
|
||||
* deducts stock for the shipment's lines via ledger movements.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Shipment ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [type, state]
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* trackingCode:
|
||||
* type: string
|
||||
* description: Carrier parcel tracking code
|
||||
* carrier:
|
||||
* type: string
|
||||
* description: Carrier name (e.g. DHL, UPS, DPD)
|
||||
* warehouseId:
|
||||
* type: integer
|
||||
* description: Optional warehouse ID for stock deduction when state is shipped
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created package
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* shipping:
|
||||
* $ref: '#/components/schemas/Shipping'
|
||||
* 400:
|
||||
* description: Validation error, manifest is not a shipment, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Shipment or warehouse not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region POST /shipment/:id/shipping
|
||||
|
||||
app.post(`${basePath}/shipment/:id/shipping`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsedId = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsedId.error) {
|
||||
return res.status(400).json({ error: parsedId.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const shipping = await db.create(req.user.id, {
|
||||
...(req.body ?? {}),
|
||||
manifestId: parsedId.id,
|
||||
});
|
||||
if (String(shipping.state || '').trim().toLowerCase() === 'shipped') {
|
||||
emitShippingEvents(selected.companyId);
|
||||
}
|
||||
res.status(201).json({ shipping });
|
||||
} catch (error) {
|
||||
const status = shippingErrorStatus(error);
|
||||
if (status === 500) console.error('Create shipment package failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /shipping/{id}:
|
||||
@@ -256,6 +433,12 @@ export function register(app, basePath = '') {
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* trackingCode:
|
||||
* type: string
|
||||
* description: Carrier parcel tracking code
|
||||
* carrier:
|
||||
* type: string
|
||||
* description: Carrier name (e.g. DHL, UPS, DPD)
|
||||
* warehouseId:
|
||||
* type: integer
|
||||
* description: Warehouse ID for stock deduction when transitioning to shipped
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:4209',
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||
implicitCreate: process.env.IMPLICIT_CREATE === 'true',
|
||||
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
|
||||
|
||||
61
src/db/mcp.js
Normal file
61
src/db/mcp.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function registerClient(input) {
|
||||
const store = await getStore();
|
||||
return store.mcp.registerClient(input);
|
||||
}
|
||||
|
||||
export async function getClient(clientId) {
|
||||
const store = await getStore();
|
||||
return (await store.mcp.getClient(clientId)) ?? null;
|
||||
}
|
||||
|
||||
export async function listClients() {
|
||||
const store = await getStore();
|
||||
return store.mcp.listClients();
|
||||
}
|
||||
|
||||
export async function deleteClient(clientId) {
|
||||
const store = await getStore();
|
||||
return store.mcp.deleteClient(clientId);
|
||||
}
|
||||
|
||||
export async function createAuthCode(input) {
|
||||
const store = await getStore();
|
||||
return store.mcp.createAuthCode(input);
|
||||
}
|
||||
|
||||
export async function getAuthCode(code) {
|
||||
const store = await getStore();
|
||||
return (await store.mcp.getAuthCode(code)) ?? null;
|
||||
}
|
||||
|
||||
export async function consumeAuthCode(code) {
|
||||
const store = await getStore();
|
||||
return store.mcp.consumeAuthCode(code);
|
||||
}
|
||||
|
||||
export async function createRefreshToken(input) {
|
||||
const store = await getStore();
|
||||
return store.mcp.createRefreshToken(input);
|
||||
}
|
||||
|
||||
export async function getRefreshToken(refreshToken) {
|
||||
const store = await getStore();
|
||||
return (await store.mcp.getRefreshToken(refreshToken)) ?? null;
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(refreshToken) {
|
||||
const store = await getStore();
|
||||
return store.mcp.revokeRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
export async function listAuthorizations(googleAuthId) {
|
||||
const store = await getStore();
|
||||
return store.mcp.listAuthorizations(googleAuthId);
|
||||
}
|
||||
|
||||
export async function revokeAuthorizations(googleAuthId, clientId) {
|
||||
const store = await getStore();
|
||||
return store.mcp.revokeAuthorizations(googleAuthId, clientId);
|
||||
}
|
||||
109
src/db/order.js
109
src/db/order.js
@@ -1,35 +1,122 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
function round2(num) {
|
||||
return Math.round((Number(num) || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function calculateOrderGrossTotal(lines = []) {
|
||||
let total = 0;
|
||||
for (const line of lines) {
|
||||
const amount = Number(line.amount || 0);
|
||||
const net = Number(line.net || 0);
|
||||
const rebate = Number(line.rebate || 0);
|
||||
const taxrate = Number(line.taxrate || 0);
|
||||
const netAfterRebate = net * (1 - rebate / 100);
|
||||
const gross = netAfterRebate * (1 + taxrate / 100);
|
||||
total += amount * gross;
|
||||
}
|
||||
return round2(total);
|
||||
}
|
||||
|
||||
function calculatePaidTotal(payments = []) {
|
||||
let total = 0;
|
||||
for (const payment of payments) {
|
||||
total += Number(payment.amount || 0);
|
||||
}
|
||||
return round2(total);
|
||||
}
|
||||
|
||||
function calculateOrderedQuantity(lines = []) {
|
||||
let qty = 0;
|
||||
for (const line of lines) {
|
||||
qty += Number(line.amount || 0);
|
||||
}
|
||||
return Math.round(qty * 1000) / 1000;
|
||||
}
|
||||
|
||||
function calculateShippedQuantity(shipments = []) {
|
||||
let qty = 0;
|
||||
for (const shipment of shipments) {
|
||||
for (const line of shipment.lines || []) {
|
||||
qty += Number(line.amount || 0);
|
||||
}
|
||||
}
|
||||
return Math.round(qty * 1000) / 1000;
|
||||
}
|
||||
|
||||
async function enrichSingleOrder(store, googleAuthId, order) {
|
||||
if (!order) return order;
|
||||
const [shippings, payments] = await Promise.all([
|
||||
const [shippings, payments, shipments] = await Promise.all([
|
||||
store.shipping.listByManifestId(googleAuthId, order.id).catch(() => []),
|
||||
store.payment.listByManifestId(googleAuthId, order.id).catch(() => []),
|
||||
store.shipment.listByOrderId(googleAuthId, order.id).catch(() => []),
|
||||
]);
|
||||
|
||||
const isPaid = Array.isArray(payments) && payments.length > 0;
|
||||
const isDelivered = Array.isArray(shippings) && shippings.some((s) => s.state === 'shipped' || s.state === 'delivered');
|
||||
const orderLines = Array.isArray(order.lines) ? order.lines : [];
|
||||
const paymentsList = Array.isArray(payments) ? payments : [];
|
||||
const shipmentsList = Array.isArray(shipments) ? shipments : [];
|
||||
const shippingsList = Array.isArray(shippings) ? shippings : [];
|
||||
|
||||
const orderTotal = calculateOrderGrossTotal(orderLines);
|
||||
const paidTotal = calculatePaidTotal(paymentsList);
|
||||
const orderedQuantity = calculateOrderedQuantity(orderLines);
|
||||
const shippedQuantity = calculateShippedQuantity(shipmentsList);
|
||||
|
||||
const allShippings = shippingsList.length > 0 ? shippingsList : shipmentsList.flatMap((s) => s.shippings || []);
|
||||
const isPaid = orderTotal > 0 && Math.abs(paidTotal - orderTotal) < 0.01;
|
||||
const isShipped = orderedQuantity > 0 && Math.abs(shippedQuantity - orderedQuantity) < 0.001;
|
||||
const isDelivered = isShipped || allShippings.some((s) => s.state === 'shipped' || s.state === 'delivered');
|
||||
|
||||
return {
|
||||
...order,
|
||||
shipping: shippings[0] ?? null,
|
||||
shippings: shippings ?? [],
|
||||
payment: payments[0] ?? null,
|
||||
payments: payments ?? [],
|
||||
shipping: shippingsList[0] ?? (shipmentsList[0]?.shippings?.[0] ?? null),
|
||||
shippings: allShippings,
|
||||
payment: paymentsList[0] ?? null,
|
||||
payments: paymentsList,
|
||||
shipments: shipmentsList,
|
||||
orderTotal,
|
||||
paidTotal,
|
||||
orderedQuantity,
|
||||
shippedQuantity,
|
||||
paid: isPaid,
|
||||
shipped: isShipped,
|
||||
delivered: isDelivered,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
21
src/db/shipment.js
Normal file
21
src/db/shipment.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function create(googleAuthId, data) {
|
||||
const store = await getStore();
|
||||
return store.shipment.create(googleAuthId, data);
|
||||
}
|
||||
|
||||
export async function getById(googleAuthId, id) {
|
||||
const store = await getStore();
|
||||
return store.shipment.getById(googleAuthId, id);
|
||||
}
|
||||
|
||||
export async function listByOrderId(googleAuthId, orderId) {
|
||||
const store = await getStore();
|
||||
return store.shipment.listByOrderId(googleAuthId, orderId);
|
||||
}
|
||||
|
||||
export async function remove(googleAuthId, id) {
|
||||
const store = await getStore();
|
||||
return store.shipment.delete(googleAuthId, id);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const STORE_NAMESPACES = [
|
||||
'package',
|
||||
'articlelist',
|
||||
'optionslist',
|
||||
'mcp',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,19 @@ export async function importOrders({
|
||||
defaultCustomerGroupId = groups[0]?.id || null;
|
||||
} catch {}
|
||||
|
||||
let defaultWarehouseId = null;
|
||||
try {
|
||||
const warehouses = await store.warehouse.list(googleAuthId);
|
||||
if (warehouses && warehouses.length > 0) {
|
||||
defaultWarehouseId = warehouses[0].id;
|
||||
} else {
|
||||
const wh = await store.warehouse.create(googleAuthId, { name: 'Standardlager' });
|
||||
defaultWarehouseId = wh.id;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Could not ensure warehouse:', err.message);
|
||||
}
|
||||
|
||||
const entityMap = new Map();
|
||||
try {
|
||||
const entities = await store.entity.list(googleAuthId);
|
||||
@@ -220,19 +233,37 @@ export async function importOrders({
|
||||
|
||||
// Handle Delivery / Shipping
|
||||
const shippingType = cleanValue(m.shippingType);
|
||||
if (shippingType) {
|
||||
const isShipped = String(m.shipped ?? '').trim() === '1';
|
||||
const shippingState = isShipped ? 'shipped' : 'open';
|
||||
const isShipped = String(m.shipped ?? '').trim() === '1';
|
||||
|
||||
try {
|
||||
await store.shipping.create(googleAuthId, {
|
||||
manifestId: createdOrder.id,
|
||||
type: shippingType,
|
||||
state: shippingState,
|
||||
});
|
||||
shippingsImported++;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create shipping for order ${orderNumber}:`, err.message);
|
||||
if (isShipped || shippingType) {
|
||||
const shippableLines = lines
|
||||
.filter((l) => l.sku)
|
||||
.map((l) => ({ sku: l.sku, amount: l.amount }));
|
||||
|
||||
if (shippableLines.length > 0) {
|
||||
try {
|
||||
const shipment = await store.shipment.create(googleAuthId, {
|
||||
orderId: createdOrder.id,
|
||||
date: validDate,
|
||||
lines: shippableLines,
|
||||
});
|
||||
|
||||
const trackingCode = cleanValue(m.trackingCode ?? m.tracking_code ?? m.cTracking);
|
||||
const carrier = cleanValue(m.carrier ?? m.cCarrier);
|
||||
const shippingState = isShipped ? 'shipped' : 'draft';
|
||||
|
||||
await store.shipping.create(googleAuthId, {
|
||||
manifestId: shipment.id,
|
||||
type: shippingType || 'standard',
|
||||
state: shippingState,
|
||||
trackingCode: trackingCode || undefined,
|
||||
carrier: carrier || undefined,
|
||||
warehouseId: defaultWarehouseId || undefined,
|
||||
});
|
||||
shippingsImported++;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create shipment/shipping for order ${orderNumber}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createSearchArticlesTool } from '../tools/search-articles.js';
|
||||
import { createEanChecksumTool } from '../tools/ean-checksum.js';
|
||||
import { createNetGrossCalcTool } from '../tools/net-gross-calc.js';
|
||||
import { createSetUserRouteTool } from '../tools/set-user-route.js';
|
||||
import { createSubagentTool } from '../tools/subagent.js';
|
||||
import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
|
||||
|
||||
// #region Constants
|
||||
@@ -517,7 +518,7 @@ export class ConversationManager {
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region LLM Run
|
||||
// #region Run
|
||||
/**
|
||||
* Run the LLM over the stored transcript, stream the answer over SSE,
|
||||
* persist reasoning, tool calls, and assistant messages, then return to `idle`.
|
||||
@@ -757,6 +758,13 @@ export class ConversationManager {
|
||||
this.broadcast('route', { route });
|
||||
},
|
||||
}),
|
||||
createSubagentTool({
|
||||
googleAuthId,
|
||||
companyId: this.companyId,
|
||||
conversationId: this.conversationId,
|
||||
parentModel: model,
|
||||
depth: 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ export const TOOL_DESCRIPTIONS = {
|
||||
|
||||
set_user_route:
|
||||
'Navigate user to a frontend route when asked to view/open an entity or page (see ROUTE MATRIX in prompt).',
|
||||
|
||||
subagent:
|
||||
'Delegate a task to an isolated subagent. Pre-loads requested SKILL files into its ' +
|
||||
'system prompt and returns a compact summary of tool executions alongside the final output. ' +
|
||||
'Subagents can call all tools you can call except subagent, ask_user and set_user_route',
|
||||
};
|
||||
|
||||
// #endregion
|
||||
@@ -243,6 +248,14 @@ export const TOOL_PARAMETER_DESCRIPTIONS = {
|
||||
set_user_route: {
|
||||
route: 'Frontend route from Route Matrix (e.g. "/artikel/ART-10042", "/auftrag/1001").',
|
||||
},
|
||||
|
||||
subagent: {
|
||||
note: NOTE_DESC,
|
||||
task: 'Specific instructions and goal for the subagent.',
|
||||
skills: 'Array of SKILL names or slugs to inject directly into the subagent system prompt (e.g. ["api-article", "label"]).',
|
||||
conversation_id: 'Existing subagent conversation ID to follow up on, or null to create a new one.',
|
||||
max_iterations: 'Safety bound on tool loops (default 15, max 30).',
|
||||
},
|
||||
};
|
||||
|
||||
export const PATCH_PARAMETER_DESCRIPTIONS = TOOL_PARAMETER_DESCRIPTIONS.patch;
|
||||
|
||||
411
src/tools/subagent.js
Normal file
411
src/tools/subagent.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'libllmorbiter';
|
||||
import env from '../config/env.js';
|
||||
import * as db from '../db/modelconversation.js';
|
||||
import { emit as emitCompanyEvent } from '../api/sse.js';
|
||||
import { buildSystemPrompt } from '../services/prompts.js';
|
||||
import { historyToLlmMessages } from '../services/conversationManager.js';
|
||||
import { resolveOpenRouterKey, resolveModelReasoning } from './llm-key.js';
|
||||
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
|
||||
import { SKILL_EXT, isSkillName, parseSkill } from './skill.js';
|
||||
import { getApiRouteSkill } from './routes-skills.js';
|
||||
import { prepareVfs } from './vfs.js';
|
||||
import { createDashbackRequestTool } from './dashback-request.js';
|
||||
import { createJavascriptExecuteTool } from './javascript.js';
|
||||
import { createFindFilesTool } from './find-files.js';
|
||||
import { createReadFileTool } from './read-file.js';
|
||||
import { createPatchTool } from './patch.js';
|
||||
import { createRmTool } from './rm.js';
|
||||
import { createSearchImagesTool } from './search-images.js';
|
||||
import { createAnalyzeImageTool } from './analyze-image.js';
|
||||
import { createEditImageTool } from './edit-image.js';
|
||||
import { createWebSearchTool } from './web-search.js';
|
||||
import { createUrlGetTool } from './url-get.js';
|
||||
import { createPdfReadTool } from './pdf-read.js';
|
||||
import { createResetVfsTool } from './reset-vfs.js';
|
||||
import { createApiSuggestionsOrIssuesTool } from './apisuggestionsorissues.js';
|
||||
import { createSearchArticlesTool } from './search-articles.js';
|
||||
import { createEanChecksumTool } from './ean-checksum.js';
|
||||
import { createNetGrossCalcTool } from './net-gross-calc.js';
|
||||
|
||||
/**
|
||||
* subagent — delegates a self-contained task to an isolated LLMOrbiter run that lives in
|
||||
* its own persisted conversation (`modelconversation` / `modelmessage`).
|
||||
*
|
||||
* The subagent gets the parent's toolbelt minus `ask_user`, `subagent` and `set_user_route`
|
||||
* (it cannot talk to the user, spawn further subagents or drive the UI). The parent only
|
||||
* receives the final output plus a byte-level audit of the tool calls; the full transcript
|
||||
* stays in the subagent's conversation.
|
||||
*/
|
||||
|
||||
// #region Constants
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS = 15;
|
||||
const MAX_ITERATIONS_LIMIT = 30;
|
||||
/** Subagents never spawn subagents: the parent runs at depth 0, its child at depth 1. */
|
||||
const MAX_SUBAGENT_DEPTH = 1;
|
||||
const SUBAGENT_PROVIDER = 'openrouter';
|
||||
|
||||
const SUBAGENT_PROMPT_NOTICE = [
|
||||
'SUBAGENT MODE:',
|
||||
'- You are a subagent working for a primary agent, not talking to the end user. You cannot ask the user questions.',
|
||||
'- Complete the task with your tools, then finish with a concise, self-contained final answer: what was done, key findings, and any file paths or IDs the primary agent needs.',
|
||||
'- Do not ask for confirmation; if the task is ambiguous, state your assumption and proceed.',
|
||||
].join('\n');
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Skill Injection
|
||||
|
||||
/**
|
||||
* Turns a skill name or slug into its flat SKILL file name: `api-article` → `api-article.SKILL`.
|
||||
* @param {string} nameOrSlug
|
||||
* @returns {string}
|
||||
*/
|
||||
function toSkillFileName(nameOrSlug) {
|
||||
const trimmed = String(nameOrSlug ?? '').trim();
|
||||
return trimmed.toLowerCase().endsWith(SKILL_EXT.toLowerCase()) ? trimmed : `${trimmed}${SKILL_EXT}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a SKILL file by name: the company's VFS root first, then the virtual
|
||||
* `api-<section>.SKILL` files generated from docs/api/routes.md.
|
||||
* @param {string} vfsRoot
|
||||
* @param {string} fileName
|
||||
* @returns {string|null} raw SKILL content, or null when no such skill exists
|
||||
*/
|
||||
function readSkillContent(vfsRoot, fileName) {
|
||||
const diskPath = path.join(vfsRoot, fileName);
|
||||
if (fs.existsSync(diskPath) && fs.statSync(diskPath).isFile()) {
|
||||
return fs.readFileSync(diskPath, 'utf8');
|
||||
}
|
||||
return getApiRouteSkill(fileName)?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested skills and renders them as an `# INJECTED SKILLS` prompt block.
|
||||
* Unknown skills are rejected as a whole so the primary agent can correct the call
|
||||
* instead of delegating to a subagent that silently lacks its guidelines.
|
||||
*
|
||||
* @param {string} vfsRoot
|
||||
* @param {string[]|null|undefined} skillNames
|
||||
* @returns {string} empty string when no skills were requested
|
||||
* @throws {ToolError} on malformed or unknown skill names
|
||||
*/
|
||||
function buildInjectedSkillsBlock(vfsRoot, skillNames) {
|
||||
if (skillNames == null) return '';
|
||||
if (!Array.isArray(skillNames)) throw new ToolError('"skills" must be an array of skill names or null.');
|
||||
|
||||
const fileNames = [...new Set(skillNames.map(toSkillFileName))];
|
||||
const invalid = fileNames.filter((fileName) => !isSkillName(fileName));
|
||||
if (invalid.length > 0) {
|
||||
throw new ToolError(`Invalid skill name(s): ${invalid.join(', ')}. Skills are flat names like "api-article".`);
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
const missing = [];
|
||||
for (const fileName of fileNames) {
|
||||
const content = readSkillContent(vfsRoot, fileName);
|
||||
if (content == null) {
|
||||
missing.push(fileName);
|
||||
continue;
|
||||
}
|
||||
const { title, markdown } = parseSkill(content);
|
||||
sections.push(`## ${title} (${fileName})\n\n${markdown}`);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new ToolError(
|
||||
`Unknown skill(s): ${missing.join(', ')}. Call find_files({ skills: true }) to list available skills.`
|
||||
);
|
||||
}
|
||||
return sections.length > 0 ? `# INJECTED SKILLS\n\n${sections.join('\n\n')}` : '';
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Conversation Guards
|
||||
|
||||
/**
|
||||
* Rejects a follow-up when the target thread is not in a state where appending a user
|
||||
* message is a legal turn: it must be idle, and its last conversational message must
|
||||
* not already be a `user` message awaiting a response.
|
||||
*
|
||||
* @param {object} conversation
|
||||
* @param {Array<{ role: string }>} history
|
||||
* @throws {ToolError}
|
||||
*/
|
||||
function assertConversationAcceptsTask(conversation, history) {
|
||||
if (conversation.state !== 'idle') {
|
||||
throw new ToolError(`Subagent conversation ${conversation.id} is busy; wait for it to finish.`);
|
||||
}
|
||||
// `usage` and `reasoning` rows are bookkeeping, not conversational turns.
|
||||
const lastTurn = [...history].reverse().find((m) => m.role !== 'usage' && m.role !== 'reasoning');
|
||||
if (lastTurn?.role === 'user') {
|
||||
throw new ToolError(
|
||||
`Subagent conversation ${conversation.id} is already awaiting a response (last message is a user message).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Helpers
|
||||
|
||||
function stringifyToolOutput(result) {
|
||||
return typeof result === 'string' ? result : JSON.stringify(result ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the subagent toolbelt: the parent's tools without `ask_user`, `subagent` and
|
||||
* `set_user_route`.
|
||||
*/
|
||||
function createSubagentTools({ googleAuthId, companyId, model }) {
|
||||
return [
|
||||
createDashbackRequestTool({ googleAuthId }),
|
||||
createJavascriptExecuteTool({ googleAuthId }),
|
||||
createFindFilesTool({ googleAuthId }),
|
||||
createReadFileTool({ googleAuthId }),
|
||||
createPatchTool({ googleAuthId }),
|
||||
createRmTool({ googleAuthId }),
|
||||
createSearchImagesTool({ googleAuthId }),
|
||||
createAnalyzeImageTool({ googleAuthId, model }),
|
||||
createEditImageTool({ googleAuthId, model }),
|
||||
createWebSearchTool({ googleAuthId }),
|
||||
createUrlGetTool({ googleAuthId }),
|
||||
createPdfReadTool({ googleAuthId }),
|
||||
createResetVfsTool({ googleAuthId }),
|
||||
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }),
|
||||
createSearchArticlesTool({ googleAuthId, companyId }),
|
||||
createEanChecksumTool(),
|
||||
createNetGrossCalcTool(),
|
||||
];
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Factory
|
||||
|
||||
/**
|
||||
* Creates the `subagent` tool.
|
||||
*
|
||||
* @param {{
|
||||
* googleAuthId: number,
|
||||
* companyId: number,
|
||||
* conversationId: number,
|
||||
* parentModel: string,
|
||||
* depth?: number,
|
||||
* }} ctx `conversationId` is the active parent conversation (target of the self-talk guard);
|
||||
* `parentModel` is the parent's resolved model, inherited by the subagent.
|
||||
*/
|
||||
// MARK: createSubagentTool — guarded subagent execution loop
|
||||
export function createSubagentTool({ googleAuthId, companyId, conversationId: parentConversationId, parentModel, depth = 0 }) {
|
||||
return defineTool({
|
||||
name: 'subagent',
|
||||
description: TOOL_DESCRIPTIONS.subagent,
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['task'],
|
||||
properties: {
|
||||
task: { type: 'string', description: TOOL_PARAMETER_DESCRIPTIONS.subagent.task },
|
||||
skills: {
|
||||
type: ['array', 'null'],
|
||||
items: { type: 'string' },
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.skills,
|
||||
},
|
||||
conversation_id: {
|
||||
type: ['integer', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.conversation_id,
|
||||
},
|
||||
max_iterations: {
|
||||
type: ['integer', 'null'],
|
||||
description: TOOL_PARAMETER_DESCRIPTIONS.subagent.max_iterations,
|
||||
},
|
||||
note: { type: ['string', 'null'], description: TOOL_PARAMETER_DESCRIPTIONS.subagent.note },
|
||||
},
|
||||
},
|
||||
invoke: async ({ task, skills = null, conversation_id: targetConversationId = null, max_iterations: maxIterations = null } = {}, { signal } = {}) => {
|
||||
// MARK: validate input and guards before touching the database
|
||||
if (depth >= MAX_SUBAGENT_DEPTH) {
|
||||
throw new ToolError('Subagents cannot spawn further subagents.');
|
||||
}
|
||||
if (typeof task !== 'string' || !task.trim()) {
|
||||
throw new ToolError('"task" must be a non-empty string.');
|
||||
}
|
||||
if (targetConversationId != null && targetConversationId === parentConversationId) {
|
||||
throw new ToolError('Cannot target active parent conversation');
|
||||
}
|
||||
const iterationLimit = maxIterations != null ? Number(maxIterations) : DEFAULT_MAX_ITERATIONS;
|
||||
if (!Number.isInteger(iterationLimit) || iterationLimit < 1) {
|
||||
throw new ToolError(`"max_iterations" must be a positive integer (received ${maxIterations}).`);
|
||||
}
|
||||
const boundedIterations = Math.min(iterationLimit, MAX_ITERATIONS_LIMIT);
|
||||
|
||||
const vfsRoot = await prepareVfs({ googleAuthId });
|
||||
const injectedSkills = buildInjectedSkillsBlock(vfsRoot, skills);
|
||||
|
||||
// MARK: create or resume the subagent conversation
|
||||
let conversation;
|
||||
let history = [];
|
||||
if (targetConversationId != null) {
|
||||
conversation = await db.get(googleAuthId, targetConversationId);
|
||||
history = await db.listMessages(googleAuthId, targetConversationId);
|
||||
if (conversation.started_by !== parentConversationId) {
|
||||
throw new ToolError(`Conversation ${targetConversationId} is not a subagent conversation started by this conversation.`);
|
||||
}
|
||||
assertConversationAcceptsTask(conversation, history);
|
||||
} else {
|
||||
conversation = await db.create(googleAuthId, { provider: SUBAGENT_PROVIDER, model: parentModel, started_by: parentConversationId });
|
||||
emitCompanyEvent('modelconversation', { companyId });
|
||||
}
|
||||
const subagentConversationId = conversation.id;
|
||||
|
||||
// Persist the task as a user turn (DB refuses unless idle), then mark busy.
|
||||
await db.addMessage(googleAuthId, subagentConversationId, {
|
||||
role: 'user',
|
||||
message: JSON.stringify({ content: task.trim() }),
|
||||
});
|
||||
await db.setState(googleAuthId, subagentConversationId, 'busy');
|
||||
emitCompanyEvent('modelconversation', { companyId });
|
||||
|
||||
const toolCalls = [];
|
||||
const toolCallsById = new Map();
|
||||
let streamedText = '';
|
||||
let streamedReasoning = '';
|
||||
|
||||
const saveMessage = async (role, payload, label) => {
|
||||
try {
|
||||
await db.addMessage(googleAuthId, subagentConversationId, { role, message: JSON.stringify(payload) });
|
||||
emitCompanyEvent('modelconversation', { companyId });
|
||||
} catch (err) {
|
||||
console.error(`[subagent ${subagentConversationId}] failed to save ${label}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const apiKey = await resolveOpenRouterKey(googleAuthId);
|
||||
const reasoningEffort = await resolveModelReasoning(googleAuthId);
|
||||
const fullHistory = await db.listMessages(googleAuthId, subagentConversationId);
|
||||
const systemPrompt = [
|
||||
await buildSystemPrompt(vfsRoot, googleAuthId),
|
||||
SUBAGENT_PROMPT_NOTICE,
|
||||
injectedSkills,
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const orbiter = new LLMOrbiter(new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl }), {
|
||||
onReasoning: (reasoning) => {
|
||||
streamedReasoning = reasoning;
|
||||
},
|
||||
onContent: (chunk) => {
|
||||
streamedText += chunk;
|
||||
},
|
||||
onAssistantMessage: async ({ content, tool_calls, reasoning_details, reasoning }) => {
|
||||
const plain = (reasoning || streamedReasoning.trim() || reasoning_details?.find((d) => d?.type === 'reasoning.summary')?.summary || '').trim();
|
||||
const encrypted = reasoning_details?.find((d) => d?.type === 'reasoning.encrypted')?.data || '';
|
||||
streamedReasoning = '';
|
||||
if (plain || encrypted) {
|
||||
await saveMessage('reasoning', { plain, encrypted }, 'reasoning message');
|
||||
}
|
||||
|
||||
const trimmedContent = (content || streamedText).trim();
|
||||
const hasToolCalls = Array.isArray(tool_calls) && tool_calls.length > 0;
|
||||
if (trimmedContent || hasToolCalls) {
|
||||
await saveMessage('assistant', {
|
||||
content: trimmedContent,
|
||||
...(hasToolCalls ? { tool_calls } : {}),
|
||||
}, 'assistant message');
|
||||
streamedText = '';
|
||||
}
|
||||
},
|
||||
onToolCall: async ({ name, args, id, callItem }) => {
|
||||
const rawArgs = callItem?.function?.arguments || (typeof args === 'string' ? args : JSON.stringify(args || {}));
|
||||
const entry = { name, input_bytes: Buffer.byteLength(rawArgs), output_bytes: 0 };
|
||||
toolCalls.push(entry);
|
||||
toolCallsById.set(id || callItem?.id || `${name}#${toolCalls.length}`, entry);
|
||||
console.log(`[subagent ${subagentConversationId}] tool call: ${name} args=${rawArgs}`);
|
||||
},
|
||||
onToolResult: async ({ name, result, id, callItem }) => {
|
||||
const rawArgs = callItem?.function?.arguments || '';
|
||||
const output = stringifyToolOutput(result);
|
||||
const entry = toolCallsById.get(id || callItem?.id);
|
||||
if (entry) entry.output_bytes = Buffer.byteLength(output);
|
||||
await saveMessage('tool', {
|
||||
name,
|
||||
parameters: rawArgs,
|
||||
content: output,
|
||||
tool_call_id: id || callItem?.id || '',
|
||||
}, 'tool result message');
|
||||
},
|
||||
onToolError: async ({ name, error, id, callItem }) => {
|
||||
const rawArgs = callItem?.function?.arguments || '';
|
||||
const output = `Tool error [${name}]: ${error}`;
|
||||
const entry = toolCallsById.get(id || callItem?.id);
|
||||
if (entry) entry.output_bytes = Buffer.byteLength(output);
|
||||
await saveMessage('tool', {
|
||||
name,
|
||||
parameters: rawArgs,
|
||||
content: output,
|
||||
tool_call_id: id || callItem?.id || '',
|
||||
}, 'tool error message');
|
||||
},
|
||||
onUsage: async (u) => {
|
||||
if (!u) return;
|
||||
await saveMessage('usage', {
|
||||
input: u.input ?? 0,
|
||||
cached: u.cached ?? 0,
|
||||
output: u.output ?? 0,
|
||||
...(u.cost != null ? { cost: u.cost } : {}),
|
||||
}, 'usage message');
|
||||
},
|
||||
onLLMCall: (call) => {
|
||||
console.log(
|
||||
`[subagent ${subagentConversationId}] LLM call #${call.iteration}: ` +
|
||||
`model=${call.model}, messages=${call.messageCount}, promptChars=${call.promptSize}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await orbiter.run({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...historyToLlmMessages(fullHistory),
|
||||
],
|
||||
model: parentModel,
|
||||
maxIterations: boundedIterations,
|
||||
extraBody: reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {},
|
||||
tools: createSubagentTools({ googleAuthId, companyId, model: parentModel }),
|
||||
signal,
|
||||
});
|
||||
|
||||
const output = (result.content ?? '').trim();
|
||||
return {
|
||||
conversation_id: subagentConversationId,
|
||||
status: output ? 'completed' : 'empty',
|
||||
output,
|
||||
tool_calls: toolCalls,
|
||||
iterations: result.iterations,
|
||||
usage: {
|
||||
input: result.usage?.input ?? 0,
|
||||
output: result.usage?.output ?? 0,
|
||||
cached: result.usage?.cached ?? 0,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ToolError) throw err;
|
||||
console.error(`[subagent ${subagentConversationId}] run failed:`, err);
|
||||
throw new ToolError(`Subagent conversation ${subagentConversationId} failed: ${err.message}`);
|
||||
} finally {
|
||||
try {
|
||||
await db.setState(googleAuthId, subagentConversationId, 'idle');
|
||||
} catch (err) {
|
||||
console.error(`[subagent ${subagentConversationId}] failed to reset state:`, err);
|
||||
}
|
||||
emitCompanyEvent('modelconversation', { companyId });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// #endregion
|
||||
Reference in New Issue
Block a user