diff --git a/README.md b/README.md index 8b107da..6723492 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..07dcf41 --- /dev/null +++ b/TODO.md @@ -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. diff --git a/docs/api/README.md b/docs/api/README.md index 13b97ba..9f3778b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 00982f9..0504022 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -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": [ diff --git a/docs/api/routes/article/set-brand.md b/docs/api/routes/article/set-brand.md new file mode 100644 index 0000000..e8fe44b --- /dev/null +++ b/docs/api/routes/article/set-brand.md @@ -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`._ diff --git a/docs/api/routes/article/set-categories.md b/docs/api/routes/article/set-categories.md new file mode 100644 index 0000000..994a5c5 --- /dev/null +++ b/docs/api/routes/article/set-categories.md @@ -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`._ diff --git a/docs/api/routes/article/set-eans.md b/docs/api/routes/article/set-eans.md new file mode 100644 index 0000000..ba8e1b5 --- /dev/null +++ b/docs/api/routes/article/set-eans.md @@ -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`._ diff --git a/docs/api/routes/article/set-weight.md b/docs/api/routes/article/set-weight.md new file mode 100644 index 0000000..87cd13f --- /dev/null +++ b/docs/api/routes/article/set-weight.md @@ -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`._ diff --git a/docs/api/routes/brand/delete.md b/docs/api/routes/brand/delete.md new file mode 100644 index 0000000..aac6a28 --- /dev/null +++ b/docs/api/routes/brand/delete.md @@ -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`._ diff --git a/docs/api/routes/brand/list.md b/docs/api/routes/brand/list.md new file mode 100644 index 0000000..9dff3d8 --- /dev/null +++ b/docs/api/routes/brand/list.md @@ -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`._ diff --git a/docs/api/routes/brand/upsert.md b/docs/api/routes/brand/upsert.md new file mode 100644 index 0000000..0a02c21 --- /dev/null +++ b/docs/api/routes/brand/upsert.md @@ -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`._ diff --git a/docs/api/routes/category/rename.md b/docs/api/routes/category/rename.md new file mode 100644 index 0000000..b922fff --- /dev/null +++ b/docs/api/routes/category/rename.md @@ -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`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index f61c659..f22bc1d 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -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' }, ], }, diff --git a/src/api/openapi.js b/src/api/openapi.js index b3bb4ac..b2b22af 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -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] diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 262003c..d6bc96d 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -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 }); + } + }); } diff --git a/src/api/routes/brand.js b/src/api/routes/brand.js new file mode 100644 index 0000000..ba433d0 --- /dev/null +++ b/src/api/routes/brand.js @@ -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 }); + } + }); +} diff --git a/src/api/routes/category.js b/src/api/routes/category.js index e971504..d4ba549 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -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 }); + } + }); } diff --git a/src/db/article.js b/src/db/article.js index b82d003..a51b9f5 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -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); +} diff --git a/src/db/brand.js b/src/db/brand.js new file mode 100644 index 0000000..807d188 --- /dev/null +++ b/src/db/brand.js @@ -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); +} diff --git a/src/db/category.js b/src/db/category.js index f147c6e..6e9b9f4 100644 --- a/src/db/category.js +++ b/src/db/category.js @@ -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 }); +} diff --git a/src/db/store.js b/src/db/store.js index 45b91b4..77cacb8 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -14,6 +14,7 @@ const STORE_NAMESPACES = [ 'article', 'pic', 'unit', + 'brand', 'lang', 'tax', 'google_auth',