From ab40a38a75f9790e1a19baa50b5d4af34bc8b20a Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 4 Oct 2026 23:39:49 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 - docs/api/routes-all.md | 652 +++++++++++++++++--------------- docs/api/routes.md | 540 +++++++++++++------------- docs/api/routes/articleIndex.md | 60 --- scripts/generateApiDocs.js | 135 +++++-- src/api/index.js | 4 + src/api/routes/article.js | 119 ------ src/db/article.js | 6 - src/services/imageCrop.js | 402 +++++++++++++++----- src/services/imageCropWorker.js | 163 ++++++++ 10 files changed, 1217 insertions(+), 865 deletions(-) delete mode 100644 docs/api/routes/articleIndex.md create mode 100644 src/services/imageCropWorker.js diff --git a/docs/api/README.md b/docs/api/README.md index bdacf76..36cef42 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -67,7 +67,6 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/article/supplier/update.md`](routes/article/supplier/update.md) | `PATCH /article/{sku}/supplier/{id}` | | [`routes/article/_sku/track_stock.md`](routes/article/_sku/track_stock.md) | `PUT /article/{sku}/track_stock` | | [`routes/article/set-weight.md`](routes/article/set-weight.md) | `PUT /article/{sku}/weight` | -| [`routes/articleIndex.md`](routes/articleIndex.md) | `GET /articleIndex` | | [`routes/attributename.get.md`](routes/attributename.get.md) | `GET /attributename` | | [`routes/attributename.post.md`](routes/attributename.post.md) | `POST /attributename` | | [`routes/attributename/order.md`](routes/attributename/order.md) | `PUT /attributename/order` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 983bc75..58732e2 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,559 +1,591 @@ # Dash API Routes Overview (All) -Overview of all 181 API endpoints across all modules. +Overview of all 180 API endpoints across all modules. -### Parameter Notation +### Notation -- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`. -- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`). -- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`). -- **Path parameters** match `{param}` in the route URL. -- **Query parameters** are prefixed with `query: ...`. -- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional). -- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property. +Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional. +Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below. + +### Reusable types + +- `T1` = `{ article: T2 }` +- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num, weight: num, unit: obj, brand: obj, categoryIds: int[], photos: T17[], eans: str[], prices: T27[], suppliers: T9[] }` +- `T3` = `{ delivery: T4 }` +- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T32, lines: T18[], files: T8[] }` +- `T5` = `{ order: T7 }` +- `T6` = `{ quote: T7 }` +- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }` +- `T8` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created: str, modified: str, thumbavif: str, thumbx: int, thumby: int, meta: obj }` +- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T35 }` +- `T10` = `{ ledger: T11[] }` +- `T11` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash: str, sku: str, created_at: str }` +- `T12` = `{ id: int, entityId: int, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }` +- `T13` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[] }` +- `T14` = `{ entity: T20 }` +- `T15` = `{ printer: T21 }` +- `T16` = `{ labeltemplate: T23 }` +- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }` +- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }` +- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }` +- `T20` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }` +- `T21` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }` +- `T22` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: str }` +- `T23` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: obj, thumb: str }` +- `T24` = `{ article: T26 }` +- `T25` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }` +- `T26` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` +- `T27` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` +- `T28` = `{ id: int, email: str, selectedCompanyId: int, picture: str }` +- `T29` = `{ id: int, entityId: int, contactType: str, contact: str }` +- `T30` = `{ id: int, attributeId: int, value: str, sort: int }` +- `T31` = `{ id: int, name: str, createdAt: str, updatedAt: str }` +- `T32` = `{ totalPositions: int, completelyOnLedgerPositions: int }` +- `T33` = `{ id: int, name: str, sort: int, exclusive: bool }` +- `T34` = `{ warehouse: T36 }` +- `T35` = `{ net: num, taxId: int, taxRate: num }` +- `T36` = `{ id: int, name: str, sort: int }` +- `T37` = `{ id: int, categoryIds: int[] }` ### Routes #### Create article in category — `POST /article` -- Params: `body: { categoryId: integer, name: string, track_stock?: boolean }` -- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `body: { categoryId: int, name: str, track_stock?: bool }` +- Returns: `201 T24` #### Export articles as ZIP — `GET /article/export.zip` -- Params: `query: categoryId?: integer` +- Params: `query: categoryId?: int` - Returns: `200 zip` #### Delete article by SKU — `DELETE /article/{sku}` -- Params: `sku: string, query: cascade?: boolean` +- Params: `sku: str, query: cascade?: bool` - Returns: `204 void` #### Get article by SKU — `GET /article/{sku}` -- Params: `sku: string` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str` +- Returns: `200 T1` #### Update article by SKU — `PATCH /article/{sku}` -- Params: `sku: string, body: { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { name?: str, unitamount?: num, unitSi?: str, track_stock?: bool }` +- Returns: `200 T1` #### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist` -- Params: `sku: string` -- Returns: `200 { items: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }` #### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist` -- Params: `sku: string, body: { items: { articleId?: integer, sku?: string, count?: integer, sort?: integer }[] }` -- Returns: `200 { items: object[] }` +- Params: `sku: str, body: { items: { articleId?: int, sku?: str, count?: int, sort?: int }[] }` +- Returns: `200 { items: obj[] }` #### List article attribute values — `GET /article/{sku}/attributevalue` -- Params: `sku: string` -- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Bulk set article attribute values — `PUT /article/{sku}/attributevalue` -- Params: `sku: string, body: integer[]` -- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `sku: str, body: int[]` +- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Set or clear article brand by SKU — `PUT /article/{sku}/brand` -- Params: `sku: string, body: { brandId: integer }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { brandId: int }` +- Returns: `200 T1` #### Replace article categories by SKU — `PUT /article/{sku}/categories` -- Params: `sku: string, body: { categoryIds: integer[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { categoryIds: int[] }` +- Returns: `200 T1` #### List child articles of a parent article — `GET /article/{sku}/children` -- Params: `sku: string` -- Returns: `200 { children: { id: integer, sku: string, name: string, parentsort: integer, attributeValueId: integer, attributeValue: string }[] }` +- 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: string, body: { attributeValueId?: integer, value?: string, name?: string }` -- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }` +- Returns: `201 T24` #### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children` -- Params: `sku: string, body: { sku: string, attributeValueId?: integer, value?: string, reparent?: boolean }` -- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean }, children: object[] }` +- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }` +- Returns: `200 { article: T26, children: obj[] }` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}` -- Params: `sku: string, childSku: string, query: deleteArticle?: boolean` -- Returns: `200 { success: boolean, deleted: boolean }` +- 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: string, body: { eans: string[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { eans: str[] }` +- Returns: `200 T1` #### Get article history by SKU — `GET /article/{sku}/history` -- Params: `sku: string, query: limit?: integer` -- Returns: `200 { history: { id: integer, date: string, text: string }[], truncated: boolean }` +- Params: `sku: str, query: limit?: int` +- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }` #### Add article image — `POST /article/{sku}/image` -- Params: `sku: string, image/avif` -- Returns: `201 { image: { picId: integer, sort: integer, full: { w: integer, h: integer, size: integer }, thumb: { w: integer, h: integer }, hash: string, thumbHash: string } }` +- Params: `sku: str, image/avif` +- Returns: `201 { image: { picId: int, sort: int, full: { w: int, h: int, size: int }, thumb: { w: int, h: int }, hash: str, thumbHash: str } }` #### Delete article image — `DELETE /article/{sku}/image/{fullHash}` -- Params: `sku: string, fullHash: string` +- Params: `sku: str, fullHash: str` - Returns: `204 void` #### Reorder article images — `PUT /article/{sku}/images/order` -- Params: `sku: string, body: { fullHashes: string[] }` -- Returns: `200 { fullHashes: string[] }` +- Params: `sku: str, body: { fullHashes: str[] }` +- Returns: `200 { fullHashes: str[] }` #### Get options list for an article — `GET /article/{sku}/optionslist` -- Params: `sku: string` -- Returns: `200 { options: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer, mandated: boolean }[] }` +- Params: `sku: str` +- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }` #### Set options list for an article — `PUT /article/{sku}/optionslist` -- Params: `sku: string, body: { options: { articleId?: integer, sku?: string, count?: integer, sort?: integer, mandated?: boolean }[] }` -- Returns: `200 { options: object[] }` +- Params: `sku: str, body: { options: { articleId?: int, sku?: str, count?: int, sort?: int, mandated?: bool }[] }` +- Returns: `200 { options: obj[] }` #### Toggle parent mode for an article — `POST /article/{sku}/parent` -- Params: `sku: string, body: { isParent: boolean, attributeNameId?: integer }` -- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` +- Returns: `200 T24` #### Replace POS prices by SKU — `PUT /article/{sku}/prices` -- Params: `sku: string, body: { prices: { price: number, tax: number, minmultiplier?: number }[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }` +- Returns: `200 T1` #### Print article label — `POST /article/{sku}/print` -- Params: `sku: string, body: { templateId: integer, printerId: integer, quantity: integer }` +- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }` - Returns: `200` #### List article properties — `GET /article/{sku}/property` -- Params: `sku: string` -- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Bulk set article properties — `PUT /article/{sku}/property` -- Params: `sku: string, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }` -- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }` +- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Delete article property — `DELETE /article/{sku}/property/{propertyId}` -- Params: `sku: string, propertyId: integer` +- Params: `sku: str, propertyId: int` - Returns: `200` #### List article suppliers — `GET /article/{sku}/supplier` -- Params: `sku: string` -- Returns: `200 { suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] }` +- Params: `sku: str` +- Returns: `200 { suppliers: T9[] }` #### Assign supplier to article — `POST /article/{sku}/supplier` -- Params: `sku: string, body: { entityId: integer, sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }` -- Returns: `201 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }` +- Params: `sku: str, body: { entityId: int, sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }` +- Returns: `201 { supplier: T9 }` #### Reorder article suppliers — `PUT /article/{sku}/supplier/order` -- Params: `sku: string, body: { ids: integer[] }` -- Returns: `200 { ids: integer[] }` +- Params: `sku: str, body: { ids: int[] }` +- Returns: `200 { ids: int[] }` #### Remove supplier assignment from article — `DELETE /article/{sku}/supplier/{id}` -- Params: `sku: string, id: integer` +- Params: `sku: str, id: int` - Returns: `204 void` #### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}` -- Params: `sku: string, id: integer, body: { sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }` -- Returns: `200 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }` +- Params: `sku: str, id: int, body: { sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }` +- Returns: `200 { supplier: T9 }` #### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock` -- Params: `sku: string, body: { track_stock: boolean }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { track_stock: bool }` +- Returns: `200 T1` #### Set article weight by SKU — `PUT /article/{sku}/weight` -- Params: `sku: string, body: { weight: number }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` -#### List article search index — `GET /articleIndex` -- Params: `query: windowSize: integer` -- Returns: `200 { articles: { id: integer, sku: string, name: string, eans: string[], categoryIds: integer[] }[], truncated: boolean }` +- Params: `sku: str, body: { weight: num }` +- Returns: `200 T1` #### List attribute names — `GET /attributename` _none_ -- Returns: `200 { attributenames: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] }[] }` +- Returns: `200 { attributenames: T13[] }` #### Create attribute name — `POST /attributename` -- Params: `body: { name: string, sort?: integer, exclusive?: boolean }` -- Returns: `201 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }` +- Params: `body: { name: str, sort?: int, exclusive?: bool }` +- Returns: `201 { attributename: T13 }` #### Reorder attribute names — `PUT /attributename/order` -- Params: `body: { attributenameIds: integer[] }` +- Params: `body: { attributenameIds: int[] }` - Returns: `200` #### List values for an attribute — `GET /attributename/{attributeId}/value` -- Params: `attributeId: integer` -- Returns: `200 { values: { id: integer, attributeId: integer, value: string, sort: integer }[] }` +- Params: `attributeId: int` +- Returns: `200 { values: T30[] }` #### Create value for an attribute — `POST /attributename/{attributeId}/value` -- Params: `attributeId: integer, body: { value: string, sort?: integer }` -- Returns: `201 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }` +- Params: `attributeId: int, body: { value: str, sort?: int }` +- Returns: `201 { attributevalue: T30 }` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order` -- Params: `attributeId: integer, body: { attributevalueIds: integer[] }` +- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Returns: `200` #### Delete attribute name — `DELETE /attributename/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update attribute name — `PATCH /attributename/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }` -- Returns: `200 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }` +- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` +- Returns: `200 { attributename: T13 }` #### Delete attribute value — `DELETE /attributevalue/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update attribute value — `PATCH /attributevalue/{id}` -- Params: `id: integer, body: { value?: string, sort?: integer }` -- Returns: `200 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }` +- Params: `id: int, body: { value?: str, sort?: int }` +- Returns: `200 { attributevalue: T30 }` #### Login with Google ID token — `POST /auth/login` -- Params: `body: { idToken: string }` -- Returns: `200 { message: string, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }` +- Params: `body: { idToken: str }` +- Returns: `200 { message: str, user: T28 }` #### Logout — `POST /auth/logout` _none_ -- Returns: `200 { message: string }` +- Returns: `200 { message: str }` #### Current user — `GET /auth/me` _none_ -- Returns: `200 { user: { id: integer, email: string, selectedCompanyId: integer, picture: string }, googleClientId: string }` +- Returns: `200 { user: T28, googleClientId: str }` #### Set selected company — `PUT /auth/selected-company` -- Params: `body: { companyId: integer }` -- Returns: `200 { user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }` +- Params: `body: { companyId: int }` +- Returns: `200 { user: T28 }` #### List brands — `GET /brand` _none_ -- Returns: `200 { brands: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }[] }` +- Returns: `200 { brands: { id: int, name: str, gsprpinfo: str, pics: { id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }[] }` #### Upsert brand — `PUT /brand` -- Params: `body: { name: string, gsprpinfo?: string }` -- Returns: `200 { brand: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] } }` +- Params: `body: { name: str, gsprpinfo?: str }` +- Returns: `200 { brand: { id: int, name: str, gsprpinfo: str, pics: { id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] } }` #### Delete brand — `DELETE /brand/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Create category — `POST /category` -- Params: `body: { parentId: integer, name: string }` -- Returns: `201 { category: { id: integer, parentId: integer, sort: integer, name: string } }` +- Params: `body: { parentId: int, name: str }` +- Returns: `201 { category: { id: int, parentId: int, sort: int, name: str } }` #### Export categories as ZIP — `GET /category/export.zip` _none_ - Returns: `200 zip` #### Reorder sibling categories — `PUT /category/order` -- Params: `body: { parentId: integer, categoryIds: integer[] }` -- Returns: `200 { categoryIds: integer[] }` +- Params: `body: { parentId: int, categoryIds: int[] }` +- Returns: `200 { categoryIds: int[] }` #### Category tree — `GET /category/tree` _none_ -- Returns: `200 { languageId: integer, tree: object[], articles: object[] }` +- Returns: `200 { languageId: int, tree: obj[], articles: obj[] }` #### Delete category — `DELETE /category/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update category (rename or move) — `PATCH /category/{id}` -- Params: `id: integer, body: { name?: string, parentId?: integer }` -- Returns: `200 { category: { id: integer, name: string, slug: string, parentId: integer, sort: integer } }` +- Params: `id: int, body: { name?: str, parentId?: int }` +- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` #### Articles in category — `GET /category/{id}/articles` -- Params: `id: integer` -- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, track_stock: boolean, optionsCount: integer, articleListCount: integer, childCount: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string, brand: object }[] }` +- Params: `id: int` +- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, brand: obj }[] }` #### List category attribute values — `GET /category/{id}/attributevalue` -- Params: `id: integer` -- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `id: int` +- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Bulk set category attribute values — `PUT /category/{id}/attributevalue` -- Params: `id: integer, body: integer[]` -- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `id: int, body: int[]` +- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Delete category image — `DELETE /category/{id}/image` -- Params: `id: integer` -- Returns: `200 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }` +- Params: `id: int` +- Returns: `200 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` #### Add category image — `POST /category/{id}/image` -- Params: `id: integer, image/avif` -- Returns: `201 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }` +- Params: `id: int, image/avif` +- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` #### List category properties — `GET /category/{id}/property` -- Params: `id: integer` -- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `id: int` +- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Bulk set category properties — `PUT /category/{id}/property` -- Params: `id: integer, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }` -- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }` +- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Delete category property — `DELETE /category/{id}/property/{propertyId}` -- Params: `id: integer, propertyId: integer` +- Params: `id: int, propertyId: int` - Returns: `200` #### List companies — `GET /company` _none_ -- Returns: `200 { companies: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }[] }` +- Returns: `200 { companies: T25[] }` #### Create company — `POST /company` -- Params: `body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }` -- Returns: `201 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }` +- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` +- Returns: `201 { company: T25, user: T28 }` #### Get company history — `GET /company/history` -- Params: `query: limit?: integer` -- Returns: `200 { history: { id: integer, companyuserId: integer, date: string, text: string }[], truncated: boolean }` +- Params: `query: limit?: int` +- Returns: `200 { history: { id: int, companyuserId: int, date: str, text: str }[], truncated: bool }` #### Add company history entry — `POST /company/history` -- Params: `body: { text: string }` -- Returns: `201 { history: { id: integer, companyuserId: integer, date: string, text: string } }` +- Params: `body: { text: str }` +- Returns: `201 { history: { id: int, companyuserId: int, date: str, text: str } }` #### List company settings — `GET /company/setting` _none_ -- Returns: `200 { settings: { key: string, value: string }[] }` +- Returns: `200 { settings: { key: str, value: str }[] }` #### Get company setting by key — `GET /company/setting/{key}` -- Params: `key: string` -- Returns: `200 { setting: { key: string, value: string } }` +- Params: `key: str` +- Returns: `200 { setting: { key: str, value: str } }` #### Upsert company setting — `PUT /company/setting/{key}` -- Params: `key: string, body: { value: string, frontend?: boolean }` -- Returns: `200 { setting: { key: string, value: string } }` +- Params: `key: str, body: { value: str, frontend?: bool }` +- Returns: `200 { setting: { key: str, value: str } }` #### Delete company — `DELETE /company/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update company — `PUT /company/{id}` -- Params: `id: integer, body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }` -- Returns: `200 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number } }` +- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` +- Returns: `200 { company: T25 }` #### List customer groups — `GET /customergroup` _none_ -- Returns: `200 { customergroups: { id: integer, name: string, sort: integer, editable: boolean }[] }` +- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` #### Create customer group — `POST /customergroup` -- Params: `body: { name: string }` -- Returns: `201 { customergroup: { id: integer, name: string, sort: integer } }` +- Params: `body: { name: str }` +- Returns: `201 { customergroup: T36 }` #### Reorder customer groups — `PUT /customergroup/order` -- Params: `body: { customergroupIds: integer[] }` -- Returns: `200 { customergroupIds: integer[] }` +- Params: `body: { customergroupIds: int[] }` +- Returns: `200 { customergroupIds: int[] }` #### Delete customer group — `DELETE /customergroup/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update customer group — `PATCH /customergroup/{id}` -- Params: `id: integer, body: { name: string }` -- Returns: `200 { customergroup: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name: str }` +- Returns: `200 { customergroup: T36 }` #### List deliveries — `GET /delivery` _none_ -- Returns: `200 { deliveries: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer } }[] }` +- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T32 }[] }` #### Create delivery — `POST /delivery` -- Params: `body: { externalNumber: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], supplierId?: integer, files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }` -- Returns: `201 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `body: { externalNumber: str, date: str, lines: T22[], supplierId?: int, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }` +- Returns: `201 T3` #### Parse document (SSE) — `POST /delivery/parse` -- Params: `form: { file: string }` +- Params: `form: { file: str }` - Returns: `200 SSE` #### Delete delivery — `DELETE /delivery/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get delivery — `GET /delivery/{id}` -- Params: `id: integer` -- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `id: int` +- Returns: `200 T3` #### Update delivery — `PATCH /delivery/{id}` -- Params: `id: integer, body: { externalNumber?: string, date?: string, supplierId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }` -- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `id: int, body: { externalNumber?: str, date?: str, supplierId?: int, lines?: T22[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }` +- Returns: `200 T3` #### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}` -- Params: `id: integer, fileId: integer, query: download?: boolean` -- Returns: `200 string` +- Params: `id: int, fileId: int, query: download?: bool` +- Returns: `200 str` #### List entities — `GET /entity` -- Params: `query: customergroupId?: integer, role?: string` -- Returns: `200 { entities: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string }[] }` +- Params: `query: customergroupId?: int, role?: str` +- Returns: `200 { entities: T20[] }` #### Create entity — `POST /entity` -- Params: `body: { customergroupId: integer, name: string, firstName?: string, number?: string }` -- Returns: `201 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }` +- Returns: `201 T14` #### Peek next customer number — `GET /entity/next-number` _none_ -- Returns: `200 { number: string }` +- Returns: `200 { number: str }` #### List entity addresses — `GET /entity/{entityId}/address` -- Params: `entityId: integer` -- Returns: `200 { addresses: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string }[] }` +- Params: `entityId: int` +- Returns: `200 { addresses: T12[] }` #### Create entity address — `POST /entity/{entityId}/address` -- Params: `entityId: integer, body: { addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }` -- Returns: `201 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }` +- Params: `entityId: int, body: { addressType: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }` +- Returns: `201 { address: T12 }` #### Delete entity address — `DELETE /entity/{entityId}/address/{id}` -- Params: `entityId: integer, id: integer` +- Params: `entityId: int, id: int` - Returns: `204 void` #### Update entity address — `PATCH /entity/{entityId}/address/{id}` -- Params: `entityId: integer, id: integer, body: { addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }` -- Returns: `200 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }` +- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }` +- Returns: `200 { address: T12 }` #### List entity contacts — `GET /entity/{entityId}/contact` -- Params: `entityId: integer` -- Returns: `200 { contacts: { id: integer, entityId: integer, contactType: string, contact: string }[] }` +- Params: `entityId: int` +- Returns: `200 { contacts: T29[] }` #### Create entity contact — `POST /entity/{entityId}/contact` -- Params: `entityId: integer, body: { contactType: string, contact: string }` -- Returns: `201 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }` +- Params: `entityId: int, body: { contactType: str, contact: str }` +- Returns: `201 { contact: T29 }` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` -- Params: `entityId: integer, id: integer` +- Params: `entityId: int, id: int` - Returns: `204 void` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` -- Params: `entityId: integer, id: integer, body: { contactType?: string, contact?: string }` -- Returns: `200 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }` +- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` +- Returns: `200 { contact: T29 }` #### Delete entity — `DELETE /entity/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get entity — `GET /entity/{id}` -- Params: `id: integer` -- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `id: int` +- Returns: `200 T14` #### Update entity — `PATCH /entity/{id}` -- Params: `id: integer, body: { name?: string, firstName?: string, number?: string }` -- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `id: int, body: { name?: str, firstName?: str, number?: str }` +- Returns: `200 T14` #### Subscribe to update signals (SSE) — `GET /events` _none_ - Returns: `200 SSE` #### Database health check — `GET /health` _none_ -- Returns: `200 { ok: boolean }` +- Returns: `200 { ok: bool }` #### Full image by hash — `GET /image/{fullHash}` -- Params: `fullHash: string` +- Params: `fullHash: str` - Returns: `200 avif` #### List label templates — `GET /labeltemplate` _none_ -- Returns: `200 { labeltemplates: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string }[] }` +- Returns: `200 { labeltemplates: T23[] }` #### Create label template — `POST /labeltemplate` -- Params: `body: { name: string, width: number, height: number, dpi: integer, type: string, content: object }` -- Returns: `201 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }` +- Returns: `201 T16` #### Delete label template — `DELETE /labeltemplate/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get label template by ID — `GET /labeltemplate/{id}` -- Params: `id: integer` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int` +- Returns: `200 T16` #### Update label template (partial) — `PATCH /labeltemplate/{id}` -- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` +- Returns: `200 T16` #### Update label template — `PUT /labeltemplate/{id}` -- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` +- Returns: `200 T16` #### Query ledger by manifest article ID or SKU — `GET /ledger` -- Params: `query: manifestarticle_id?: integer, sku?: string, transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` +- Returns: `200 T10` #### Add ledger entry — `POST /ledger` -- Params: `body: { manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string }` -- Returns: `201 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string } }` +- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }` +- Returns: `201 { ledger: T11 }` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}` -- Params: `manifestarticleId: integer, query: transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `manifestarticleId: int, query: transaction_type?: str` +- Returns: `200 T10` #### Query ledger by article SKU — `GET /ledger/sku/{sku}` -- Params: `sku: string, query: transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `sku: str, query: transaction_type?: str` +- Returns: `200 T10` #### List conversations — `GET /modelconversation` _none_ -- Returns: `200 { conversations: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string }[] }` +- Returns: `200 { conversations: T19[] }` #### Create conversation — `POST /modelconversation` -- Params: `body: { provider: string, model: string }` -- Returns: `201 { conversation: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string } }` +- Params: `body: { provider: str, model: str }` +- Returns: `201 { conversation: T19 }` #### Delete conversation — `DELETE /modelconversation/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get conversation — `GET /modelconversation/{id}` -- Params: `id: integer` -- Returns: `200 { conversation: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string } }` +- Params: `id: int` +- Returns: `200 { conversation: T19 }` #### List conversation messages — `GET /modelconversation/{id}/message` -- Params: `id: integer` -- Returns: `200 { messages: { id: integer, conversation_id: integer, created_at: string, role: string, message: string }[] }` +- Params: `id: int` +- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }` #### Post a user message — `POST /modelconversation/{id}/message` -- Params: `id: integer, body: { message?: string, role?: string, tool_call_id?: string, content?: any, answer?: any }` -- Returns: `201 { message: { id: integer, conversation_id: integer, created_at: string, role: string, message: string } }` +- Params: `id: int, body: { message?: str, role?: str, tool_call_id?: str, content?: any, answer?: any }` +- Returns: `201 { message: { id: int, conversation_id: int, created_at: str, role: str, message: str } }` #### Stream conversation events (SSE) — `GET /modelconversation/{id}/stream` -- Params: `id: integer` +- Params: `id: int` - Returns: `200 SSE` #### List number sequences — `GET /number` _none_ -- Returns: `200 { numbers: { id: integer, type: string, prefix: string, padding: integer, next: integer }[] }` +- Returns: `200 { numbers: { id: int, type: str, prefix: str, padding: int, next: int }[] }` #### Update number sequence — `PATCH /number/{id}` -- Params: `id: integer, body: { prefix?: string, padding?: integer, next?: integer }` -- Returns: `200 { number: { id: integer, type: string, prefix: string, padding: integer, next: integer } }` +- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` +- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` #### List orders — `GET /order` _none_ -- Returns: `200 { orders: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }` +- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }` #### Create order — `POST /order` -- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }` -- Returns: `201 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }` +- Returns: `201 T5` #### Delete order — `DELETE /order/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get order — `GET /order/{id}` -- Params: `id: integer` -- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int` +- Returns: `200 T5` #### Update order — `PATCH /order/{id}` -- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }` -- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }` +- Returns: `200 T5` #### List POS terminals — `GET /posterminal` _none_ -- Returns: `200 { posterminals: { id: integer, name: string, pairingpin: string, categoryIds: integer[] }[] }` +- Returns: `200 { posterminals: { id: int, name: str, pairingpin: str, categoryIds: int[] }[] }` #### Create POS terminal — `PUT /posterminal` -- Params: `body: { name: string }` -- Returns: `200 { posterminal: { id: integer, name: string, pairingpin: string, categoryIds: integer[] } }` +- Params: `body: { name: str }` +- Returns: `200 { posterminal: { id: int, name: str, pairingpin: str, categoryIds: int[] } }` #### Delete POS terminal — `DELETE /posterminal/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Disable all categories on terminal — `DELETE /posterminal/{id}/all` -- Params: `id: integer` -- Returns: `200 { id: integer, categoryIds: integer[] }` +- Params: `id: int` +- Returns: `200 T37` #### Enable all categories on terminal — `PUT /posterminal/{id}/all` -- Params: `id: integer` -- Returns: `200 { id: integer, categoryIds: integer[] }` +- Params: `id: int` +- Returns: `200 T37` #### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}` -- Params: `id: integer, categoryId: integer` -- Returns: `200 { id: integer, categoryIds: integer[] }` +- Params: `id: int, categoryId: int` +- Returns: `200 T37` #### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}` -- Params: `id: integer, categoryId: integer` -- Returns: `200 { id: integer, categoryIds: integer[] }` +- Params: `id: int, categoryId: int` +- Returns: `200 T37` #### List printers — `GET /printer` _none_ -- Returns: `200 { printers: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string }[] }` +- Returns: `200 { printers: T21[] }` #### Create printer — `POST /printer` -- Params: `body: { address: string, type: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `201 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `201 T15` #### Reorder printers — `PUT /printer/order` -- Params: `body: { printerIds: integer[] }` -- Returns: `200 { printerIds: integer[] }` +- Params: `body: { printerIds: int[] }` +- Returns: `200 { printerIds: int[] }` #### Delete printer — `DELETE /printer/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get printer by ID — `GET /printer/{id}` -- Params: `id: integer` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int` +- Returns: `200 T15` #### Update printer (partial) — `PATCH /printer/{id}` -- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `200 T15` #### Update printer — `PUT /printer/{id}` -- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `200 T15` #### List property names — `GET /propertyname` _none_ -- Returns: `200 { propertynames: { id: integer, name: string, sort: integer, exclusive: boolean }[] }` +- Returns: `200 { propertynames: T33[] }` #### Create property name — `POST /propertyname` -- Params: `body: { name: string, sort?: integer, exclusive?: boolean }` -- Returns: `201 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }` +- Params: `body: { name: str, sort?: int, exclusive?: bool }` +- Returns: `201 { propertyname: T33 }` #### Reorder property names — `PUT /propertyname/order` -- Params: `body: { propertynameIds: integer[] }` +- Params: `body: { propertynameIds: int[] }` - Returns: `200` #### Delete property name — `DELETE /propertyname/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update property name — `PATCH /propertyname/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }` -- Returns: `200 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }` +- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` +- Returns: `200 { propertyname: T33 }` #### List public articles — `GET /public/{companyId}/articles` -- Params: `companyId: integer, query: categoryId?: integer, search?: string, limit?: integer, offset?: integer` -- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, gross: number, trackStock: boolean, brand: { id: integer, name: string }, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }` +- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int` +- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, gross: num, trackStock: bool, brand: { id: int, name: str }, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }` #### Get public article detail — `GET /public/{companyId}/articles/{sku}` -- Params: `companyId: integer, sku: string` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, description: string, unitamount: number, weight: number, trackStock: boolean, unit: object, brand: object, price: object, prices: object[], categories: object[], photos: object[], eans: string[], isParent: boolean, parentSku: string, variants: object[] } }` +- Params: `companyId: int, sku: str` +- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num, weight: num, trackStock: bool, unit: obj, brand: obj, price: obj, prices: obj[], categories: obj[], photos: obj[], eans: str[], isParent: bool, parentSku: str, variants: obj[] } }` #### List public categories — `GET /public/{companyId}/categories` -- Params: `companyId: integer` -- Returns: `200 { categories: { id: integer, parentId: integer, sort: integer, name: string, slug: string, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }` +- Params: `companyId: int` +- Returns: `200 { categories: { id: int, parentId: int, sort: int, name: str, slug: str, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }` #### List quotes — `GET /quote` _none_ -- Returns: `200 { quotes: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }` +- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }` #### Create quote — `POST /quote` -- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }` -- Returns: `201 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `body: { number: str, date: str, lines: T22[], entityId?: int }` +- Returns: `201 T6` #### Delete quote — `DELETE /quote/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get quote — `GET /quote/{id}` -- Params: `id: integer` -- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int` +- Returns: `200 T6` #### Update quote — `PATCH /quote/{id}` -- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }` -- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T22[] }` +- Returns: `200 T6` #### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image` -- Params: `body: { url: string }` +- Params: `body: { url: str }` - Returns: `200 avif` #### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images` -- Params: `body: { q: string, gl?: string, hl?: string, num?: integer, preferSource?: boolean }` +- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }` - Returns: `200 SSE` #### Adjust stock manually — `POST /stock/adjust` -- Params: `body: { sku?: string, warehouseId?: integer, amount?: number, text?: string, lines?: { sku: string, amount: number, warehouseId?: integer, text?: string }[] }` -- Returns: `201 { manifest: { id: integer, number: string, type: string, date: string, createdAt: string }, lines: object[], ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }` +- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: obj[], ledger: T11[] }` #### List suppliers — `GET /supplier` _none_ -- Returns: `200 { suppliers: { id: integer, name: string, createdAt: string, updatedAt: string }[] }` +- Returns: `200 { suppliers: T31[] }` #### Create supplier — `POST /supplier` -- Params: `body: { name: string }` -- Returns: `201 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }` +- Params: `body: { name: str }` +- Returns: `201 { supplier: T31 }` #### Delete supplier — `DELETE /supplier/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update supplier — `PATCH /supplier/{id}` -- Params: `id: integer, body: { name: string }` -- Returns: `200 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }` +- Params: `id: int, body: { name: str }` +- Returns: `200 { supplier: T31 }` #### List supplier articles by SKUs — `GET /supplier/{id}/articles` -- Params: `id: integer, query: skus?: string` -- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }` +- Params: `id: int, query: skus?: string` +- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T35, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles` -- Params: `id: integer, body?: { skus?: string[] }` -- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }` +- Params: `id: int, body?: { skus?: str[] }` +- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T35, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }` #### Thumb image by hash — `GET /thumb/{fullHash}` -- Params: `fullHash: string` +- Params: `fullHash: str` - Returns: `200 avif` #### Delete unit — `DELETE /unit` -- Params: `query: si: string` +- Params: `query: si: str` - Returns: `204 void` #### List units — `GET /unit` _none_ -- Returns: `200 { units: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }[] }` +- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }` #### Upsert unit — `PUT /unit` -- Params: `body: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }` -- Returns: `200 { unit: { si: string, basesi: string, basesifactor: number, symbol: string, name: string } }` +- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` +- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }` #### JTL-POS category synchronization — `GET /v1/category` -- Params: `query: lastChangedCategory?: string, limit?: integer` -- Returns: `200 object[]` +- Params: `query: lastChangedCategory?: str, limit?: int` +- Returns: `200 obj[]` #### POS discovery and pairing — `GET /v1/client` -- Params: `query: authCode: string, name?: string` -- Returns: `200 { authCode: string, authToken: string, certificateFingerprint: string, certificateSerialNumber: string, mandantId: string, mandantName: string, mandantDatabase: string, serverFingerprint: string, name: string, serverTimestamp: string }` +- Params: `query: authCode: str, name?: str` +- Returns: `200 { authCode: str, authToken: str, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str, mandantDatabase: str, serverFingerprint: str, name: str, serverTimestamp: str }` #### POS sync initialization counts — `GET /v1/init` -- Params: `query: lastChangedProduct?: integer, lastChangedCategory?: integer, lastChangedCustomer?: integer, lastChangedCustomerGroup?: integer, lastChangedCompositeProduct?: integer, lastChangedDeletedEntity?: integer` -- Returns: `200 { version: string, product_count: string, category_count: string, customer_count: string, customerGroup_count: string, compositeProduct_count: string, configurationGroup_count: string, configurationItem_count: string, deletedEntity_count: string, max_orderId_count: string }` +- Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int` +- Returns: `200 { version: str, product_count: str, category_count: str, customer_count: str, customerGroup_count: str, compositeProduct_count: str, configurationGroup_count: str, configurationItem_count: str, deletedEntity_count: str, max_orderId_count: str }` #### JTL-POS product catalog synchronization — `GET /v1/product` -- Params: `query: lastChangedProduct?: string, limit?: integer` -- Returns: `200 object[]` +- Params: `query: lastChangedProduct?: str, limit?: int` +- Returns: `200 obj[]` #### Get a virtual filesystem file — `GET /vfs/{*path}` -- Params: `path: string, query: download?: boolean` -- Returns: `200 string` +- Params: `path: str, query: download?: bool` +- Returns: `200 str` #### List warehouses — `GET /warehouse` _none_ -- Returns: `200 { warehouses: { id: integer, name: string, sort: integer }[] }` +- Returns: `200 { warehouses: T36[] }` #### Create warehouse — `POST /warehouse` -- Params: `body: { name: string, sort?: integer }` -- Returns: `201 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `body: { name: str, sort?: int }` +- Returns: `201 T34` #### Reorder warehouses — `PUT /warehouse/order` -- Params: `body: { warehouseIds: integer[] }` -- Returns: `200 { warehouseIds: integer[] }` +- Params: `body: { warehouseIds: int[] }` +- Returns: `200 { warehouseIds: int[] }` #### Delete warehouse — `DELETE /warehouse/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get warehouse by ID — `GET /warehouse/{id}` -- Params: `id: integer` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int` +- Returns: `200 T34` #### Update warehouse (partial) — `PATCH /warehouse/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer }` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name?: str, sort?: int }` +- Returns: `200 T34` #### Update warehouse — `PUT /warehouse/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer }` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name?: str, sort?: int }` +- Returns: `200 T34` diff --git a/docs/api/routes.md b/docs/api/routes.md index 2ceb86f..e7b21b9 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,454 +1,482 @@ # Dash API Routes Overview -Overview of all 145 API endpoints across all modules. +Overview of all 144 API endpoints across all modules. -### Parameter Notation +### Notation -- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`. -- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`). -- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`). -- **Path parameters** match `{param}` in the route URL. -- **Query parameters** are prefixed with `query: ...`. -- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional). -- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property. +Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional. +Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below. + +### Reusable types + +- `T1` = `{ article: T2 }` +- `T2` = `{ id: int, sku: str, name: str, slug: str, track_stock: bool, unitamount: num, weight: num, unit: obj, brand: obj, categoryIds: int[], photos: T17[], eans: str[], prices: T25[], suppliers: T9[] }` +- `T3` = `{ delivery: T4 }` +- `T4` = `{ id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T29, lines: T18[], files: T8[] }` +- `T5` = `{ order: T7 }` +- `T6` = `{ quote: T7 }` +- `T7` = `{ id: int, number: str, type: str, entityId: int, date: str, createdAt: str, lines: T18[] }` +- `T8` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created: str, modified: str, thumbavif: str, thumbx: int, thumby: int, meta: obj }` +- `T9` = `{ id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T32 }` +- `T10` = `{ ledger: T11[] }` +- `T11` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash: str, sku: str, created_at: str }` +- `T12` = `{ id: int, entityId: int, firstName: str, name: str, street: str, streetNumber: str, zip: str, city: str, county: str, addressType: str }` +- `T13` = `{ id: int, name: str, sort: int, exclusive: bool, values: T27[] }` +- `T14` = `{ entity: T19 }` +- `T15` = `{ printer: T20 }` +- `T16` = `{ labeltemplate: T22 }` +- `T17` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }` +- `T18` = `{ id: int, sku: str, name: str, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str }` +- `T19` = `{ id: int, customergroupId: int, firstName: str, name: str, number: str, createdAt: str, updatedAt: str }` +- `T20` = `{ id: int, company_id: int, address: str, type: str, model: str, serial: str, sort: int, desc: str }` +- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str, name?: str, pu?: num, rebate?: num, comment?: str }` +- `T22` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: obj, thumb: str }` +- `T23` = `{ article: T24 }` +- `T24` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` +- `T25` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` +- `T26` = `{ id: int, entityId: int, contactType: str, contact: str }` +- `T27` = `{ id: int, attributeId: int, value: str, sort: int }` +- `T28` = `{ id: int, name: str, createdAt: str, updatedAt: str }` +- `T29` = `{ totalPositions: int, completelyOnLedgerPositions: int }` +- `T30` = `{ id: int, name: str, sort: int, exclusive: bool }` +- `T31` = `{ warehouse: T33 }` +- `T32` = `{ net: num, taxId: int, taxRate: num }` +- `T33` = `{ id: int, name: str, sort: int }` ### Routes #### Create article in category — `POST /article` -- Params: `body: { categoryId: integer, name: string, track_stock?: boolean }` -- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `body: { categoryId: int, name: str, track_stock?: bool }` +- Returns: `201 T23` #### Delete article by SKU — `DELETE /article/{sku}` -- Params: `sku: string, query: cascade?: boolean` +- Params: `sku: str, query: cascade?: bool` - Returns: `204 void` #### Get article by SKU — `GET /article/{sku}` -- Params: `sku: string` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str` +- Returns: `200 T1` #### Update article by SKU — `PATCH /article/{sku}` -- Params: `sku: string, body: { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { name?: str, unitamount?: num, unitSi?: str, track_stock?: bool }` +- Returns: `200 T1` #### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist` -- Params: `sku: string` -- Returns: `200 { items: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }` #### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist` -- Params: `sku: string, body: { items: { articleId?: integer, sku?: string, count?: integer, sort?: integer }[] }` -- Returns: `200 { items: object[] }` +- Params: `sku: str, body: { items: { articleId?: int, sku?: str, count?: int, sort?: int }[] }` +- Returns: `200 { items: obj[] }` #### List article attribute values — `GET /article/{sku}/attributevalue` -- Params: `sku: string` -- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Bulk set article attribute values — `PUT /article/{sku}/attributevalue` -- Params: `sku: string, body: integer[]` -- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `sku: str, body: int[]` +- Returns: `200 { attributevalues: { id: int, articleId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Set or clear article brand by SKU — `PUT /article/{sku}/brand` -- Params: `sku: string, body: { brandId: integer }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { brandId: int }` +- Returns: `200 T1` #### Replace article categories by SKU — `PUT /article/{sku}/categories` -- Params: `sku: string, body: { categoryIds: integer[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { categoryIds: int[] }` +- Returns: `200 T1` #### List child articles of a parent article — `GET /article/{sku}/children` -- Params: `sku: string` -- Returns: `200 { children: { id: integer, sku: string, name: string, parentsort: integer, attributeValueId: integer, attributeValue: string }[] }` +- 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: string, body: { attributeValueId?: integer, value?: string, name?: string }` -- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }` +- Returns: `201 T23` #### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children` -- Params: `sku: string, body: { sku: string, attributeValueId?: integer, value?: string, reparent?: boolean }` -- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean }, children: object[] }` +- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }` +- Returns: `200 { article: T24, children: obj[] }` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}` -- Params: `sku: string, childSku: string, query: deleteArticle?: boolean` -- Returns: `200 { success: boolean, deleted: boolean }` +- 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: string, body: { eans: string[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { eans: str[] }` +- Returns: `200 T1` #### Get article history by SKU — `GET /article/{sku}/history` -- Params: `sku: string, query: limit?: integer` -- Returns: `200 { history: { id: integer, date: string, text: string }[], truncated: boolean }` +- Params: `sku: str, query: limit?: int` +- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }` #### Delete article image — `DELETE /article/{sku}/image/{fullHash}` -- Params: `sku: string, fullHash: string` +- Params: `sku: str, fullHash: str` - Returns: `204 void` #### Reorder article images — `PUT /article/{sku}/images/order` -- Params: `sku: string, body: { fullHashes: string[] }` -- Returns: `200 { fullHashes: string[] }` +- Params: `sku: str, body: { fullHashes: str[] }` +- Returns: `200 { fullHashes: str[] }` #### Get options list for an article — `GET /article/{sku}/optionslist` -- Params: `sku: string` -- Returns: `200 { options: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer, mandated: boolean }[] }` +- Params: `sku: str` +- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }` #### Set options list for an article — `PUT /article/{sku}/optionslist` -- Params: `sku: string, body: { options: { articleId?: integer, sku?: string, count?: integer, sort?: integer, mandated?: boolean }[] }` -- Returns: `200 { options: object[] }` +- Params: `sku: str, body: { options: { articleId?: int, sku?: str, count?: int, sort?: int, mandated?: bool }[] }` +- Returns: `200 { options: obj[] }` #### Toggle parent mode for an article — `POST /article/{sku}/parent` -- Params: `sku: string, body: { isParent: boolean, attributeNameId?: integer }` -- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }` +- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` +- Returns: `200 T23` #### Replace POS prices by SKU — `PUT /article/{sku}/prices` -- Params: `sku: string, body: { prices: { price: number, tax: number, minmultiplier?: number }[] }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { prices: { price: num, tax: num, minmultiplier?: num }[] }` +- Returns: `200 T1` #### Print article label — `POST /article/{sku}/print` -- Params: `sku: string, body: { templateId: integer, printerId: integer, quantity: integer }` +- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }` - Returns: `200` #### List article properties — `GET /article/{sku}/property` -- Params: `sku: string` -- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `sku: str` +- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Bulk set article properties — `PUT /article/{sku}/property` -- Params: `sku: string, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }` -- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `sku: str, body: { properties: { propertyId: int, value: str, sort?: int }[] }` +- Returns: `200 { properties: { id: int, articleId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Delete article property — `DELETE /article/{sku}/property/{propertyId}` -- Params: `sku: string, propertyId: integer` +- Params: `sku: str, propertyId: int` - Returns: `200` #### List article suppliers — `GET /article/{sku}/supplier` -- Params: `sku: string` -- Returns: `200 { suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] }` +- Params: `sku: str` +- Returns: `200 { suppliers: T9[] }` #### Assign supplier to article — `POST /article/{sku}/supplier` -- Params: `sku: string, body: { entityId: integer, sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }` -- Returns: `201 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }` +- Params: `sku: str, body: { entityId: int, sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }` +- Returns: `201 { supplier: T9 }` #### Reorder article suppliers — `PUT /article/{sku}/supplier/order` -- Params: `sku: string, body: { ids: integer[] }` -- Returns: `200 { ids: integer[] }` +- Params: `sku: str, body: { ids: int[] }` +- Returns: `200 { ids: int[] }` #### Remove supplier assignment from article — `DELETE /article/{sku}/supplier/{id}` -- Params: `sku: string, id: integer` +- Params: `sku: str, id: int` - Returns: `204 void` #### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}` -- Params: `sku: string, id: integer, body: { sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }` -- Returns: `200 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }` +- Params: `sku: str, id: int, body: { sku?: str, pu?: num, stock?: num, sort?: int, netPrice?: num, taxId?: int, taxRate?: num }` +- Returns: `200 { supplier: T9 }` #### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock` -- Params: `sku: string, body: { track_stock: boolean }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` +- Params: `sku: str, body: { track_stock: bool }` +- Returns: `200 T1` #### Set article weight by SKU — `PUT /article/{sku}/weight` -- Params: `sku: string, body: { weight: number }` -- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }` -#### List article search index — `GET /articleIndex` -- Params: `query: windowSize: integer` -- Returns: `200 { articles: { id: integer, sku: string, name: string, eans: string[], categoryIds: integer[] }[], truncated: boolean }` +- Params: `sku: str, body: { weight: num }` +- Returns: `200 T1` #### List attribute names — `GET /attributename` _none_ -- Returns: `200 { attributenames: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] }[] }` +- Returns: `200 { attributenames: T13[] }` #### Create attribute name — `POST /attributename` -- Params: `body: { name: string, sort?: integer, exclusive?: boolean }` -- Returns: `201 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }` +- Params: `body: { name: str, sort?: int, exclusive?: bool }` +- Returns: `201 { attributename: T13 }` #### Reorder attribute names — `PUT /attributename/order` -- Params: `body: { attributenameIds: integer[] }` +- Params: `body: { attributenameIds: int[] }` - Returns: `200` #### List values for an attribute — `GET /attributename/{attributeId}/value` -- Params: `attributeId: integer` -- Returns: `200 { values: { id: integer, attributeId: integer, value: string, sort: integer }[] }` +- Params: `attributeId: int` +- Returns: `200 { values: T27[] }` #### Create value for an attribute — `POST /attributename/{attributeId}/value` -- Params: `attributeId: integer, body: { value: string, sort?: integer }` -- Returns: `201 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }` +- Params: `attributeId: int, body: { value: str, sort?: int }` +- Returns: `201 { attributevalue: T27 }` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order` -- Params: `attributeId: integer, body: { attributevalueIds: integer[] }` +- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Returns: `200` #### Delete attribute name — `DELETE /attributename/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update attribute name — `PATCH /attributename/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }` -- Returns: `200 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }` +- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` +- Returns: `200 { attributename: T13 }` #### Delete attribute value — `DELETE /attributevalue/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update attribute value — `PATCH /attributevalue/{id}` -- Params: `id: integer, body: { value?: string, sort?: integer }` -- Returns: `200 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }` +- Params: `id: int, body: { value?: str, sort?: int }` +- Returns: `200 { attributevalue: T27 }` #### Login with Google ID token — `POST /auth/login` -- Params: `body: { idToken: string }` -- Returns: `200 { message: string, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }` +- Params: `body: { idToken: str }` +- Returns: `200 { message: str, user: { id: int, email: str, selectedCompanyId: int, picture: str } }` #### Logout — `POST /auth/logout` _none_ -- Returns: `200 { message: string }` +- Returns: `200 { message: str }` #### List brands — `GET /brand` _none_ -- Returns: `200 { brands: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }[] }` +- Returns: `200 { brands: { id: int, name: str, gsprpinfo: str, pics: { id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] }[] }` #### Upsert brand — `PUT /brand` -- Params: `body: { name: string, gsprpinfo?: string }` -- Returns: `200 { brand: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] } }` +- Params: `body: { name: str, gsprpinfo?: str }` +- Returns: `200 { brand: { id: int, name: str, gsprpinfo: str, pics: { id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[] } }` #### Delete brand — `DELETE /brand/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Create category — `POST /category` -- Params: `body: { parentId: integer, name: string }` -- Returns: `201 { category: { id: integer, parentId: integer, sort: integer, name: string } }` +- Params: `body: { parentId: int, name: str }` +- Returns: `201 { category: { id: int, parentId: int, sort: int, name: str } }` #### Reorder sibling categories — `PUT /category/order` -- Params: `body: { parentId: integer, categoryIds: integer[] }` -- Returns: `200 { categoryIds: integer[] }` +- Params: `body: { parentId: int, categoryIds: int[] }` +- Returns: `200 { categoryIds: int[] }` #### Category tree — `GET /category/tree` _none_ -- Returns: `200 { languageId: integer, tree: object[], articles: object[] }` +- Returns: `200 { languageId: int, tree: obj[], articles: obj[] }` #### Delete category — `DELETE /category/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update category (rename or move) — `PATCH /category/{id}` -- Params: `id: integer, body: { name?: string, parentId?: integer }` -- Returns: `200 { category: { id: integer, name: string, slug: string, parentId: integer, sort: integer } }` +- Params: `id: int, body: { name?: str, parentId?: int }` +- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` #### Articles in category — `GET /category/{id}/articles` -- Params: `id: integer` -- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, track_stock: boolean, optionsCount: integer, articleListCount: integer, childCount: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string, brand: object }[] }` +- Params: `id: int` +- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num, taxRate: num, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, brand: obj }[] }` #### List category attribute values — `GET /category/{id}/attributevalue` -- Params: `id: integer` -- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `id: int` +- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Bulk set category attribute values — `PUT /category/{id}/attributevalue` -- Params: `id: integer, body: integer[]` -- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }` +- Params: `id: int, body: int[]` +- Returns: `200 { attributevalues: { id: int, categoryId: int, attributeId: int, attributeName: str, exclusive: bool, attributeValueId: int, value: str, sort: int }[] }` #### Delete category image — `DELETE /category/{id}/image` -- Params: `id: integer` -- Returns: `200 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }` +- Params: `id: int` +- Returns: `200 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` #### List category properties — `GET /category/{id}/property` -- Params: `id: integer` -- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `id: int` +- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Bulk set category properties — `PUT /category/{id}/property` -- Params: `id: integer, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }` -- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }` +- Params: `id: int, body: { properties: { propertyId: int, value: str, sort?: int }[] }` +- Returns: `200 { properties: { id: int, categoryId: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int }[] }` #### Delete category property — `DELETE /category/{id}/property/{propertyId}` -- Params: `id: integer, propertyId: integer` +- Params: `id: int, propertyId: int` - Returns: `200` #### List companies — `GET /company` _none_ -- Returns: `200 { companies: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }[] }` +- Returns: `200 { companies: { id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }[] }` #### Get company history — `GET /company/history` -- Params: `query: limit?: integer` -- Returns: `200 { history: { id: integer, companyuserId: integer, date: string, text: string }[], truncated: boolean }` +- Params: `query: limit?: int` +- Returns: `200 { history: { id: int, companyuserId: int, date: str, text: str }[], truncated: bool }` #### Add company history entry — `POST /company/history` -- Params: `body: { text: string }` -- Returns: `201 { history: { id: integer, companyuserId: integer, date: string, text: string } }` +- Params: `body: { text: str }` +- Returns: `201 { history: { id: int, companyuserId: int, date: str, text: str } }` #### List company settings — `GET /company/setting` _none_ -- Returns: `200 { settings: { key: string, value: string }[] }` +- Returns: `200 { settings: { key: str, value: str }[] }` #### Get company setting by key — `GET /company/setting/{key}` -- Params: `key: string` -- Returns: `200 { setting: { key: string, value: string } }` +- Params: `key: str` +- Returns: `200 { setting: { key: str, value: str } }` #### Upsert company setting — `PUT /company/setting/{key}` -- Params: `key: string, body: { value: string, frontend?: boolean }` -- Returns: `200 { setting: { key: string, value: string } }` +- Params: `key: str, body: { value: str, frontend?: bool }` +- Returns: `200 { setting: { key: str, value: str } }` #### Update company — `PUT /company/{id}` -- Params: `id: integer, body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }` -- Returns: `200 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number } }` +- 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 } }` #### List customer groups — `GET /customergroup` _none_ -- Returns: `200 { customergroups: { id: integer, name: string, sort: integer, editable: boolean }[] }` +- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` #### Create customer group — `POST /customergroup` -- Params: `body: { name: string }` -- Returns: `201 { customergroup: { id: integer, name: string, sort: integer } }` +- Params: `body: { name: str }` +- Returns: `201 { customergroup: T33 }` #### Reorder customer groups — `PUT /customergroup/order` -- Params: `body: { customergroupIds: integer[] }` -- Returns: `200 { customergroupIds: integer[] }` +- Params: `body: { customergroupIds: int[] }` +- Returns: `200 { customergroupIds: int[] }` #### Delete customer group — `DELETE /customergroup/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update customer group — `PATCH /customergroup/{id}` -- Params: `id: integer, body: { name: string }` -- Returns: `200 { customergroup: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name: str }` +- Returns: `200 { customergroup: T33 }` #### List deliveries — `GET /delivery` _none_ -- Returns: `200 { deliveries: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer } }[] }` +- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, externalNumber: str, date: str, createdAt: str, ledger: T29 }[] }` #### Create delivery — `POST /delivery` -- Params: `body: { externalNumber: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], supplierId?: integer, files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }` -- Returns: `201 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `body: { externalNumber: str, date: str, lines: T21[], supplierId?: int, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }` +- Returns: `201 T3` #### Delete delivery — `DELETE /delivery/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get delivery — `GET /delivery/{id}` -- Params: `id: integer` -- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `id: int` +- Returns: `200 T3` #### Update delivery — `PATCH /delivery/{id}` -- Params: `id: integer, body: { externalNumber?: string, date?: string, supplierId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }` -- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }` +- Params: `id: int, body: { externalNumber?: str, date?: str, supplierId?: int, lines?: T21[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: obj }[] }` +- Returns: `200 T3` #### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}` -- Params: `id: integer, fileId: integer, query: download?: boolean` -- Returns: `200 string` +- Params: `id: int, fileId: int, query: download?: bool` +- Returns: `200 str` #### List entities — `GET /entity` -- Params: `query: customergroupId?: integer, role?: string` -- Returns: `200 { entities: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string }[] }` +- Params: `query: customergroupId?: int, role?: str` +- Returns: `200 { entities: T19[] }` #### Create entity — `POST /entity` -- Params: `body: { customergroupId: integer, name: string, firstName?: string, number?: string }` -- Returns: `201 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `body: { customergroupId: int, name: str, firstName?: str, number?: str }` +- Returns: `201 T14` #### Peek next customer number — `GET /entity/next-number` _none_ -- Returns: `200 { number: string }` +- Returns: `200 { number: str }` #### List entity addresses — `GET /entity/{entityId}/address` -- Params: `entityId: integer` -- Returns: `200 { addresses: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string }[] }` +- Params: `entityId: int` +- Returns: `200 { addresses: T12[] }` #### Create entity address — `POST /entity/{entityId}/address` -- Params: `entityId: integer, body: { addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }` -- Returns: `201 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }` +- Params: `entityId: int, body: { addressType: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }` +- Returns: `201 { address: T12 }` #### Delete entity address — `DELETE /entity/{entityId}/address/{id}` -- Params: `entityId: integer, id: integer` +- Params: `entityId: int, id: int` - Returns: `204 void` #### Update entity address — `PATCH /entity/{entityId}/address/{id}` -- Params: `entityId: integer, id: integer, body: { addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }` -- Returns: `200 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }` +- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str, name?: str, street?: str, streetNumber?: str, zip?: str, city?: str, county?: str }` +- Returns: `200 { address: T12 }` #### List entity contacts — `GET /entity/{entityId}/contact` -- Params: `entityId: integer` -- Returns: `200 { contacts: { id: integer, entityId: integer, contactType: string, contact: string }[] }` +- Params: `entityId: int` +- Returns: `200 { contacts: T26[] }` #### Create entity contact — `POST /entity/{entityId}/contact` -- Params: `entityId: integer, body: { contactType: string, contact: string }` -- Returns: `201 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }` +- Params: `entityId: int, body: { contactType: str, contact: str }` +- Returns: `201 { contact: T26 }` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` -- Params: `entityId: integer, id: integer` +- Params: `entityId: int, id: int` - Returns: `204 void` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` -- Params: `entityId: integer, id: integer, body: { contactType?: string, contact?: string }` -- Returns: `200 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }` +- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` +- Returns: `200 { contact: T26 }` #### Delete entity — `DELETE /entity/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get entity — `GET /entity/{id}` -- Params: `id: integer` -- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `id: int` +- Returns: `200 T14` #### Update entity — `PATCH /entity/{id}` -- Params: `id: integer, body: { name?: string, firstName?: string, number?: string }` -- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }` +- Params: `id: int, body: { name?: str, firstName?: str, number?: str }` +- Returns: `200 T14` #### List label templates — `GET /labeltemplate` _none_ -- Returns: `200 { labeltemplates: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string }[] }` +- Returns: `200 { labeltemplates: T22[] }` #### Create label template — `POST /labeltemplate` -- Params: `body: { name: string, width: number, height: number, dpi: integer, type: string, content: object }` -- Returns: `201 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: obj }` +- Returns: `201 T16` #### Delete label template — `DELETE /labeltemplate/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get label template by ID — `GET /labeltemplate/{id}` -- Params: `id: integer` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int` +- Returns: `200 T16` #### Update label template (partial) — `PATCH /labeltemplate/{id}` -- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` +- Returns: `200 T16` #### Update label template — `PUT /labeltemplate/{id}` -- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }` -- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }` +- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: obj }` +- Returns: `200 T16` #### Query ledger by manifest article ID or SKU — `GET /ledger` -- Params: `query: manifestarticle_id?: integer, sku?: string, transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` +- Returns: `200 T10` #### Add ledger entry — `POST /ledger` -- Params: `body: { manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string }` -- Returns: `201 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string } }` +- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }` +- Returns: `201 { ledger: T11 }` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}` -- Params: `manifestarticleId: integer, query: transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `manifestarticleId: int, query: transaction_type?: str` +- Returns: `200 T10` #### Query ledger by article SKU — `GET /ledger/sku/{sku}` -- Params: `sku: string, query: transaction_type?: string` -- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `sku: str, query: transaction_type?: str` +- Returns: `200 T10` #### List number sequences — `GET /number` _none_ -- Returns: `200 { numbers: { id: integer, type: string, prefix: string, padding: integer, next: integer }[] }` +- Returns: `200 { numbers: { id: int, type: str, prefix: str, padding: int, next: int }[] }` #### Update number sequence — `PATCH /number/{id}` -- Params: `id: integer, body: { prefix?: string, padding?: integer, next?: integer }` -- Returns: `200 { number: { id: integer, type: string, prefix: string, padding: integer, next: integer } }` +- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` +- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` #### List orders — `GET /order` _none_ -- Returns: `200 { orders: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }` +- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }` #### Create order — `POST /order` -- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }` -- Returns: `201 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }` +- Returns: `201 T5` #### Delete order — `DELETE /order/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get order — `GET /order/{id}` -- Params: `id: integer` -- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int` +- Returns: `200 T5` #### Update order — `PATCH /order/{id}` -- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }` -- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }` +- Returns: `200 T5` #### List printers — `GET /printer` _none_ -- Returns: `200 { printers: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string }[] }` +- Returns: `200 { printers: T20[] }` #### Create printer — `POST /printer` -- Params: `body: { address: string, type: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `201 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `body: { address: str, type: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `201 T15` #### Reorder printers — `PUT /printer/order` -- Params: `body: { printerIds: integer[] }` -- Returns: `200 { printerIds: integer[] }` +- Params: `body: { printerIds: int[] }` +- Returns: `200 { printerIds: int[] }` #### Delete printer — `DELETE /printer/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get printer by ID — `GET /printer/{id}` -- Params: `id: integer` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int` +- Returns: `200 T15` #### Update printer (partial) — `PATCH /printer/{id}` -- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `200 T15` #### Update printer — `PUT /printer/{id}` -- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }` -- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }` +- Params: `id: int, body: { address?: str, type?: str, model?: str, serial?: str, sort?: int, desc?: str }` +- Returns: `200 T15` #### List property names — `GET /propertyname` _none_ -- Returns: `200 { propertynames: { id: integer, name: string, sort: integer, exclusive: boolean }[] }` +- Returns: `200 { propertynames: T30[] }` #### Create property name — `POST /propertyname` -- Params: `body: { name: string, sort?: integer, exclusive?: boolean }` -- Returns: `201 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }` +- Params: `body: { name: str, sort?: int, exclusive?: bool }` +- Returns: `201 { propertyname: T30 }` #### Reorder property names — `PUT /propertyname/order` -- Params: `body: { propertynameIds: integer[] }` +- Params: `body: { propertynameIds: int[] }` - Returns: `200` #### Delete property name — `DELETE /propertyname/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `200` #### Update property name — `PATCH /propertyname/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }` -- Returns: `200 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }` +- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` +- Returns: `200 { propertyname: T30 }` #### List quotes — `GET /quote` _none_ -- Returns: `200 { quotes: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }` +- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int, date: str, createdAt: str }[] }` #### Create quote — `POST /quote` -- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }` -- Returns: `201 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `body: { number: str, date: str, lines: T21[], entityId?: int }` +- Returns: `201 T6` #### Delete quote — `DELETE /quote/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get quote — `GET /quote/{id}` -- Params: `id: integer` -- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int` +- Returns: `200 T6` #### Update quote — `PATCH /quote/{id}` -- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }` -- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }` +- Params: `id: int, body: { number?: str, date?: str, entityId?: int, lines?: T21[] }` +- Returns: `200 T6` #### Adjust stock manually — `POST /stock/adjust` -- Params: `body: { sku?: string, warehouseId?: integer, amount?: number, text?: string, lines?: { sku: string, amount: number, warehouseId?: integer, text?: string }[] }` -- Returns: `201 { manifest: { id: integer, number: string, type: string, date: string, createdAt: string }, lines: object[], ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }` +- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }` +- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: obj[], ledger: T11[] }` #### List suppliers — `GET /supplier` _none_ -- Returns: `200 { suppliers: { id: integer, name: string, createdAt: string, updatedAt: string }[] }` +- Returns: `200 { suppliers: T28[] }` #### Create supplier — `POST /supplier` -- Params: `body: { name: string }` -- Returns: `201 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }` +- Params: `body: { name: str }` +- Returns: `201 { supplier: T28 }` #### Delete supplier — `DELETE /supplier/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Update supplier — `PATCH /supplier/{id}` -- Params: `id: integer, body: { name: string }` -- Returns: `200 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }` +- Params: `id: int, body: { name: str }` +- Returns: `200 { supplier: T28 }` #### List supplier articles by SKUs — `GET /supplier/{id}/articles` -- Params: `id: integer, query: skus?: string` -- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }` +- Params: `id: int, query: skus?: string` +- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T32, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles` -- Params: `id: integer, body?: { skus?: string[] }` -- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }` +- Params: `id: int, body?: { skus?: str[] }` +- Returns: `200 { articles: { id: int, articleId: int, entityId: int, supplierName: str, sku: str, pu: num, stock: num, sort: int, priceId: int, price: T32, article: { id: int, sku: str, name: str, slug: str, photos: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[] } }[] }` #### Delete unit — `DELETE /unit` -- Params: `query: si: string` +- Params: `query: si: str` - Returns: `204 void` #### List units — `GET /unit` _none_ -- Returns: `200 { units: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }[] }` +- Returns: `200 { units: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }[] }` #### Upsert unit — `PUT /unit` -- Params: `body: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }` -- Returns: `200 { unit: { si: string, basesi: string, basesifactor: number, symbol: string, name: string } }` +- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` +- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }` #### Get a virtual filesystem file — `GET /vfs/{*path}` -- Params: `path: string, query: download?: boolean` -- Returns: `200 string` +- Params: `path: str, query: download?: bool` +- Returns: `200 str` #### List warehouses — `GET /warehouse` _none_ -- Returns: `200 { warehouses: { id: integer, name: string, sort: integer }[] }` +- Returns: `200 { warehouses: T33[] }` #### Create warehouse — `POST /warehouse` -- Params: `body: { name: string, sort?: integer }` -- Returns: `201 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `body: { name: str, sort?: int }` +- Returns: `201 T31` #### Reorder warehouses — `PUT /warehouse/order` -- Params: `body: { warehouseIds: integer[] }` -- Returns: `200 { warehouseIds: integer[] }` +- Params: `body: { warehouseIds: int[] }` +- Returns: `200 { warehouseIds: int[] }` #### Delete warehouse — `DELETE /warehouse/{id}` -- Params: `id: integer` +- Params: `id: int` - Returns: `204 void` #### Get warehouse by ID — `GET /warehouse/{id}` -- Params: `id: integer` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int` +- Returns: `200 T31` #### Update warehouse (partial) — `PATCH /warehouse/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer }` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name?: str, sort?: int }` +- Returns: `200 T31` #### Update warehouse — `PUT /warehouse/{id}` -- Params: `id: integer, body: { name?: string, sort?: integer }` -- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }` +- Params: `id: int, body: { name?: str, sort?: int }` +- Returns: `200 T31` ### 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: diff --git a/docs/api/routes/articleIndex.md b/docs/api/routes/articleIndex.md deleted file mode 100644 index 0170932..0000000 --- a/docs/api/routes/articleIndex.md +++ /dev/null @@ -1,60 +0,0 @@ -# `GET /articleIndex` - -Protected. Requires a valid session cookie (`credentials: 'include'`). - -List article search index - -Returns every article reachable via the company's category tree. -Designed for client-side search / autocomplete preload. -Auth via company access. - -REQUIRES `windowSize`. Without it the response is always an empty list — the full -index can be very large and is not useful to a caller that did not ask for a -specific window. Pass the number of entries the client actually intends to preload. - - -## Query parameters - -| Param | Required | Type | Description | -|-------|----------|------|-------------| -| `windowSize` | yes | integer | Number of articles to return (window size). Required; omitting it returns an -empty `articles` array. `truncated` is true when more entries exist than were -returned, signalling the client should request a larger window or narrow its -query. - | - -## Response `200` - -Search index - -```json -{ - "articles": [ - { - "id": 1, - "sku": "…", - "name": "…", - "eans": [ - "…" - ], - "categoryIds": [ - 1 - ] - } - ], - "truncated": true -} -``` - -## Errors - -| Status | Description | -|--------|-------------| -| 400 | No company selected — `{"error":"…"}` | -| 401 | Unauthorized — `{"error":"…"}` | -| 403 | Not authorized — `{"error":"…"}` | -| 404 | Company not found — `{"error":"…"}` | - ---- - -_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 163dad8..bcbd12e 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -518,23 +518,34 @@ function renderReadme(operations) { * @param {boolean} [markOptional] suffix non-required props with `?` (request bodies) * @returns {string} */ -function summarizeSchema(schema, components = spec.components, depth = 0, markOptional = false) { +function summarizeSchema( + schema, + components = spec.components, + depth = 0, + markOptional = false, + aliases = null, + skipRootAlias = false, +) { if (!schema) return ''; - if (depth > 6) return 'object'; + if (depth > 6) return aliases?.compactTypes ? 'obj' : 'object'; const resolved = resolveSchema(schema, components); if (!resolved) return schemaRefName(schema) || ''; if (resolved.$ref) return schemaRefName(resolved) || ''; if (resolved.type === 'array') { - const item = summarizeSchema(resolved.items, components, depth + 1, markOptional) || 'any'; + const item = summarizeSchema(resolved.items, components, depth + 1, markOptional, aliases) || 'any'; return `${item}[]`; } if (resolved.type === 'object' || resolved.properties) { + const shape = aliases && summarizeSchema(resolved, components, 0, markOptional); + const alias = shape && aliases.get(`${markOptional ? 1 : 0}\0${shape}`); + if (alias && !skipRootAlias) return alias; + const required = new Set(resolved.required || []); let entries = Object.entries(resolved.properties || {}); - if (entries.length === 0) return 'object'; + if (entries.length === 0) return aliases?.compactTypes ? 'obj' : 'object'; // Required props first, then optional ones (stable within each group). if (markOptional) { @@ -546,27 +557,91 @@ function summarizeSchema(schema, components = spec.components, depth = 0, markOp const parts = entries.map(([name, prop]) => { const opt = markOptional && !required.has(name) ? '?' : ''; - const type = summarizeSchema(prop, components, depth + 1, markOptional) || 'any'; + const type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any'; return `${name}${opt}: ${type}`; }); return `{ ${parts.join(', ')} }`; } - if (resolved.type) return resolved.type; + if (resolved.type) { + if (aliases?.compactTypes) { + return { + string: 'str', + integer: 'int', + number: 'num', + boolean: 'bool', + object: 'obj', + }[resolved.type] || resolved.type; + } + return resolved.type; + } return schemaRefName(schema) || ''; } +function collectSchemaAliases(operations) { + const shapes = new Map(); + const visit = (schema, markOptional) => { + const resolved = resolveSchema(schema, spec.components); + if (!resolved) return; + + if (resolved.type === 'array') { + visit(resolved.items, markOptional); + return; + } + if (resolved.type !== 'object' && !resolved.properties) return; + + const shape = summarizeSchema(resolved, spec.components, 0, markOptional); + const key = `${markOptional ? 1 : 0}\0${shape}`; + const current = shapes.get(key); + if (current) { + current.count += 1; + } else { + shapes.set(key, { count: 1, shape, schema: resolved, markOptional }); + } + for (const prop of Object.values(resolved.properties || {})) { + visit(prop, markOptional); + } + }; + + for (const { operation } of operations) { + for (const param of operation.parameters || []) visit(param.schema, false); + const content = operation.requestBody?.content || {}; + visit(content['application/json']?.schema, true); + visit(content['multipart/form-data']?.schema, true); + for (const [code, response] of Object.entries(operation.responses || {})) { + if (code.startsWith('2')) { + for (const media of Object.values(response.content || {})) { + visit(media.schema, false); + } + } + } + } + + const candidates = [...shapes.entries()] + .filter(([, item]) => item.count >= 3 && item.shape.length >= 36) + .sort((a, b) => b[1].shape.length - a[1].shape.length || a[0].localeCompare(b[0])); + const aliases = new Map(); + const definitions = []; + for (const [key, item] of candidates) { + const alias = `T${definitions.length + 1}`; + aliases.set(key, alias); + definitions.push({ alias, ...item }); + } + aliases.compactTypes = true; + return { aliases, definitions }; +} + /** * Always-visible type for a path/query parameter, so declarations are uniform. * Falls back to `string` only when the parameter schema omits a type entirely. * @param {object} param * @returns {string} */ -function paramType(param) { - return summarizeSchema(param?.schema, spec.components) || 'string'; +function paramType(param, aliases = null) { + return summarizeSchema(param?.schema, spec.components, 0, false, aliases) || 'string'; } -function summarizeOperationParams(operation) { +function summarizeOperationParams(operation, aliases = null) { const parts = []; const params = operation.parameters || []; const pathParams = []; @@ -577,9 +652,9 @@ function summarizeOperationParams(operation) { const isOpt = !p.required; const optSuffix = isOpt ? '?' : ''; if (p.in === 'path') { - pathParams.push(`${p.name}: ${paramType(p)}`); + pathParams.push(`${p.name}: ${paramType(p, aliases)}`); } else if (p.in === 'query') { - queryParams.push(`${p.name}${optSuffix}: ${paramType(p)}`); + queryParams.push(`${p.name}${optSuffix}: ${paramType(p, aliases)}`); } } @@ -596,11 +671,11 @@ function summarizeOperationParams(operation) { const content = operation.requestBody.content; if (content['application/json']?.schema) { const s = content['application/json'].schema; - const sub = summarizeSchema(s, spec.components, 0, true); + const sub = summarizeSchema(s, spec.components, 0, true, aliases); parts.push(`${bodyPrefix}: ${sub || 'json'}`); } else if (content['multipart/form-data']) { const formSchema = content['multipart/form-data'].schema; - const formSub = summarizeSchema(formSchema, spec.components, 0, true); + const formSub = summarizeSchema(formSchema, spec.components, 0, true, aliases); parts.push(`form: ${formSub || 'multipart'}`); } else { const ct = Object.keys(content)[0]; @@ -611,7 +686,7 @@ function summarizeOperationParams(operation) { return parts.length ? parts.join(', ') : '—'; } -function summarizeOperationReturns(operation) { +function summarizeOperationReturns(operation, aliases = null) { const responses = operation.responses || {}; const successCodes = Object.keys(responses) .filter((c) => c.startsWith('2')) @@ -643,7 +718,7 @@ function summarizeOperationReturns(operation) { continue; } const s = resp.content[ct]?.schema; - const sub = summarizeSchema(s); + const sub = summarizeSchema(s, spec.components, 0, false, aliases); if (sub) { parts.push(`${code} ${sub}`); } else { @@ -654,30 +729,37 @@ function summarizeOperationReturns(operation) { } function renderRoutesOverview(operations, title = 'Dash API Routes Overview') { + const { aliases, definitions } = collectSchemaAliases(operations); const lines = []; lines.push(`# ${title}`); lines.push(''); lines.push(`Overview of all ${operations.length} API endpoints across all modules.`); lines.push(''); - lines.push('### Parameter Notation'); + lines.push('### Notation'); lines.push(''); - lines.push('- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.'); - lines.push('- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).'); - lines.push('- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).'); - lines.push('- **Path parameters** match `{param}` in the route URL.'); - lines.push('- **Query parameters** are prefixed with `query: ...`.'); - lines.push('- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).'); - lines.push('- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.'); + lines.push('Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `T[]` array; `?` optional.'); + lines.push('Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below.'); + if (definitions.length) { + lines.push(''); + lines.push('### Reusable types'); + lines.push(''); + for (const def of definitions) { + const shape = summarizeSchema(def.schema, spec.components, 0, def.markOptional, aliases, true); + lines.push(`- \`${def.alias}\` = \`${shape}\``); + } + } lines.push(''); lines.push('### Routes'); lines.push(''); for (const op of operations) { + const params = summarizeOperationParams(op.operation, aliases); + const returns = summarizeOperationReturns(op.operation, aliases); const method = op.method.toUpperCase(); const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim(); const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``; lines.push(`#### ${heading}`); - lines.push(op.params === '—' ? '_none_' : `- Params: \`${op.params}\``); - lines.push(op.returns === '—' ? '_no response body_' : `- Returns: \`${op.returns}\``); + lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``); + lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``); } lines.push(''); return lines.join('\n'); @@ -756,8 +838,7 @@ for (const apiPath of paths) { file: relFile.replace(/\\/g, '/'), summary: operation.summary || '', tags: operation.tags || [], - params: summarizeOperationParams(operation), - returns: summarizeOperationReturns(operation), + operation, omittedFromOverview: isRouteOmitted(method, apiPath), }); } diff --git a/src/api/index.js b/src/api/index.js index f9514db..5b108e5 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -12,6 +12,7 @@ import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; import { closeAll as closeAllSse } from './sse.js'; import { closeAllManagers } from '../services/conversationManager.js'; +import { shutdownImageCropWorkers } from '../services/imageCrop.js'; import { getOrCreateCert } from '../util/cert.js'; import v1Router from '../v1/index.js'; @@ -192,6 +193,9 @@ export async function stopApi() { // forever — node --watch hangs on "Waiting for graceful termination...". closeAllSse(); closeAllManagers(); + // Pooled image-crop workers are live threads and must be terminated too, or the + // process stays alive after the last request has been served. + await shutdownImageCropWorkers(); const closers = []; if (server) { const s = server; diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 91120ae..5841921 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -1544,124 +1544,6 @@ export function register(app, basePath = '') { } }); - /** - * @openapi - * /articleIndex: - * get: - * tags: [Article] - * summary: List article search index - * description: | - * Returns every article reachable via the company's category tree. - * Designed for client-side search / autocomplete preload. - * Auth via company access. - * - * REQUIRES `windowSize`. Without it the response is always an empty list — the full - * index can be very large and is not useful to a caller that did not ask for a - * specific window. Pass the number of entries the client actually intends to preload. - * security: - * - cookieAuth: [] - * parameters: - * - in: query - * name: windowSize - * required: true - * schema: - * type: integer - * minimum: 1 - * maximum: 1000 - * description: | - * Number of articles to return (window size). Required; omitting it returns an - * empty `articles` array. `truncated` is true when more entries exist than were - * returned, signalling the client should request a larger window or narrow its - * query. - * responses: - * 200: - * description: Search index - * content: - * application/json: - * schema: - * type: object - * properties: - * articles: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * sku: - * type: string - * name: - * type: string - * nullable: true - * eans: - * type: array - * items: - * type: string - * categoryIds: - * type: array - * items: - * type: integer - * truncated: - * type: boolean - * description: True when the window was smaller than the total number of matching articles. - * 400: - * description: No company selected - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 401: - * description: Unauthorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 403: - * description: Not authorized - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 404: - * description: Company not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - app.get(`${basePath}/articleIndex`, async (req, res) => { - const companyId = req.user.selectedCompanyId; - if (companyId == null) { - return res.status(400).json({ error: 'No company selected' }); - } - - // A window size must be given explicitly. Without one the index can be arbitrarily large, - // and returning the entire result set to a caller that never asked for a window is - // exactly the "fetch everything" behaviour this guard exists to prevent. - const rawWindowSize = req.query.windowSize; - if (rawWindowSize === undefined || rawWindowSize === '') { - return res.status(200).json({ articles: [], truncated: false }); - } - - const windowSize = parseInt(rawWindowSize, 10); - if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1000) { - return res.status(400).json({ error: 'windowSize must be an integer between 1 and 1000' }); - } - - try { - const all = await db.listSearchIndex(req.user.id); - // `length === windowSize` is the same truncation signal used by - // GET /article/{sku}/history: the window is exactly full, so more may exist. - const truncated = all.length > windowSize; - const articles = truncated ? all.slice(0, windowSize) : all; - res.status(200).json({ articles, truncated }); - } catch (error) { - const status = articleErrorStatus(error); - if (status === 500) console.error('List article search index failed:', error); - res.status(status).json({ error: error.message }); - } - }); - /** * @openapi * /article/{sku}/print: @@ -2448,4 +2330,3 @@ export function register(app, basePath = '') { }); } - diff --git a/src/db/article.js b/src/db/article.js index aff8237..d63c755 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -70,11 +70,6 @@ export async function setCategoryIdsBySku(googleAuthId, sku, categoryIds) { return store.article.setCategoryIdsBySku(googleAuthId, sku, categoryIds); } -export async function listSearchIndex(googleAuthId) { - const store = await getStore(); - return store.article.listSearchIndex(googleAuthId); -} - export async function listExport(googleAuthId, categoryId) { const store = await getStore(); return store.article.listExport(googleAuthId, categoryId); @@ -127,4 +122,3 @@ export async function setOptionslist(googleAuthId, sku, options) { return store.article.setOptionslist(googleAuthId, sku, options); } - diff --git a/src/services/imageCrop.js b/src/services/imageCrop.js index 5a5afc8..a30cfb9 100644 --- a/src/services/imageCrop.js +++ b/src/services/imageCrop.js @@ -1,12 +1,302 @@ -import sharp from 'sharp'; +import { Worker } from 'worker_threads'; +import { fileURLToPath } from 'url'; const FETCH_TIMEOUT_MS = 15000; const MAX_BYTES = 50 * 1024 * 1024; // 50MB -const MAX_EDGE = 1000; -const TRIM_THRESHOLD = 10; -const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed) -const AVIF_QUALITY = 80; -const AVIF_EFFORT = 4; + +// The crop/trim/resize/AVIF pipeline runs up to six sequential sharp passes (PNG +// intermediate, trim, extract, resize, AVIF encode). A sharp pipeline is owned by +// whichever thread created it, so running it on the main thread stalls every HTTP +// handler and every SSE client (heartbeats included). The pipeline therefore lives +// in imageCropWorker.js and is reached through the small pool below. +const WORKER_PATH = fileURLToPath(new URL('./imageCropWorker.js', import.meta.url)); +const WORKER_BOOT_TIMEOUT_MS = 15000; +const DEFAULT_JOB_TIMEOUT_MS = 30000; +// libvips keeps its own warm thread pool per process, so one worker per CPU is +// plenty; sharp's internal concurrency is what actually saturates a worker. +const POOL_SIZE = Math.max(1, Math.min(4, (process.availableParallelism?.() ?? 2) - 1)); + +/** @type {Array<{ worker: Worker, busy: boolean, id: number }>} */ +const pool = []; +/** @type {Array<{ resolve: Function, reject: Function, timer: NodeJS.Timeout, inputBuffer: Buffer, timeoutMs: number }>} */ +const queue = []; +let shuttingDown = false; +let workerSeq = 0; +/** Serializes pool growth so concurrent callers cannot overshoot POOL_SIZE. */ +let growing = Promise.resolve(); + +/** + * Copies a Buffer into a standalone, transferable ArrayBuffer. + * + * `Buffer.from(buffer)` and `Uint8Array.prototype.subarray` can alias the shared + * 8 KB Buffer pool, and transferring pooled memory would detach it for every other + * Buffer in the process (including in-flight HTTP bodies). A fresh `Uint8Array` is + * always backed by its own ArrayBuffer, so it is always safe to transfer. + * + * @param {Buffer} buffer + * @returns {ArrayBuffer} + */ +function toTransferable(buffer) { + return Uint8Array.from(buffer).buffer; +} + +/** + * Spawns a worker and resolves once it has booted far enough to accept a job. + * @returns {Promise<{ worker: Worker, busy: boolean, id: number }>} + */ +function spawnWorker() { + const entry = { worker: null, busy: false, id: ++workerSeq }; + + return new Promise((resolve, reject) => { + const bootTimer = setTimeout(() => { + cleanup(); + try { entry.worker?.terminate(); } catch { /* already closed */ } + reject(new Error(`Image crop worker failed to start within ${WORKER_BOOT_TIMEOUT_MS} ms`)); + }, WORKER_BOOT_TIMEOUT_MS); + + function cleanup() { + clearTimeout(bootTimer); + entry.worker?.off('message', onBooted); + entry.worker?.off('error', onError); + entry.worker?.off('exit', onExit); + } + + function onBooted(msg) { + // The worker sends an explicit `{ ready: true }` once its imports (notably + // sharp/libvips) have resolved. Without this handshake there is no first + // message to await, so a worker that fails to boot would only surface later + // as a confusing per-job failure. + if (!msg || msg.ready !== true) return; + cleanup(); + resolve(entry); + } + + function onError(err) { + cleanup(); + reject(err instanceof Error ? err : new Error(err?.message || 'Image crop worker failed to start')); + } + + function onExit(code) { + cleanup(); + reject(new Error(`Image crop worker exited before becoming ready (code ${code})`)); + } + + try { + entry.worker = new Worker(WORKER_PATH); + } catch (err) { + cleanup(); + reject(err); + return; + } + + entry.worker.on('message', onBooted); + entry.worker.on('error', onError); + entry.worker.on('exit', onExit); + }); +} + +/** + * Returns an idle worker, spawning one when the pool is below capacity. + * + * When spawning fails the oldest queued job is failed with that error rather than + * being left waiting on a worker that will never arrive — otherwise a boot failure + * would deadlock the queue instead of surfacing to the HTTP route. + * + * @returns {Promise<{ worker: Worker, busy: boolean, id: number }>} + */ +async function acquireWorker() { + const idle = pool.find((entry) => !entry.busy); + if (idle) return idle; + + if (pool.length < POOL_SIZE) { + // Serialize pool growth. Without this, N concurrent callers all observe + // `pool.length < POOL_SIZE` and each spawn a worker, overshooting the cap. + growing = growing.then(async () => { + if (pool.length >= POOL_SIZE) return; + try { + pool.push(await spawnWorker()); + } catch (err) { + const waiting = queue[0]; + if (!waiting) throw err; + // Fail the oldest queued job rather than leaving it waiting on a worker + // that will never arrive — otherwise a boot failure deadlocks the queue + // instead of surfacing to the HTTP route. + queue.shift(); + clearTimeout(waiting.timer); + waiting.reject(err); + } + }); + try { + await growing; + } catch { + // Already reported to the queued job; continue so this caller still queues. + } + // Re-check: an idle worker may now exist, or the pool may still be saturated. + if (pool.some((entry) => !entry.busy)) return; + } + + // Every worker is busy and the pool is at capacity. enqueueJob/drainQueue hand the + // job the next free worker; acquiring one here is only needed to grow the pool. + return null; +} + +/** + * Runs one job on a worker and resolves with its reply. + * @param {{ worker: Worker, busy: boolean, id: number }} entry + * @param {Buffer} inputBuffer + * @param {number} timeoutMs + */ +function runJob(entry, inputBuffer, timeoutMs) { + return new Promise((resolve, reject) => { + const jobId = `${entry.id}:${process.hrtime.bigint()}`; + const transfer = toTransferable(inputBuffer); + let settled = false; + + const timer = setTimeout(() => { + // Terminate first, then report. Leaving a worker blocked in libvips would let + // it keep burning a core long after the HTTP request has been answered, and + // its eventual reply would then arrive against an already-settled job. + try { entry.worker.terminate(); } catch { /* already closed */ } + finish(new Error(`Image crop worker exceeded the ${timeoutMs} ms timeout and was terminated`)); + }, timeoutMs); + + function cleanup() { + clearTimeout(timer); + entry.worker.off('message', onMessage); + entry.worker.off('error', onError); + entry.worker.off('exit', onExit); + } + + function finish(err, value) { + if (settled) return; + settled = true; + cleanup(); + if (err) reject(err); + else resolve(value); + } + + function onMessage(msg) { + // Ignore anything that is not this job's reply. Under `node --watch` the dev + // server's module-loader hook posts `watch:import` / `watch:require` notices on + // the worker's parentPort for every import, so the first message is not + // necessarily our reply. Job ids are matched instead of taking the first one. + if (!msg || msg.id !== jobId) return; + if (msg.ok) { + finish(null, { + buffer: Buffer.from(msg.buffer), + width: msg.width, + height: msg.height, + size: msg.size, + }); + } else { + const err = new Error(msg.error || 'Image crop failed in worker'); + if (msg.errorName) err.name = msg.errorName; + if (msg.stack) err.workerStack = msg.stack; + finish(err); + } + } + + function onError(err) { + dropWorker(entry); + finish(err instanceof Error ? err : new Error(err?.message || 'Image crop worker error')); + } + + function onExit(code) { + dropWorker(entry); + finish(new Error(`Image crop worker exited unexpectedly (code ${code})`)); + } + + entry.worker.on('message', onMessage); + entry.worker.on('error', onError); + entry.worker.on('exit', onExit); + + try { + entry.worker.postMessage({ id: jobId, buffer: transfer }, [transfer]); + } catch (err) { + finish(err instanceof Error ? err : new Error(err?.message || 'Failed to post image to crop worker')); + } + }); +} + +/** + * Removes a dead worker from the pool. Jobs it was running have already been failed + * by its error/exit listener; queued jobs are re-homed by drainQueue(). + * @param {{ worker: Worker }} entry + */ +function dropWorker(entry) { + const index = pool.indexOf(entry); + if (index !== -1) pool.splice(index, 1); +} + +/** + * Starts as many queued jobs as there are idle workers. + */ +function drainQueue() { + while (queue.length > 0) { + const idle = pool.find((entry) => !entry.busy); + if (!idle) return; + + const next = queue.shift(); + clearTimeout(next.timer); + idle.busy = true; + + runJob(idle, next.inputBuffer, next.timeoutMs).then(next.resolve, next.reject).finally(() => { + idle.busy = false; + drainQueue(); + }); + } +} + +/** + * Queues a crop job and resolves with the worker's result. + * + * `drainQueue` is the single owner of dispatch: it hands each queued job an idle + * worker and runs it, so a job can never be dispatched twice nor left waiting on a + * worker that acquireWorker never obtained. + * + * @param {Buffer} inputBuffer + * @param {number} timeoutMs + * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} + */ +function enqueueJob(inputBuffer, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const index = queue.findIndex((item) => item.timer === timer); + if (index !== -1) queue.splice(index, 1); + reject(new Error(`Image crop worker queue timed out after ${timeoutMs} ms`)); + }, timeoutMs); + + queue.push({ resolve, reject, timer, inputBuffer, timeoutMs }); + drainQueue(); + }); +} + +/** + * Terminates every pooled worker and fails anything still queued. + * + * Call from shutdown so `node --watch` does not hang on "Waiting for graceful + * termination..." the way long-lived SSE sockets do. + * + * @returns {Promise} + */ +export async function shutdownImageCropWorkers() { + shuttingDown = true; + + while (queue.length > 0) { + const pending = queue.shift(); + clearTimeout(pending.timer); + pending.reject(new Error('Image crop worker pool is shutting down')); + } + + const entries = pool.splice(0, pool.length); + await Promise.all( + entries.map((entry) => + Promise.resolve() + .then(() => entry.worker.terminate()) + .catch(() => { /* already closed */ }) + ) + ); +} /** * Validates and normalizes an image URL. @@ -85,90 +375,29 @@ export async function crawlImage(url, { signal } = {}) { * actually trimmed, never enlarging beyond the original), resizes to max 1000px, * and converts to AVIF. * + * Runs the sharp pipeline in a pooled worker thread (see imageCropWorker.js) so the + * multi-pass encode never blocks the main event loop. Errors are re-thrown here with + * the worker's message intact, so callers that classify on message text (e.g. the + * `Failed to crawl image:` prefix check in /serper/image) keep working unchanged. + * * @param {Buffer} inputBuffer - Raw image buffer + * @param {object} [options] + * @param {number} [options.timeout] - Hard wall-clock limit for the worker job * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} */ -export async function cropAndConvertToAvif(inputBuffer) { - // 1. Initial metadata and orientation correction - const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate(); - const initialMeta = await baseSharp.metadata(); - const hasAlpha = Boolean(initialMeta.hasAlpha); - - // Use PNG intermediate for trimming passes to preserve alpha - const originalBuffer = await baseSharp.png().toBuffer(); - const origMeta = await sharp(originalBuffer).metadata(); - - // Cumulative crop box in original (oriented) pixel coordinates - let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height }; - let currentBuffer = originalBuffer; - - const trimPass = async (background) => { - try { - const trimmed = await sharp(currentBuffer) - .trim({ background, threshold: TRIM_THRESHOLD }) - .png() - .toBuffer({ resolveWithObject: true }); - if (trimmed.info.width > 0 && trimmed.info.height > 0) { - currentBuffer = trimmed.data; - box = { - left: box.left - (trimmed.info.trimOffsetLeft ?? 0), - top: box.top - (trimmed.info.trimOffsetTop ?? 0), - width: trimmed.info.width, - height: trimmed.info.height, - }; - } - } catch { - // Keep currentBuffer - } - }; - - // 2. Step A: Trim transparent borders if alpha is present - if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); - - // 3. Step B: Trim white borders - await trimPass('#ffffff'); - - // 4. Step C: Trim transparent borders again (handles white border containing transparent padding) - if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); - - // 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds - const left = Math.max(0, box.left - RESTORE_MARGIN); - const top = Math.max(0, box.top - RESTORE_MARGIN); - const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN); - const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN); - if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) { - try { - const restored = await sharp(originalBuffer) - .extract({ left, top, width: right - left, height: bottom - top }) - .png() - .toBuffer({ resolveWithObject: true }); - currentBuffer = restored.data; - } catch { - // Keep currentBuffer - } +export async function cropAndConvertToAvif(inputBuffer, { timeout = DEFAULT_JOB_TIMEOUT_MS } = {}) { + if (!Buffer.isBuffer(inputBuffer)) { + throw new TypeError('cropAndConvertToAvif expects a Buffer'); } - const currentMeta = await sharp(currentBuffer).metadata(); - - // 5. Resize to max 1000px without enlargement - let finalPipeline = sharp(currentBuffer); - if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) { - finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, { - fit: 'inside', - withoutEnlargement: true, - }); + if (shuttingDown) { + throw new Error('Image crop worker pool is shutting down'); } - // 6. Convert to AVIF - const avifResult = await finalPipeline - .avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT }) - .toBuffer({ resolveWithObject: true }); - - return { - buffer: avifResult.data, - width: avifResult.info.width, - height: avifResult.info.height, - size: avifResult.info.size, - }; + // Grow the pool on demand, then queue. enqueueJob/drainQueue hand the job to the + // first idle worker, so an idle worker is never leased twice and a saturated pool + // simply waits instead of piling up sharp pipelines. + await acquireWorker(); + return enqueueJob(inputBuffer, timeout); } /** @@ -177,9 +406,10 @@ export async function cropAndConvertToAvif(inputBuffer) { * @param {string} url - Image URL to crawl * @param {object} [options] * @param {AbortSignal} [options.signal] - Abort signal + * @param {number} [options.timeout] - Hard wall-clock limit for the crop worker * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} */ -export async function crawlAndProcessImage(url, { signal } = {}) { +export async function crawlAndProcessImage(url, { signal, timeout } = {}) { const rawBuffer = await crawlImage(url, { signal }); - return cropAndConvertToAvif(rawBuffer); + return cropAndConvertToAvif(rawBuffer, { timeout }); } diff --git a/src/services/imageCropWorker.js b/src/services/imageCropWorker.js new file mode 100644 index 0000000..7d19897 --- /dev/null +++ b/src/services/imageCropWorker.js @@ -0,0 +1,163 @@ +'use strict'; + +/** + * Worker-thread entry point for the sharp crop/trim/resize/AVIF pipeline. + * + * This pipeline runs up to six sequential sharp passes (PNG intermediate, trim, + * extract, resize, AVIF encode) whose JS glue cost lands on the thread that owns + * the pipeline. Running it here keeps that cost off the main event loop, which + * otherwise stalls HTTP handlers and every SSE client (heartbeats included). + * + * Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and + * the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer + * transferred back) or `{ id, ok: false, error, errorName }`. + */ + +import { parentPort } from 'worker_threads'; +import sharp from 'sharp'; + +const MAX_EDGE = 1000; +const TRIM_THRESHOLD = 10; +const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed) +const AVIF_QUALITY = 80; +const AVIF_EFFORT = 4; + +/** + * Copies a Buffer into a standalone, transferable ArrayBuffer. + * + * `Buffer.from(buffer)` / `Uint8Array.prototype.subarray` can alias the shared + * 8 KB Buffer pool, and transferring pooled memory would corrupt every other + * Buffer in the process. A fresh `Uint8Array` is always backed by its own + * ArrayBuffer, so it is always safe to hand to `postMessage(..., [transfer])`. + * + * @param {Buffer} buffer + * @returns {ArrayBuffer} + */ +function toTransferable(buffer) { + return Uint8Array.from(buffer).buffer; +} + +/** + * Trims transparent and white borders from an image buffer, restores up to + * RESTORE_MARGIN px of the original image on each edge (never more than was + * actually trimmed, never enlarging beyond the original), resizes to max 1000px, + * and converts to AVIF. + * + * @param {Buffer} inputBuffer - Raw image buffer + * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} + */ +async function cropAndConvertToAvif(inputBuffer) { + // 1. Initial metadata and orientation correction + const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate(); + const initialMeta = await baseSharp.metadata(); + const hasAlpha = Boolean(initialMeta.hasAlpha); + + // Use PNG intermediate for trimming passes to preserve alpha + const originalBuffer = await baseSharp.png().toBuffer(); + const origMeta = await sharp(originalBuffer).metadata(); + + // Cumulative crop box in original (oriented) pixel coordinates + let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height }; + let currentBuffer = originalBuffer; + + const trimPass = async (background) => { + try { + const trimmed = await sharp(currentBuffer) + .trim({ background, threshold: TRIM_THRESHOLD }) + .png() + .toBuffer({ resolveWithObject: true }); + if (trimmed.info.width > 0 && trimmed.info.height > 0) { + currentBuffer = trimmed.data; + box = { + left: box.left - (trimmed.info.trimOffsetLeft ?? 0), + top: box.top - (trimmed.info.trimOffsetTop ?? 0), + width: trimmed.info.width, + height: trimmed.info.height, + }; + } + } catch { + // Keep currentBuffer + } + }; + + // 2. Step A: Trim transparent borders if alpha is present + if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); + + // 3. Step B: Trim white borders + await trimPass('#ffffff'); + + // 4. Step C: Trim transparent borders again (handles white border containing transparent padding) + if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); + + // 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds + const left = Math.max(0, box.left - RESTORE_MARGIN); + const top = Math.max(0, box.top - RESTORE_MARGIN); + const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN); + const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN); + if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) { + try { + const restored = await sharp(originalBuffer) + .extract({ left, top, width: right - left, height: bottom - top }) + .png() + .toBuffer({ resolveWithObject: true }); + currentBuffer = restored.data; + } catch { + // Keep currentBuffer + } + } + const currentMeta = await sharp(currentBuffer).metadata(); + + // 5. Resize to max 1000px without enlargement + let finalPipeline = sharp(currentBuffer); + if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) { + finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, { + fit: 'inside', + withoutEnlargement: true, + }); + } + + // 6. Convert to AVIF + const avifResult = await finalPipeline + .avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT }) + .toBuffer({ resolveWithObject: true }); + + return { + buffer: avifResult.data, + width: avifResult.info.width, + height: avifResult.info.height, + size: avifResult.info.size, + }; +} + +// Boot handshake. The parent awaits this before queuing the first job, so a worker +// whose imports (sharp/libvips) fail surfaces as a start-up error rather than as an +// inexplicable per-job failure. +parentPort.postMessage({ ready: true }); + +parentPort.on('message', async (msg) => { + const { id, buffer } = msg ?? {}; + + try { + const result = await cropAndConvertToAvif(Buffer.from(buffer)); + const out = toTransferable(result.buffer); + parentPort.postMessage( + { + id, + ok: true, + buffer: out, + width: result.width, + height: result.height, + size: result.size, + }, + [out] + ); + } catch (err) { + parentPort.postMessage({ + id, + ok: false, + error: err?.message || String(err), + errorName: err?.name || 'Error', + stack: err?.stack, + }); + } +});