feat(api): add GET and POST /supplier/:id/articles endpoints and openapi docs

This commit is contained in:
seb
2026-09-18 02:54:06 +02:00
parent fe8fa55fbb
commit adb529d14f
8 changed files with 776 additions and 1 deletions

View File

@@ -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` |

View File

@@ -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": [

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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]

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}