From 8a7500cb5ab1e6708248525f49bb1cc4f07abd1f Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 1 Oct 2026 10:19:16 +0200 Subject: [PATCH] u --- docs/api/routes.md | 4 +- .../routes/article/_sku/attributevalue.put.md | 2 +- docs/api/routes/article/_sku/children.post.md | 9 +++-- docs/api/routes/article/history.md | 6 +++ src/api/routes/article.js | 39 +++++++++++++++---- src/api/routes/articleattributevalue.js | 9 ++++- src/api/routes/delivery.js | 1 + src/api/routes/order.js | 1 + src/api/routes/quote.js | 1 + src/api/routes/stock.js | 2 +- src/db/article.js | 4 +- 11 files changed, 59 insertions(+), 19 deletions(-) diff --git a/docs/api/routes.md b/docs/api/routes.md index 968441b..ca4f23d 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -23,11 +23,11 @@ Overview of all 136 API endpoints across all modules. | `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set or clear article brand by SKU | | `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article categories by SKU | | `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article | -| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article | +| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article | | `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article | | `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article EANs by SKU | | `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article | -| `GET` | `/article/{sku}/history` | `sku:string` | `200 { history: { id, date, text }[] }` | Get article history by SKU | +| `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU | | `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image | | `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images | | `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article | diff --git a/docs/api/routes/article/_sku/attributevalue.put.md b/docs/api/routes/article/_sku/attributevalue.put.md index 1dde00d..a77a8e0 100644 --- a/docs/api/routes/article/_sku/attributevalue.put.md +++ b/docs/api/routes/article/_sku/attributevalue.put.md @@ -16,7 +16,7 @@ Replaces all attribute values for an article. Enforces exclusive constraint (max | Field | Required | Type | Description | |-------|----------|------|-------------| -| `attributevalues` | yes | any[] | | +| `attributevalues` | yes | any[] | Array of attribute value IDs (numbers) or objects with id or attributeValueId | ```json { diff --git a/docs/api/routes/article/_sku/children.post.md b/docs/api/routes/article/_sku/children.post.md index e884bb9..d060018 100644 --- a/docs/api/routes/article/_sku/children.post.md +++ b/docs/api/routes/article/_sku/children.post.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Add a child article to a father article -Automatically generates SKU as fatherSku-attributeValueId and assigns the variant attribute. +Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute. ## Path parameters @@ -19,12 +19,14 @@ Automatically generates SKU as fatherSku-attributeValueId and assigns the varian | `attributeValueId` | no | integer | ID of existing attribute value for the father's attribute | | `value` | no | string | String value to find or create under the father's attribute | | `name` | no | string | Optional custom variant name | +| `sku` | no | string | Optional custom SKU for the child article | ```json { "attributeValueId": 1, "value": "…", - "name": "…" + "name": "…", + "sku": "…" } ``` @@ -48,8 +50,7 @@ Created child article | Status | Description | |--------|-------------| -| 400 | Father not configured as father or missing value | -| 409 | Child SKU already exists | +| 400 | Father not configured as father, missing value, duplicate variant, or child SKU already exists | --- diff --git a/docs/api/routes/article/history.md b/docs/api/routes/article/history.md index cb421f2..b0125aa 100644 --- a/docs/api/routes/article/history.md +++ b/docs/api/routes/article/history.md @@ -14,6 +14,12 @@ Auth like GET by SKU. |-------|----------|------|-------------| | `sku` | yes | string | Article SKU | +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `limit` | no | integer | Maximum number of entries to return (newest first). Omit for all entries. | + ## Response `200` Article history (newest first) diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 176fd80..62e348a 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -88,6 +88,9 @@ function articleErrorStatus(error) { if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400; if (error.message === 'Attribute value not found or does not match father attribute') return 400; if (error.message === 'Child article does not belong to this father') return 400; + if (error.message === 'Variant with this attribute value already exists') return 400; + if (error.message?.startsWith('Child article') && error.message?.includes('already exists')) return 400; + if (error.message?.toLowerCase().includes('duplicate variant')) return 400; if (error.message === 'Father article not found') return 404; if ( error.message?.includes('Stückliste') || @@ -553,6 +556,14 @@ export function register(app, basePath = '') { * schema: * type: string * description: Article SKU + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 1000 + * description: Maximum number of entries to return (newest first). Omit for all entries. * responses: * 200: * description: Article history (newest first) @@ -560,12 +571,15 @@ export function register(app, basePath = '') { * application/json: * schema: * type: object - * required: [history] + * required: [history, truncated] * properties: * history: * type: array * items: * $ref: '#/components/schemas/ArticleHistoryEntry' + * truncated: + * type: boolean + * description: True when a limit was applied and the result set was exactly that size (more entries may exist). * example: * history: * - id: 42 @@ -605,9 +619,19 @@ export function register(app, basePath = '') { return res.status(400).json({ error: 'sku is required' }); } + const rawLimit = req.query.limit; + let limit = null; + if (rawLimit !== undefined) { + limit = parseInt(rawLimit, 10); + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); + } + } + try { - const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku)); - res.status(200).json({ history }); + const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku), limit != null ? { limit } : undefined); + const truncated = limit != null && history.length === limit; + res.status(200).json({ history, truncated }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Get article history by sku failed:', error); @@ -1868,7 +1892,7 @@ export function register(app, basePath = '') { * /article/{sku}/children: * post: * summary: Add a child article to a father article - * description: Automatically generates SKU as fatherSku-attributeValueId and assigns the variant attribute. + * description: Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute. * tags: * - Article * security: @@ -1896,6 +1920,9 @@ export function register(app, basePath = '') { * name: * type: string * description: Optional custom variant name + * sku: + * type: string + * description: Optional custom SKU for the child article * responses: * 201: * description: Created child article @@ -1907,9 +1934,7 @@ export function register(app, basePath = '') { * article: * $ref: '#/components/schemas/Article' * 400: - * description: Father not configured as father or missing value - * 409: - * description: Child SKU already exists + * description: Father not configured as father, missing value, duplicate variant, or child SKU already exists */ app.post(`${basePath}/article/:sku/children`, async (req, res) => { const parsed = parseSku(req.params.sku); diff --git a/src/api/routes/articleattributevalue.js b/src/api/routes/articleattributevalue.js index 0df7968..96200cb 100644 --- a/src/api/routes/articleattributevalue.js +++ b/src/api/routes/articleattributevalue.js @@ -89,14 +89,19 @@ export function register(app, basePath = '') { * properties: * attributevalues: * type: array + * description: Array of attribute value IDs (numbers) or objects with id or attributeValueId * items: * oneOf: * - type: integer + * description: Attribute value ID * - type: object - * required: [attributeValueId] * properties: + * id: + * type: integer + * description: Attribute value ID * attributeValueId: * type: integer + * description: Attribute value ID * sort: * type: integer * responses: @@ -142,7 +147,7 @@ export function register(app, basePath = '') { return res.status(400).json({ error: skuParsed.error }); } - const valuesInput = req.body?.attributevalues ?? req.body; + const valuesInput = req.body?.attributevalues ?? req.body?.attributeValues ?? req.body?.attribute_values ?? req.body?.values ?? req.body; if (!Array.isArray(valuesInput)) { return res.status(400).json({ error: 'attributevalues array required' }); } diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index 536e3b9..bd67282 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -171,6 +171,7 @@ function deliveryErrorStatus(error) { if (error.message === 'Invalid file') return 400; if (error.message === 'Invalid JSON in "data" field') return 400; if (error.message === 'Only PDF or text files are accepted as attachments') return 400; + if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400; return 500; } diff --git a/src/api/routes/order.js b/src/api/routes/order.js index 0263704..df6b4f3 100644 --- a/src/api/routes/order.js +++ b/src/api/routes/order.js @@ -18,6 +18,7 @@ function orderErrorStatus(error) { if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid order') return 400; if (error.message === 'Invalid lines') return 400; + if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400; return 500; } diff --git a/src/api/routes/quote.js b/src/api/routes/quote.js index 6ef3351..6c7f4d3 100644 --- a/src/api/routes/quote.js +++ b/src/api/routes/quote.js @@ -18,6 +18,7 @@ function quoteErrorStatus(error) { if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid quote') return 400; if (error.message === 'Invalid lines') return 400; + if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400; return 500; } diff --git a/src/api/routes/stock.js b/src/api/routes/stock.js index 8de4e93..3fbf90a 100644 --- a/src/api/routes/stock.js +++ b/src/api/routes/stock.js @@ -20,7 +20,7 @@ function stockErrorStatus(error) { if (error.message?.includes('Amount must be')) return 400; if (error.message?.includes('Invalid amount')) return 400; if (error.message === 'No items to adjust') return 400; - if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article')) return 400; + if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article') || error.message?.includes('Stückliste')) return 400; return 500; } diff --git a/src/db/article.js b/src/db/article.js index a572d6e..99aa6a8 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -10,9 +10,9 @@ export async function getBySku(googleAuthId, sku) { return store.article.getBySku(googleAuthId, sku); } -export async function getHistoryBySku(googleAuthId, sku) { +export async function getHistoryBySku(googleAuthId, sku, opts) { const store = await getStore(); - return store.article.getHistoryBySku(googleAuthId, sku); + return store.article.getHistoryBySku(googleAuthId, sku, opts); } export async function updateBySku(googleAuthId, sku, data) {