This commit is contained in:
seb
2026-08-02 19:04:56 +02:00
parent bfb1d8762a
commit aed0857f23
21 changed files with 1953 additions and 0 deletions

View File

@@ -31,6 +31,29 @@ npm run docs:api
That writes `docs/api/openapi.json` and per-route Markdown under `docs/api/routes/`.
### Brands (company-scoped, `selectedCompanyId` aus der Session)
| Methode | Route | Store-Methode |
| --- | --- | --- |
| `GET` | `/api/brand` | `brand.list` |
| `PUT` | `/api/brand` | `brand.upsert` |
| `DELETE` | `/api/brand/:id` | `brand.delete` |
### Artikel-Felder (liefern dasselbe Shape wie `GET /api/article/:sku`)
| Methode | Route | Store-Methode |
| --- | --- | --- |
| `PUT` | `/api/article/:sku/eans` | `article.setEansBySku` |
| `PUT` | `/api/article/:sku/brand` | `article.setBrandIdBySku` |
| `PUT` | `/api/article/:sku/weight` | `article.setWeightBySku` |
| `PUT` | `/api/article/:sku/categories` | `article.setCategoryIdsBySku` |
### Kategorien
| Methode | Route | Store-Methode |
| --- | --- | --- |
| `PATCH` | `/api/category/:id` | `category.rename` (Name + Slug in Firmensprache) |
## Users
Manage the email allowlist:

30
TODO.md Normal file
View File

@@ -0,0 +1,30 @@
# TODO — nächster Schritt (Brands / Artikelfelder)
Geschwister: **dashdbschema** hat `libdashstore` um folgende Store-Methoden erweitert (alle authenticated, `googleAuthId` = eingeloggter User). Die Brand-Store-API existiert bereits. Das HTTP-Layer + OpenAPI-Doku hier in dashback ist **umgesetzt** — **dashfront** kann die UI bauen.
## Store-Methoden + HTTP-Routen (in dashback verdrahtet)
**`companyId` kommt immer aus der Auth-Session** (`req.user.selectedCompanyId`) — keine explizite `companyId` in Pfad/Query/Body, wie bei den Unit-Routen.
### `article` (alle liefern dasselbe Shape wie `getBySku`)
| Store-Methode | HTTP-Route | Args |
| --- | --- | --- |
| `setEansBySku` | `PUT /api/article/:sku/eans` | `(googleAuthId, sku, string[])` — replace-all |
| `setBrandIdBySku` | `PUT /api/article/:sku/brand` | `(googleAuthId, sku, brandId \| null)` |
| `setWeightBySku` | `PUT /api/article/:sku/weight` | `(googleAuthId, sku, weight)` |
| `setCategoryIdsBySku` | `PUT /api/article/:sku/categories` | `(googleAuthId, sku, number[])` — replace-all |
| `getBySku` | `GET /api/article/:sku` | liefert `eans`, `categoryIds`, `weight`, `brand` bereits mit |
### `brand` (verdrahtet)
| Store-Methode | HTTP-Route | Args |
| --- | --- | --- |
| `brand.list` | `GET /api/brand` | `(googleAuthId, selectedCompanyId)` |
| `brand.upsert` | `PUT /api/brand` | `(googleAuthId, selectedCompanyId, { name, gsprpinfo? })` |
| `brand.delete` | `DELETE /api/brand/:id` | `(googleAuthId, selectedCompanyId, id)` — Fehler `Brand in use`, wenn noch Artikel referenzieren |
## Umgesetzt
1. Routen unter `docs/api/routes/` + OpenAPI via `@openapi` JSDoc angelegt (stil wie existing routes).
2. Auth-Integration: `googleAuthId` aus Session bezogen (wie bestehende Routen).
3. Company-Scope: `selectedCompanyId` aus Session; Methoden validieren selbst (`Company not found` / `Not authorized` / etc.).
4. `npm run docs:api` regenerieren.
5. `README.md` (dashback) mit neuen Endpoints ergänzen.

