From adb529d14fab46d0c81a93532a4e4b3b3bf1edc5 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 02:54:06 +0200 Subject: [PATCH] feat(api): add GET and POST /supplier/:id/articles endpoints and openapi docs --- docs/api/README.md | 2 + docs/api/openapi.json | 306 ++++++++++++++++++++++ docs/api/routes/supplier/articles.get.md | 92 +++++++ docs/api/routes/supplier/articles.post.md | 97 +++++++ src/api/openapi.js | 52 ++++ src/api/routes/supplier.js | 217 +++++++++++++++ src/db/articlesupplier.js | 6 +- src/db/supplier.js | 5 + 8 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 docs/api/routes/supplier/articles.get.md create mode 100644 docs/api/routes/supplier/articles.post.md diff --git a/docs/api/README.md b/docs/api/README.md index 49b1d48..c38711c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -124,6 +124,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/supplier/create.md`](routes/supplier/create.md) | `POST /supplier` | | [`routes/supplier/delete.md`](routes/supplier/delete.md) | `DELETE /supplier/{id}` | | [`routes/supplier/update.md`](routes/supplier/update.md) | `PATCH /supplier/{id}` | +| [`routes/supplier/articles.get.md`](routes/supplier/articles.get.md) | `GET /supplier/{id}/articles` | +| [`routes/supplier/articles.post.md`](routes/supplier/articles.post.md) | `POST /supplier/{id}/articles` | | [`routes/thumb/get.md`](routes/thumb/get.md) | `GET /thumb/{fullHash}` | | [`routes/unit/delete.md`](routes/unit/delete.md) | `DELETE /unit` | | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index a4f7f52..354117d 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -683,6 +683,220 @@ } } }, + "/supplier/{id}/articles": { + "get": { + "tags": [ + "Supplier" + ], + "summary": "List supplier articles by SKUs", + "description": "Lists article-supplier assignments for the specified supplier (entity ID),\noptionally filtered by a list of SKUs (matching either article SKU or supplier SKU).\nIncludes linked article details (name, slug, photos/articlepic, eans/articleean).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "supplier/articles.get.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Supplier entity ID" + }, + { + "in": "query", + "name": "skus", + "required": false, + "schema": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "description": "Optional SKU or comma-separated SKUs to filter by" + } + ], + "responses": { + "200": { + "description": "List of supplier articles", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "articles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupplierArticle" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid id", + "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": "Supplier or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "tags": [ + "Supplier" + ], + "summary": "Query supplier articles by SKUs (bulk)", + "description": "Queries article-supplier assignments for the specified supplier (entity ID) with a\nJSON list of SKUs in the request body, avoiding URL length limits for large SKU lists.\nIncludes linked article details (name, slug, photos/articlepic, eans/articleean).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "supplier/articles.post.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Supplier entity ID" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "skus": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of SKUs to filter by (empty array returns empty list)" + } + }, + "example": { + "skus": [ + "SKU-1", + "SKU-2" + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "List of supplier articles", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "articles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupplierArticle" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid id", + "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": "Supplier or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/quote": { "get": { "tags": [ @@ -9335,6 +9549,98 @@ } } }, + "SupplierArticlePhoto": { + "type": "object", + "required": [ + "sort", + "thumbhash", + "hash", + "thumbw", + "thumbh" + ], + "properties": { + "sort": { + "type": "integer" + }, + "thumbhash": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "thumbw": { + "type": "integer" + }, + "thumbh": { + "type": "integer" + } + } + }, + "SupplierArticleInfo": { + "type": "object", + "required": [ + "id", + "sku" + ], + "properties": { + "id": { + "type": "integer" + }, + "sku": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "slug": { + "type": "string", + "nullable": true + }, + "photos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupplierArticlePhoto" + } + }, + "articlepic": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SupplierArticlePhoto" + } + }, + "eans": { + "type": "array", + "items": { + "type": "string" + } + }, + "articleean": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SupplierArticle": { + "allOf": [ + { + "$ref": "#/components/schemas/ArticleSupplier" + }, + { + "type": "object", + "required": [ + "article" + ], + "properties": { + "article": { + "$ref": "#/components/schemas/SupplierArticleInfo" + } + } + } + ] + }, "Brand": { "type": "object", "required": [ diff --git a/docs/api/routes/supplier/articles.get.md b/docs/api/routes/supplier/articles.get.md new file mode 100644 index 0000000..2739cd4 --- /dev/null +++ b/docs/api/routes/supplier/articles.get.md @@ -0,0 +1,92 @@ +# `GET /supplier/{id}/articles` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List supplier articles by SKUs + +Lists article-supplier assignments for the specified supplier (entity ID), +optionally filtered by a list of SKUs (matching either article SKU or supplier SKU). +Includes linked article details (name, slug, photos/articlepic, eans/articleean). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Supplier entity ID | + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `skus` | no | | Optional SKU or comma-separated SKUs to filter by | + +## Response `200` + +List of supplier articles + +```json +{ + "articles": [ + { + "id": 1, + "articleId": 1, + "entityId": 1, + "supplierName": "…", + "sku": "…", + "pu": 1, + "stock": 1, + "sort": 1, + "priceId": 1, + "price": { + "net": 1, + "taxId": 1, + "taxRate": 1 + }, + "article": { + "id": 1, + "sku": "…", + "name": "…", + "slug": "…", + "photos": [ + { + "sort": 1, + "thumbhash": "…", + "hash": "…", + "thumbw": 1, + "thumbh": 1 + } + ], + "articlepic": [ + { + "sort": 1, + "thumbhash": "…", + "hash": "…", + "thumbw": 1, + "thumbh": 1 + } + ], + "eans": [ + "…" + ], + "articleean": [ + "…" + ] + } + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Supplier or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/supplier/articles.post.md b/docs/api/routes/supplier/articles.post.md new file mode 100644 index 0000000..26e1f82 --- /dev/null +++ b/docs/api/routes/supplier/articles.post.md @@ -0,0 +1,97 @@ +# `POST /supplier/{id}/articles` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Query supplier articles by SKUs (bulk) + +Queries article-supplier assignments for the specified supplier (entity ID) with a +JSON list of SKUs in the request body, avoiding URL length limits for large SKU lists. +Includes linked article details (name, slug, photos/articlepic, eans/articleean). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Supplier entity ID | + +## Body (JSON) + +```json +{ + "skus": [ + "SKU-1", + "SKU-2" + ] +} +``` + +## Response `200` + +List of supplier articles + +```json +{ + "articles": [ + { + "id": 1, + "articleId": 1, + "entityId": 1, + "supplierName": "…", + "sku": "…", + "pu": 1, + "stock": 1, + "sort": 1, + "priceId": 1, + "price": { + "net": 1, + "taxId": 1, + "taxRate": 1 + }, + "article": { + "id": 1, + "sku": "…", + "name": "…", + "slug": "…", + "photos": [ + { + "sort": 1, + "thumbhash": "…", + "hash": "…", + "thumbw": 1, + "thumbh": 1 + } + ], + "articlepic": [ + { + "sort": 1, + "thumbhash": "…", + "hash": "…", + "thumbw": 1, + "thumbh": 1 + } + ], + "eans": [ + "…" + ], + "articleean": [ + "…" + ] + } + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Supplier or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/openapi.js b/src/api/openapi.js index 0581107..a113301 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -155,6 +155,58 @@ * price: * $ref: '#/components/schemas/ArticleSupplierPrice' * nullable: true + * SupplierArticlePhoto: + * type: object + * required: [sort, thumbhash, hash, thumbw, thumbh] + * properties: + * sort: + * type: integer + * thumbhash: + * type: string + * hash: + * type: string + * thumbw: + * type: integer + * thumbh: + * type: integer + * SupplierArticleInfo: + * type: object + * required: [id, sku] + * properties: + * id: + * type: integer + * sku: + * type: string + * name: + * type: string + * nullable: true + * slug: + * type: string + * nullable: true + * photos: + * type: array + * items: + * $ref: '#/components/schemas/SupplierArticlePhoto' + * articlepic: + * type: array + * items: + * $ref: '#/components/schemas/SupplierArticlePhoto' + * eans: + * type: array + * items: + * type: string + * articleean: + * type: array + * items: + * type: string + * SupplierArticle: + * allOf: + * - $ref: '#/components/schemas/ArticleSupplier' + * - type: object + * required: [article] + * properties: + * article: + * $ref: '#/components/schemas/SupplierArticleInfo' * Brand: * type: object * required: [id, name] diff --git a/src/api/routes/supplier.js b/src/api/routes/supplier.js index b0aedcb..36d3cce 100644 --- a/src/api/routes/supplier.js +++ b/src/api/routes/supplier.js @@ -13,10 +13,41 @@ function supplierErrorStatus(error) { if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Supplier not found') return 404; + if (error.message === 'Supplier required') return 400; if (error.message === 'Name required') return 400; return 500; } +function parsePositiveInt(value, field) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +function parseSkus(source) { + if (source == null) return undefined; + if (Array.isArray(source)) return source; + if (typeof source === 'string') { + const trimmed = source.trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed; + } catch { + // fall through + } + } + if (trimmed.includes(',')) { + return trimmed.split(',').map((s) => s.trim()).filter(Boolean); + } + return [trimmed]; + } + return undefined; +} + /** * Registers supplier routes for the authenticated user's selected company. * @param {import('express').Application} app @@ -308,4 +339,190 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + /** + * @openapi + * /supplier/{id}/articles: + * get: + * tags: [Supplier] + * summary: List supplier articles by SKUs + * description: | + * Lists article-supplier assignments for the specified supplier (entity ID), + * optionally filtered by a list of SKUs (matching either article SKU or supplier SKU). + * Includes linked article details (name, slug, photos/articlepic, eans/articleean). + * security: + * - cookieAuth: [] + * x-markdown: supplier/articles.get.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Supplier entity ID + * - in: query + * name: skus + * required: false + * schema: + * oneOf: + * - type: array + * items: + * type: string + * - type: string + * description: Optional SKU or comma-separated SKUs to filter by + * responses: + * 200: + * description: List of supplier articles + * content: + * application/json: + * schema: + * type: object + * properties: + * articles: + * type: array + * items: + * $ref: '#/components/schemas/SupplierArticle' + * 400: + * description: No company selected or invalid id + * 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: Supplier or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * post: + * tags: [Supplier] + * summary: Query supplier articles by SKUs (bulk) + * description: | + * Queries article-supplier assignments for the specified supplier (entity ID) with a + * JSON list of SKUs in the request body, avoiding URL length limits for large SKU lists. + * Includes linked article details (name, slug, photos/articlepic, eans/articleean). + * security: + * - cookieAuth: [] + * x-markdown: supplier/articles.post.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Supplier entity ID + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * skus: + * type: array + * items: + * type: string + * description: List of SKUs to filter by (empty array returns empty list) + * example: + * skus: ['SKU-1', 'SKU-2'] + * responses: + * 200: + * description: List of supplier articles + * content: + * application/json: + * schema: + * type: object + * properties: + * articles: + * type: array + * items: + * $ref: '#/components/schemas/SupplierArticle' + * 400: + * description: No company selected or invalid id + * 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: Supplier or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + async function handleGetSupplierArticles(req, res) { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'id'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + const skus = parseSkus(req.query.skus ?? req.query.sku); + + try { + const articles = await db.listArticlesBySkus(req.user.id, idParsed.id, skus); + res.status(200).json({ articles }); + } catch (error) { + const status = supplierErrorStatus(error); + if (status === 500) console.error('List supplier articles failed:', error); + res.status(status).json({ error: error.message }); + } + } + + async function handlePostSupplierArticles(req, res) { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'id'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + const skus = parseSkus(req.body?.skus ?? req.body?.sku); + + try { + const articles = await db.listArticlesBySkus(req.user.id, idParsed.id, skus); + res.status(200).json({ articles }); + } catch (error) { + const status = supplierErrorStatus(error); + if (status === 500) console.error('Query supplier articles failed:', error); + res.status(status).json({ error: error.message }); + } + } + + app.get(`${basePath}/supplier/:id/articles`, handleGetSupplierArticles); + app.get(`${basePath}/supplier/:id/article`, handleGetSupplierArticles); + app.post(`${basePath}/supplier/:id/articles`, handlePostSupplierArticles); + app.post(`${basePath}/supplier/:id/article`, handlePostSupplierArticles); } + diff --git a/src/db/articlesupplier.js b/src/db/articlesupplier.js index 6867767..b64c0b0 100644 --- a/src/db/articlesupplier.js +++ b/src/db/articlesupplier.js @@ -19,9 +19,13 @@ export async function remove(googleAuthId, sku, id) { const store = await getStore(); return store.articlesupplier.delete(googleAuthId, sku, id); } - export async function order(googleAuthId, sku, data) { const store = await getStore(); return store.articlesupplier.order(googleAuthId, sku, data); } +export async function listByEntityAndSkus(googleAuthId, entityIdOrOptions, maybeSkus) { + const store = await getStore(); + return store.articlesupplier.listByEntityAndSkus(googleAuthId, entityIdOrOptions, maybeSkus); +} + diff --git a/src/db/supplier.js b/src/db/supplier.js index 30097ab..e72974c 100644 --- a/src/db/supplier.js +++ b/src/db/supplier.js @@ -19,3 +19,8 @@ export async function remove(googleAuthId, id) { const store = await getStore(); return store.supplier.delete(googleAuthId, id); } + +export async function listArticlesBySkus(googleAuthId, entityIdOrOptions, maybeSkus) { + const store = await getStore(); + return store.articlesupplier.listByEntityAndSkus(googleAuthId, entityIdOrOptions, maybeSkus); +}