From 120e59631eaebc849e5aeabe8f547b406363ba20 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 30 Sep 2026 22:37:34 +0200 Subject: [PATCH] u --- docs/api/routes.md | 2 +- .../routes/article/_sku/children/_childSku.md | 2 +- docs/api/routes/article/delete.md | 6 ++ docs/api/routes/article/set-eans.md | 1 + docs/api/routes/article/set-prices.md | 2 +- docs/api/routes/stock/adjust.md | 2 +- src/api/routes/article.js | 75 ++++++++++++++----- src/api/routes/articlesupplier.js | 34 +++++++-- src/api/routes/category.js | 12 ++- src/api/routes/company.js | 7 ++ src/api/routes/delivery.js | 4 + src/api/routes/entity.js | 4 + src/api/routes/order.js | 7 ++ src/api/routes/quote.js | 7 ++ src/api/routes/stock.js | 3 +- src/db/article.js | 4 +- 16 files changed, 137 insertions(+), 35 deletions(-) diff --git a/docs/api/routes.md b/docs/api/routes.md index f4efb72..968441b 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -13,7 +13,7 @@ Overview of all 136 API endpoints across all modules. | Method | Route | Params | Return Type | Summary | |:---|:---|:---|:---|:---| | `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category | -| `DELETE` | `/article/{sku}` | `sku:string` | `204 void` | Delete article by SKU | +| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU | | `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Get article by SKU | | `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Update article by SKU | | `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article | diff --git a/docs/api/routes/article/_sku/children/_childSku.md b/docs/api/routes/article/_sku/children/_childSku.md index 058435f..ce8e2c5 100644 --- a/docs/api/routes/article/_sku/children/_childSku.md +++ b/docs/api/routes/article/_sku/children/_childSku.md @@ -17,7 +17,7 @@ Unlinks the child from the father (or deletes if deleteArticle query/body is tru | Param | Required | Type | Description | |-------|----------|------|-------------| -| `deleteArticle` | no | boolean | If true, permanently deletes the child article instead of just unlinking | +| `deleteArticle` | no | boolean | If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking | ## Response `200` diff --git a/docs/api/routes/article/delete.md b/docs/api/routes/article/delete.md index 081848b..2b3304e 100644 --- a/docs/api/routes/article/delete.md +++ b/docs/api/routes/article/delete.md @@ -15,6 +15,12 @@ Auth like GET by SKU. |-------|----------|------|-------------| | `sku` | yes | string | Article SKU | +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `cascade` | no | boolean | If true, recursively deletes all descendant child articles in the subtree | + ## Signals On success, emits SSE signal: `articles` (see [`GET /events`](../events.md)). diff --git a/docs/api/routes/article/set-eans.md b/docs/api/routes/article/set-eans.md index a7411fa..e5b1ea4 100644 --- a/docs/api/routes/article/set-eans.md +++ b/docs/api/routes/article/set-eans.md @@ -75,6 +75,7 @@ Updated article detail | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Article not found — `{"error":"…"}` | +| 409 | EAN already in use — `{"error":"…"}` | --- diff --git a/docs/api/routes/article/set-prices.md b/docs/api/routes/article/set-prices.md index da15cd2..ac7de63 100644 --- a/docs/api/routes/article/set-prices.md +++ b/docs/api/routes/article/set-prices.md @@ -83,7 +83,7 @@ Updated article detail | Status | Description | |--------|-------------| -| 400 | Invalid prices or POS customer group required — `{"error":"…"}` | +| 400 | Invalid prices, POS customer group required, or father article cannot have prices — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Article not found — `{"error":"…"}` | diff --git a/docs/api/routes/stock/adjust.md b/docs/api/routes/stock/adjust.md index 86235a9..26f2b19 100644 --- a/docs/api/routes/stock/adjust.md +++ b/docs/api/routes/stock/adjust.md @@ -77,7 +77,7 @@ Stock adjusted successfully | Status | Description | |--------|-------------| -| 400 | Missing fields, invalid amount, or no company selected — `{"error":"…"}` | +| 400 | Missing fields, invalid amount, father article cannot have stock, or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` | | 404 | Article or warehouse not found — `{"error":"…"}` | diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 259801b..198cd80 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -64,8 +64,12 @@ function articleErrorStatus(error) { if (error.message === 'Invalid unitamount') return 400; if (error.message === 'Unit not found') return 404; if (error.message === 'Invalid eans') return 400; + if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409; + if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint')) return 409; if (error.message === 'Company not found') return 404; if (error.message === 'Brand not found') return 404; + if (error.message === 'Invalid brandId') return 400; + if (error.message === 'Invalid attributeValueId') return 400; if (error.message === 'Invalid weight') return 400; if (error.message === 'Invalid track_stock') return 400; if (error.message === 'Invalid categoryIds') return 400; @@ -73,6 +77,7 @@ function articleErrorStatus(error) { if (error.message === 'attributeNameId is required to become a father article') return 400; if (error.message?.includes('already used by an ancestor father')) return 400; if (error.message?.includes('cannot have attribute values for its variant attribute')) return 400; + if (error.message?.includes('A father article cannot have') || error.message?.includes('father article cannot have')) return 400; if (error.message === 'Cannot disable father mode on an article that still has children') return 400; if (error.message === 'Article is not a father article') return 400; if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400; @@ -186,10 +191,14 @@ export function register(app, basePath = '') { * $ref: '#/components/schemas/Error' */ app.post(`${basePath}/article`, async (req, res) => { - const parent = parsePositiveInt(req.body?.categoryId ?? req.body?.parentId, 'categoryId'); - if (parent.error) { - return res.status(400).json({ error: parent.error }); + const rawParent = req.body?.categoryId ?? req.body?.parentId; + if (rawParent === undefined || rawParent === null) { + return res.status(400).json({ error: 'categoryId is required' }); } + if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) { + return res.status(400).json({ error: 'categoryId must be an integer' }); + } + const parentId = rawParent; const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { @@ -205,7 +214,7 @@ export function register(app, basePath = '') { try { const created = await db.createUnderParent({ googleAuthId: req.user.id, - parentId: parent.id, + parentId, name, track_stock, }); @@ -467,6 +476,11 @@ export function register(app, basePath = '') { * schema: * type: string * description: Article SKU + * - in: query + * name: cascade + * schema: + * type: boolean + * description: If true, recursively deletes all descendant child articles in the subtree * responses: * 204: * description: Deleted @@ -638,8 +652,10 @@ export function register(app, basePath = '') { return res.status(400).json({ error: parsed.error }); } + const cascade = req.query.cascade === 'true'; + try { - await db.remove(req.user.id, parsed.sku); + await db.remove(req.user.id, parsed.sku, { cascade }); emitArticlesSignal(req); res.sendStatus(204); } catch (error) { @@ -710,7 +726,7 @@ export function register(app, basePath = '') { * article: * $ref: '#/components/schemas/ArticleDetail' * 400: - * description: Invalid prices or POS customer group required + * description: Invalid prices, POS customer group required, or father article cannot have prices * content: * application/json: * schema: @@ -1094,6 +1110,12 @@ export function register(app, basePath = '') { * application/json: * schema: * $ref: '#/components/schemas/Error' + * 409: + * description: EAN already in use + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ app.put(`${basePath}/article/:sku/eans`, async (req, res) => { const skuParsed = parseSku(req.params.sku); @@ -1190,6 +1212,9 @@ export function register(app, basePath = '') { } const brandId = req.body?.brandId ?? null; + if (brandId !== null && (typeof brandId !== 'number' || !Number.isInteger(brandId) || brandId <= 0)) { + return res.status(400).json({ error: 'Invalid brandId' }); + } try { const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId); @@ -1602,16 +1627,32 @@ export function register(app, basePath = '') { if (skuError) return res.status(400).json({ error: skuError }); const sku = parsedSku; - const { templateId, printerId, quantity } = req.body; - - const parsedTemplateId = parsePositiveInt(templateId, 'templateId'); - if (parsedTemplateId.error) return res.status(400).json({ error: parsedTemplateId.error }); - - const parsedPrinterId = parsePositiveInt(printerId, 'printerId'); - if (parsedPrinterId.error) return res.status(400).json({ error: parsedPrinterId.error }); - - const parsedQuantity = parsePositiveInt(quantity, 'quantity'); - if (parsedQuantity.error) return res.status(400).json({ error: parsedQuantity.error }); + const { templateId, printerId, quantity } = req.body ?? {}; + + if (templateId === undefined || templateId === null) { + return res.status(400).json({ error: 'templateId is required' }); + } + if (typeof templateId !== 'number' || !Number.isInteger(templateId) || templateId <= 0) { + return res.status(400).json({ error: 'templateId must be an integer' }); + } + + if (printerId === undefined || printerId === null) { + return res.status(400).json({ error: 'printerId is required' }); + } + if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) { + return res.status(400).json({ error: 'printerId must be an integer' }); + } + + if (quantity === undefined || quantity === null) { + return res.status(400).json({ error: 'quantity is required' }); + } + if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity <= 0) { + return res.status(400).json({ error: 'quantity must be an integer' }); + } + + const parsedPrinterId = { id: printerId }; + const parsedTemplateId = { id: templateId }; + const parsedQuantity = { id: quantity }; try { const printer = await printerDb.getById(req.user.id, parsedPrinterId.id); @@ -1907,7 +1948,7 @@ export function register(app, basePath = '') { * name: deleteArticle * schema: * type: boolean - * description: If true, permanently deletes the child article instead of just unlinking + * description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking * responses: * 200: * description: Child removed diff --git a/src/api/routes/articlesupplier.js b/src/api/routes/articlesupplier.js index e5aa669..9a75ea4 100644 --- a/src/api/routes/articlesupplier.js +++ b/src/api/routes/articlesupplier.js @@ -35,6 +35,7 @@ function articleSupplierErrorStatus(error) { if (error.message === 'Company not found') return 404; if (error.message === 'Article not found') return 404; if (error.message === 'Supplier not found') return 404; + if (error.message === 'Invalid supplierId') return 400; if (error.message === 'Supplier assignment not found') return 404; if (error.message === 'Supplier already assigned') return 409; if (error.message === 'Supplier required' || error.message === 'entityId is required') return 400; @@ -245,21 +246,30 @@ export function register(app, basePath = '') { } const { entityId, sku: supplierSku, pu, stock, sort, netPrice, taxId, taxRate } = req.body ?? {}; - if (entityId == null || !Number.isFinite(entityId)) { + if (entityId == null) { return res.status(400).json({ error: 'entityId is required' }); } - if (pu !== undefined && pu !== null && (!Number.isFinite(pu) || pu <= 0)) { + if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) { + return res.status(400).json({ error: 'entityId must be an integer' }); + } + if (pu !== undefined && pu !== null && (typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0)) { return res.status(400).json({ error: 'pu must be greater than 0' }); } - if (stock !== undefined && stock !== null && !Number.isFinite(stock)) { + if (stock !== undefined && stock !== null && (typeof stock !== 'number' || !Number.isFinite(stock))) { return res.status(400).json({ error: 'Invalid stock' }); } - if (sort !== undefined && sort !== null && (!Number.isInteger(sort) || sort < 0)) { + if (sort !== undefined && sort !== null && (typeof sort !== 'number' || !Number.isInteger(sort) || sort < 0)) { return res.status(400).json({ error: 'Invalid sort' }); } - if (netPrice !== undefined && netPrice !== null && !Number.isFinite(netPrice)) { + if (netPrice !== undefined && netPrice !== null && (typeof netPrice !== 'number' || !Number.isFinite(netPrice) || netPrice < 0)) { return res.status(400).json({ error: 'Invalid price' }); } + if (taxId !== undefined && taxId !== null && (typeof taxId !== 'number' || !Number.isInteger(taxId) || taxId <= 0)) { + return res.status(400).json({ error: 'Invalid taxId' }); + } + if (taxRate !== undefined && taxRate !== null && (typeof taxRate !== 'number' || !Number.isFinite(taxRate) || taxRate < 0)) { + return res.status(400).json({ error: 'Invalid taxRate' }); + } try { const supplier = await db.create(req.user.id, skuParsed.sku, { @@ -465,8 +475,18 @@ export function register(app, basePath = '') { } patch.netPrice = body.netPrice; } - if (body.taxId !== undefined) patch.taxId = body.taxId; - if (body.taxRate !== undefined) patch.taxRate = body.taxRate; + if (body.taxId !== undefined) { + if (body.taxId !== null && (typeof body.taxId !== 'number' || !Number.isInteger(body.taxId) || body.taxId <= 0)) { + return res.status(400).json({ error: 'Invalid taxId' }); + } + patch.taxId = body.taxId; + } + if (body.taxRate !== undefined) { + if (body.taxRate !== null && (typeof body.taxRate !== 'number' || !Number.isFinite(body.taxRate) || body.taxRate < 0)) { + return res.status(400).json({ error: 'Invalid taxRate' }); + } + patch.taxRate = body.taxRate; + } try { const supplier = await db.update(req.user.id, skuParsed.sku, idParsed.id, patch); diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 7ff8a57..54ec93c 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -261,10 +261,14 @@ export function register(app, basePath = '') { * $ref: '#/components/schemas/Error' */ app.post(`${basePath}/category`, async (req, res) => { - const parent = parsePositiveInt(req.body?.parentId, 'parentId'); - if (parent.error) { - return res.status(400).json({ error: parent.error }); + const rawParent = req.body?.parentId; + if (rawParent === undefined || rawParent === null) { + return res.status(400).json({ error: 'parentId is required' }); } + if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) { + return res.status(400).json({ error: 'parentId must be an integer' }); + } + const parentId = rawParent; const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { @@ -274,7 +278,7 @@ export function register(app, basePath = '') { try { const category = await db.createUnderParent({ googleAuthId: req.user.id, - parentId: parent.id, + parentId, name, }); emitCategorySignal(req); diff --git a/src/api/routes/company.js b/src/api/routes/company.js index dc9ccd2..bce0dc3 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -15,6 +15,12 @@ function parseCompanyBody(body) { if (!iso639 || !name || taxrate == null || reducedtaxrate == null) { return { error: 'iso639, name, taxrate, and reducedtaxrate are required' }; } + if (typeof taxrate !== 'number' || !Number.isFinite(taxrate) || taxrate < 0) { + return { error: 'taxrate must be a non-negative number' }; + } + if (typeof reducedtaxrate !== 'number' || !Number.isFinite(reducedtaxrate) || reducedtaxrate < 0) { + return { error: 'reducedtaxrate must be a non-negative number' }; + } return { iso639, name, taxrate, reducedtaxrate }; } @@ -29,6 +35,7 @@ function parseCompanyId(id) { function companyErrorStatus(error) { if (error.message === 'Company not found') return 404; if (error.message === 'Company name not available') return 409; + if (error.message === 'Invalid tax rate') return 400; return 500; } diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index 8035907..536e3b9 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -48,6 +48,9 @@ function parseDeliveryPayload(req) { } const { externalNumber, date, createdAt, supplierId, lines } = payload; + if (supplierId !== undefined && supplierId !== null && (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0)) { + throw new Error('Invalid supplierId'); + } let files = payload.files; // If binary files were uploaded via multer: if (Array.isArray(req.files) && req.files.length > 0) { @@ -161,6 +164,7 @@ function deliveryErrorStatus(error) { if (error.message === 'Attachment not found') return 404; if (error.message === 'File not found') return 404; if (error.message === 'Supplier not found') return 404; + if (error.message === 'Invalid supplierId') return 400; if (error.message === 'Article not found') return 404; if (error.message === 'Invalid delivery') return 400; if (error.message === 'Invalid lines') return 400; diff --git a/src/api/routes/entity.js b/src/api/routes/entity.js index 43889b2..754e01e 100644 --- a/src/api/routes/entity.js +++ b/src/api/routes/entity.js @@ -17,6 +17,7 @@ function entityErrorStatus(error) { if (error.message === 'Name required') return 400; if (error.message === 'Number required') return 400; if (error.message === 'Number already in use') return 409; + if (error.message === 'Invalid customergroupId') return 400; if (error.message === 'Invalid role') return 400; return 500; } @@ -196,6 +197,9 @@ export function register(app, basePath = '') { } const body = req.body ?? {}; + if (body.customergroupId !== undefined && body.customergroupId !== null && (typeof body.customergroupId !== 'number' || !Number.isInteger(body.customergroupId) || body.customergroupId <= 0)) { + return res.status(400).json({ error: 'Invalid customergroupId' }); + } const data = { customergroupId: body.customergroupId, name: body.name, diff --git a/src/api/routes/order.js b/src/api/routes/order.js index c0fdc48..0263704 100644 --- a/src/api/routes/order.js +++ b/src/api/routes/order.js @@ -15,6 +15,7 @@ function orderErrorStatus(error) { if (error.message === 'Order not found') return 404; if (error.message === 'Entity not found') return 404; if (error.message === 'Article not found') return 404; + if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid order') return 400; if (error.message === 'Invalid lines') return 400; return 500; @@ -176,6 +177,9 @@ export function register(app, basePath = '') { } const { number, date, createdAt, entityId, lines } = req.body ?? {}; + if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) { + return res.status(400).json({ error: 'Invalid entityId' }); + } try { const order = await db.create(req.user.id, { @@ -401,6 +405,9 @@ export function register(app, basePath = '') { patch.createdAt = body.createdAt; } if (Object.prototype.hasOwnProperty.call(body, 'entityId')) { + if (body.entityId !== null && (typeof body.entityId !== 'number' || !Number.isInteger(body.entityId) || body.entityId <= 0)) { + return res.status(400).json({ error: 'Invalid entityId' }); + } patch.entityId = body.entityId; } if (Object.prototype.hasOwnProperty.call(body, 'lines')) { diff --git a/src/api/routes/quote.js b/src/api/routes/quote.js index 8f9298b..6ef3351 100644 --- a/src/api/routes/quote.js +++ b/src/api/routes/quote.js @@ -15,6 +15,7 @@ function quoteErrorStatus(error) { if (error.message === 'Quote not found') return 404; if (error.message === 'Entity not found') return 404; if (error.message === 'Article not found') return 404; + if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid quote') return 400; if (error.message === 'Invalid lines') return 400; return 500; @@ -176,6 +177,9 @@ export function register(app, basePath = '') { } const { number, date, createdAt, entityId, lines } = req.body ?? {}; + if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) { + return res.status(400).json({ error: 'Invalid entityId' }); + } try { const quote = await db.create(req.user.id, { @@ -401,6 +405,9 @@ export function register(app, basePath = '') { patch.createdAt = body.createdAt; } if (Object.prototype.hasOwnProperty.call(body, 'entityId')) { + if (body.entityId !== null && (typeof body.entityId !== 'number' || !Number.isInteger(body.entityId) || body.entityId <= 0)) { + return res.status(400).json({ error: 'Invalid entityId' }); + } patch.entityId = body.entityId; } if (Object.prototype.hasOwnProperty.call(body, 'lines')) { diff --git a/src/api/routes/stock.js b/src/api/routes/stock.js index 7e8acb3..8de4e93 100644 --- a/src/api/routes/stock.js +++ b/src/api/routes/stock.js @@ -20,6 +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; return 500; } @@ -84,7 +85,7 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/StockAdjustmentResult' * 400: - * description: Missing fields, invalid amount, or no company selected + * description: Missing fields, invalid amount, father article cannot have stock, or no company selected * content: * application/json: * schema: diff --git a/src/db/article.js b/src/db/article.js index 9e41270..a572d6e 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -35,9 +35,9 @@ export async function orderImages(googleAuthId, sku, data) { return store.article.orderImages(googleAuthId, sku, data); } -export async function remove(googleAuthId, sku) { +export async function remove(googleAuthId, sku, options) { const store = await getStore(); - return store.article.delete(googleAuthId, sku); + return store.article.delete(googleAuthId, sku, options); } export async function setPricesBySku(googleAuthId, sku, prices) {