View File

@@ -34,17 +34,25 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` |
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` |
| [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` |
| [`routes/article/set-brand.md`](routes/article/set-brand.md) | `PUT /article/{sku}/brand` |
| [`routes/article/set-categories.md`](routes/article/set-categories.md) | `PUT /article/{sku}/categories` |
| [`routes/article/set-eans.md`](routes/article/set-eans.md) | `PUT /article/{sku}/eans` |
| [`routes/article/add-image.md`](routes/article/add-image.md) | `POST /article/{sku}/image` |
| [`routes/article/delete-image.md`](routes/article/delete-image.md) | `DELETE /article/{sku}/image/{fullHash}` |
| [`routes/article/order-images.md`](routes/article/order-images.md) | `PUT /article/{sku}/images/order` |
| [`routes/article/set-prices.md`](routes/article/set-prices.md) | `PUT /article/{sku}/prices` |
| [`routes/article/set-weight.md`](routes/article/set-weight.md) | `PUT /article/{sku}/weight` |
| [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` |
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/brand/list.md`](routes/brand/list.md) | `GET /brand` |
| [`routes/brand/upsert.md`](routes/brand/upsert.md) | `PUT /brand` |
| [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` |
| [`routes/category/create.md`](routes/category/create.md) | `POST /category` |
| [`routes/category/tree.md`](routes/category/tree.md) | `GET /category/tree` |
| [`routes/category/delete.md`](routes/category/delete.md) | `DELETE /category/{id}` |
| [`routes/category/rename.md`](routes/category/rename.md) | `PATCH /category/{id}` |
| [`routes/category/articles.md`](routes/category/articles.md) | `GET /category/{id}/articles` |
| [`routes/company/list.md`](routes/company/list.md) | `GET /company` |
| [`routes/company/create.md`](routes/company/create.md) | `POST /company` |

View File

@@ -36,6 +36,10 @@
"name": "Unit",
"description": "Company-scoped units of measure"
},
{
"name": "Brand",
"description": "Company-scoped brands"
},
{
"name": "Image",
"description": "Content-addressed AVIF by fullHash"
@@ -1132,6 +1136,124 @@
}
},
"/category/{id}": {
"patch": {
"tags": [
"Category"
],
"summary": "Rename category",
"description": "Renames a category the user is authorized for (updates name and re-slugifies in the company default language).",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "category/rename.md",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
},
"description": "Category id"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
},
"example": {
"name": "Renamed category"
}
}
}
}
},
"responses": {
"200": {
"description": "Renamed category",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"category": {
"type": "object",
"required": [
"id",
"name",
"slug"
],
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"slug": {
"type": "string"
}
}
}
}
}
}
}
},
"400": {
"description": "Validation error (name required)",
"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": "Category not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"tags": [
"Category"
@@ -1192,6 +1314,256 @@
}
}
},
"/brand": {
"get": {
"tags": [
"Brand"
],
"summary": "List brands",
"description": "Lists brands for the authenticated user's selected company, sorted by name.",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "brand/list.md",
"responses": {
"200": {
"description": "Brand list",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"brands": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Brand"
}
}
}
}
}
}
},
"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"
}
}
}
}
}
},
"put": {
"tags": [
"Brand"
],
"summary": "Upsert brand",
"description": "Insert or update brand by `name` for the selected company. `gsprpinfo` optional.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "brand/upsert.md",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"gsprpinfo": {
"type": "string"
}
},
"example": {
"name": "Acme",
"gsprpinfo": "Acme GmbH"
}
}
}
}
},
"responses": {
"200": {
"description": "Upserted brand",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"brand": {
"$ref": "#/components/schemas/Brand"
}
}
}
}
}
},
"400": {
"description": "Invalid brand or 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"
}
}
}
}
}
}
},
"/brand/{id}": {
"delete": {
"tags": [
"Brand"
],
"summary": "Delete brand",
"description": "Deletes the brand for the selected company. Fails with `Brand in use`\nif any article still references it.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "brand/delete.md",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"204": {
"description": "Deleted"
},
"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": "Brand not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Brand in use",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/article": {
"post": {
"tags": [
@@ -1971,6 +2343,430 @@
}
}
},
"/article/{sku}/eans": {
"put": {
"tags": [
"Article"
],
"summary": "Replace article EANs by SKU",
"description": "Replace-all EANs for the article. EANs must be non-empty unique strings;\nempty array clears all EANs. Returns the same shape as GET by SKU.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "article/set-eans.md",
"parameters": [
{
"in": "path",
"name": "sku",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"eans"
],
"properties": {
"eans": {
"type": "array",
"items": {
"type": "string"
}
}
},
"example": {
"eans": [
"4001234567890"
]
}
}
}
}
},
"responses": {
"200": {
"description": "Updated article detail",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleDetail"
}
}
}
}
}
},
"400": {
"description": "Invalid eans",
"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": "Article not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/article/{sku}/brand": {
"put": {
"tags": [
"Article"
],
"summary": "Set or clear article brand by SKU",
"description": "Sets the article's `brandId` (must belong to the article's company).\nPass `null` to clear the brand. Returns the same shape as GET by SKU.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "article/set-brand.md",
"parameters": [
{
"in": "path",
"name": "sku",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"brandId"
],
"properties": {
"brandId": {
"type": "integer",
"nullable": true
}
},
"example": {
"brandId": 3
}
}
}
}
},
"responses": {
"200": {
"description": "Updated article detail",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleDetail"
}
}
}
}
}
},
"400": {
"description": "Validation error",
"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": "Article or brand not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/article/{sku}/weight": {
"put": {
"tags": [
"Article"
],
"summary": "Set article weight by SKU",
"description": "Sets the article's `weight`. Must be finite and `>= 0`.\nReturns the same shape as GET by SKU.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "article/set-weight.md",
"parameters": [
{
"in": "path",
"name": "sku",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"weight"
],
"properties": {
"weight": {
"type": "number"
}
},
"example": {
"weight": 1.5
}
}
}
}
},
"responses": {
"200": {
"description": "Updated article detail",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleDetail"
}
}
}
}
}
},
"400": {
"description": "Invalid weight",
"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": "Article not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/article/{sku}/categories": {
"put": {
"tags": [
"Article"
],
"summary": "Replace article categories by SKU",
"description": "Replace-all the article's category list. Each category must belong to\nthe article's company; ids must be unique. Empty array removes the\narticle from all categories. Returns the same shape as GET by SKU.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "article/set-categories.md",
"parameters": [
{
"in": "path",
"name": "sku",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"categoryIds"
],
"properties": {
"categoryIds": {
"type": "array",
"items": {
"type": "integer"
}
}
},
"example": {
"categoryIds": [
4,
7
]
}
}
}
}
},
"responses": {
"200": {
"description": "Updated article detail",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleDetail"
}
}
}
}
}
},
"400": {
"description": "Invalid categoryIds",
"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": "Article not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/auth/selected-company": {
"put": {
"tags": [
@@ -2365,6 +3161,25 @@
"description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, …)",
"additionalProperties": true
},
"Brand": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"gsprpinfo": {
"type": "string",
"nullable": true
}
}
},
"ArticleImage": {
"type": "object",
"required": [

View File

@@ -0,0 +1,46 @@
# `PUT /article/{sku}/brand`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Set or clear article brand by SKU
Sets the article's `brandId` (must belong to the article's company).
Pass `null` to clear the brand. Returns the same shape as GET by SKU.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | |
## Body (JSON)
```json
{
"brandId": 3
}
```
## Response `200`
Updated article detail
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article or brand not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,50 @@
# `PUT /article/{sku}/categories`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Replace article categories by SKU
Replace-all the article's category list. Each category must belong to
the article's company; ids must be unique. Empty array removes the
article from all categories. Returns the same shape as GET by SKU.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | |
## Body (JSON)
```json
{
"categoryIds": [
4,
7
]
}
```
## Response `200`
Updated article detail
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid categoryIds — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,48 @@
# `PUT /article/{sku}/eans`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Replace article EANs by SKU
Replace-all EANs for the article. EANs must be non-empty unique strings;
empty array clears all EANs. Returns the same shape as GET by SKU.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | |
## Body (JSON)
```json
{
"eans": [
"4001234567890"
]
}
```
## Response `200`
Updated article detail
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid eans — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,46 @@
# `PUT /article/{sku}/weight`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Set article weight by SKU
Sets the article's `weight`. Must be finite and `>= 0`.
Returns the same shape as GET by SKU.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | |
## Body (JSON)
```json
{
"weight": 1.5
}
```
## Response `200`
Updated article detail
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid weight — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,35 @@
# `DELETE /brand/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete brand
Deletes the brand for the selected company. Fails with `Brand in use`
if any article still references it.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `204`
Deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Brand not found — `{"error":"…"}` |
| 409 | Brand in use — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,30 @@
# `GET /brand`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List brands
Lists brands for the authenticated user's selected company, sorted by name.
## Response `200`
Brand list
```json
{
"brands": []
}
```
## 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`._

View File

@@ -0,0 +1,40 @@
# `PUT /brand`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Upsert brand
Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
## Body (JSON)
```json
{
"name": "Acme",
"gsprpinfo": "Acme GmbH"
}
```
## Response `200`
Upserted brand
```json
{
"brand": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid brand or 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`._

View File

@@ -0,0 +1,44 @@
# `PATCH /category/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Rename category
Renames a category the user is authorized for (updates name and re-slugifies in the company default language).
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Category id |
## Body (JSON)
```json
{
"name": "Renamed category"
}
```
## Response `200`
Renamed category
```json
{
"category": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error (name required) — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Category not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -34,6 +34,7 @@ const options = {
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
{ name: 'Unit', description: 'Company-scoped units of measure' },
{ name: 'Brand', description: 'Company-scoped brands' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
],
},

View File

@@ -88,6 +88,17 @@
* type: object
* description: Full article payload (unit, brand, categoryIds, photos, eans, prices, …)
* additionalProperties: true
* Brand:
* type: object
* required: [id, name]
* properties:
* id:
* type: integer
* name:
* type: string
* gsprpinfo:
* type: string
* nullable: true
* ArticleImage:
* type: object
* required: [picId, sort, full, thumb, hash, thumbHash]

View File

@@ -38,6 +38,10 @@ function articleErrorStatus(error) {
if (error.message === 'POS customer group required') return 400;
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 === 'Brand not found') return 404;
if (error.message === 'Invalid weight') return 400;
if (error.message === 'Invalid categoryIds') return 400;
return 500;
}
@@ -724,4 +728,350 @@ export function register(app, basePath = '') {
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/eans:
* put:
* tags: [Article]
* summary: Replace article EANs by SKU
* description: |
* Replace-all EANs for the article. EANs must be non-empty unique strings;
* empty array clears all EANs. Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-markdown: article/set-eans.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [eans]
* properties:
* eans:
* type: array
* items:
* type: string
* example:
* eans: ["4001234567890"]
* responses:
* 200:
* description: Updated article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid eans
* 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: Article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/article/:sku/eans`, async (req, res) => {
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
if (!Array.isArray(req.body?.eans)) {
return res.status(400).json({ error: 'eans is required' });
}
try {
const article = await db.setEansBySku(req.user.id, skuParsed.sku, req.body.eans);
res.status(200).json({ article });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set article eans failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/brand:
* put:
* tags: [Article]
* summary: Set or clear article brand by SKU
* description: |
* Sets the article's `brandId` (must belong to the article's company).
* Pass `null` to clear the brand. Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-markdown: article/set-brand.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [brandId]
* properties:
* brandId:
* type: integer
* nullable: true
* example:
* brandId: 3
* responses:
* 200:
* description: Updated article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Validation error
* 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: Article or brand not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/article/:sku/brand`, async (req, res) => {
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
const brandId = req.body?.brandId ?? null;
try {
const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId);
res.status(200).json({ article });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set article brand failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/weight:
* put:
* tags: [Article]
* summary: Set article weight by SKU
* description: |
* Sets the article's `weight`. Must be finite and `>= 0`.
* Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-markdown: article/set-weight.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [weight]
* properties:
* weight:
* type: number
* example:
* weight: 1.5
* responses:
* 200:
* description: Updated article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid weight
* 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: Article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/article/:sku/weight`, async (req, res) => {
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
const weight = req.body?.weight;
try {
const article = await db.setWeightBySku(req.user.id, skuParsed.sku, weight);
res.status(200).json({ article });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set article weight failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/categories:
* put:
* tags: [Article]
* summary: Replace article categories by SKU
* description: |
* Replace-all the article's category list. Each category must belong to
* the article's company; ids must be unique. Empty array removes the
* article from all categories. Returns the same shape as GET by SKU.
* security:
* - cookieAuth: []
* x-markdown: article/set-categories.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [categoryIds]
* properties:
* categoryIds:
* type: array
* items:
* type: integer
* example:
* categoryIds: [4, 7]
* responses:
* 200:
* description: Updated article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid categoryIds
* 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: Article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/article/:sku/categories`, async (req, res) => {
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
if (!Array.isArray(req.body?.categoryIds)) {
return res.status(400).json({ error: 'categoryIds is required' });
}
try {
const article = await db.setCategoryIdsBySku(
req.user.id,
skuParsed.sku,
req.body.categoryIds,
);
res.status(200).json({ article });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set article categories failed:', error);
res.status(status).json({ error: error.message });
}
});
}

239
src/api/routes/brand.js Normal file
View File

@@ -0,0 +1,239 @@
import * as db from '../../db/brand.js';
function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function brandErrorStatus(error) {
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Brand not found') return 404;
if (error.message === 'Invalid brand') return 400;
if (error.message === 'Brand in use') return 409;
return 500;
}
/**
* Registers brand routes for the authenticated user's selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /brand:
* get:
* tags: [Brand]
* summary: List brands
* description: Lists brands for the authenticated user's selected company, sorted by name.
* security:
* - cookieAuth: []
* x-markdown: brand/list.md
* responses:
* 200:
* description: Brand list
* content:
* application/json:
* schema:
* type: object
* properties:
* brands:
* type: array
* items:
* $ref: '#/components/schemas/Brand'
* 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'
* put:
* tags: [Brand]
* summary: Upsert brand
* description: |
* Insert or update brand by `name` for the selected company. `gsprpinfo` optional.
* security:
* - cookieAuth: []
* x-markdown: brand/upsert.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* gsprpinfo:
* type: string
* example:
* name: Acme
* gsprpinfo: 'Acme GmbH'
* responses:
* 200:
* description: Upserted brand
* content:
* application/json:
* schema:
* type: object
* properties:
* brand:
* $ref: '#/components/schemas/Brand'
* 400:
* description: Invalid brand or 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}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const brands = await db.list(req.user.id, selected.companyId);
res.status(200).json({ brands });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('List brands failed:', error);
res.status(status).json({ error: error.message });
}
});
app.put(`${basePath}/brand`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { name, gsprpinfo } = req.body ?? {};
try {
const brand = await db.upsert(req.user.id, selected.companyId, {
name,
gsprpinfo,
});
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Upsert brand failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /brand/{id}:
* delete:
* tags: [Brand]
* summary: Delete brand
* description: |
* Deletes the brand for the selected company. Fails with `Brand in use`
* if any article still references it.
* security:
* - cookieAuth: []
* x-markdown: brand/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 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: Brand not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Brand in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.delete(`${basePath}/brand/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
await db.remove(req.user.id, selected.companyId, id);
res.sendStatus(204);
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Delete brand failed:', error);
res.status(status).json({ error: error.message });
}
});
}

View File

@@ -13,6 +13,8 @@ function categoryErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Parent not found') return 404;
if (error.message === 'Cannot delete root category') return 400;
if (error.message === 'Name required') return 400;
if (error.message === 'Category not found') return 404;
return 500;
}
@@ -246,6 +248,74 @@ export function register(app, basePath = '') {
/**
* @openapi
* /category/{id}:
* patch:
* tags: [Category]
* summary: Rename category
* description: Renames a category the user is authorized for (updates name and re-slugifies in the company default language).
* security:
* - cookieAuth: []
* x-markdown: category/rename.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category id
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example:
* name: Renamed category
* responses:
* 200:
* description: Renamed category
* content:
* application/json:
* schema:
* type: object
* properties:
* category:
* type: object
* required: [id, name, slug]
* properties:
* id:
* type: integer
* name:
* type: string
* slug:
* type: string
* 400:
* description: Validation error (name required)
* 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: Category not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Category]
* summary: Delete category
@@ -300,4 +370,29 @@ export function register(app, basePath = '') {
res.status(status).json({ error: error.message });
}
});
app.patch(`${basePath}/category/:id`, async (req, res) => {
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
try {
const category = await db.rename({
googleAuthId: req.user.id,
id: parsed.id,
name,
});
res.status(200).json({ category });
} catch (error) {
const status = categoryErrorStatus(error);
if (status === 500) console.error('Rename category failed:', error);
res.status(status).json({ error: error.message });
}
});
}

View File

@@ -39,3 +39,23 @@ export async function setPricesBySku(googleAuthId, sku, prices) {
const store = await getStore();
return store.article.setPricesBySku(googleAuthId, sku, prices);
}
export async function setEansBySku(googleAuthId, sku, eans) {
const store = await getStore();
return store.article.setEansBySku(googleAuthId, sku, eans);
}
export async function setBrandIdBySku(googleAuthId, sku, brandId) {
const store = await getStore();
return store.article.setBrandIdBySku(googleAuthId, sku, brandId);
}
export async function setWeightBySku(googleAuthId, sku, weight) {
const store = await getStore();
return store.article.setWeightBySku(googleAuthId, sku, weight);
}
export async function setCategoryIdsBySku(googleAuthId, sku, categoryIds) {
const store = await getStore();
return store.article.setCategoryIdsBySku(googleAuthId, sku, categoryIds);
}

16
src/db/brand.js Normal file
View File

@@ -0,0 +1,16 @@
import { getStore } from './store.js';
export async function list(googleAuthId, companyId) {
const store = await getStore();
return store.brand.list(googleAuthId, companyId);
}
export async function upsert(googleAuthId, companyId, data) {
const store = await getStore();
return store.brand.upsert(googleAuthId, companyId, data);
}
export async function remove(googleAuthId, companyId, id) {
const store = await getStore();
return store.brand.delete(googleAuthId, companyId, id);
}

View File

@@ -19,3 +19,8 @@ export async function getCategoryArticles(googleAuthId, categoryId) {
const store = await getStore();
return store.category.getCategoryArticles(googleAuthId, categoryId);
}
export async function rename({ googleAuthId, id, name }) {
const store = await getStore();
return store.category.rename({ googleAuthId, id, name });
}

View File

@@ -14,6 +14,7 @@ const STORE_NAMESPACES = [
'article',
'pic',
'unit',
'brand',
'lang',
'tax',
'google_auth',