From 68b793c9bae3ed3e49f34d15a7585ebc4396d9dc Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 27 Sep 2026 21:01:20 +0200 Subject: [PATCH] u --- docs/api/README.md | 10 +- docs/api/openapi.json | 765 +++++++++++++++++++- docs/api/routes/article/:sku/track_stock.md | 49 ++ docs/api/routes/article/create.md | 3 +- docs/api/routes/warehouse/:id.put.md | 50 ++ docs/api/routes/warehouse/create.md | 45 ++ docs/api/routes/warehouse/delete.md | 33 + docs/api/routes/warehouse/get.md | 40 + docs/api/routes/warehouse/list.md | 35 + docs/api/routes/warehouse/order.md | 43 ++ docs/api/routes/warehouse/update.md | 50 ++ scripts/generateApiDocs.js | 1 + src/api/openapi.js | 32 +- src/api/routes/article.js | 114 ++- src/api/routes/warehouse.js | 434 ++++++++++- src/db/article.js | 9 +- src/db/warehouse.js | 5 + 17 files changed, 1695 insertions(+), 23 deletions(-) create mode 100644 docs/api/routes/article/:sku/track_stock.md create mode 100644 docs/api/routes/warehouse/:id.put.md create mode 100644 docs/api/routes/warehouse/create.md create mode 100644 docs/api/routes/warehouse/delete.md create mode 100644 docs/api/routes/warehouse/get.md create mode 100644 docs/api/routes/warehouse/list.md create mode 100644 docs/api/routes/warehouse/order.md create mode 100644 docs/api/routes/warehouse/update.md diff --git a/docs/api/README.md b/docs/api/README.md index 84e2cb0..9314a7f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -48,6 +48,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/article/supplier/order.md`](routes/article/supplier/order.md) | `PUT /article/{sku}/supplier/order` | | [`routes/article/supplier/delete.md`](routes/article/supplier/delete.md) | `DELETE /article/{sku}/supplier/{id}` | | [`routes/article/supplier/update.md`](routes/article/supplier/update.md) | `PATCH /article/{sku}/supplier/{id}` | +| [`routes/article/:sku/track_stock.md`](routes/article/:sku/track_stock.md) | `PUT /article/{sku}/track_stock` | | [`routes/article/set-weight.md`](routes/article/set-weight.md) | `PUT /article/{sku}/weight` | | [`routes/articleIndex.md`](routes/articleIndex.md) | `GET /articleIndex` | | [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` | @@ -145,6 +146,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/unit/delete.md`](routes/unit/delete.md) | `DELETE /unit` | | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | | [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` | +| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` | +| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` | +| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` | +| [`routes/warehouse/delete.md`](routes/warehouse/delete.md) | `DELETE /warehouse/{id}` | +| [`routes/warehouse/get.md`](routes/warehouse/get.md) | `GET /warehouse/{id}` | +| [`routes/warehouse/update.md`](routes/warehouse/update.md) | `PATCH /warehouse/{id}` | +| [`routes/warehouse/:id.put.md`](routes/warehouse/:id.put.md) | `PUT /warehouse/{id}` | Machine-readable spec: [`openapi.json`](openapi.json). @@ -154,7 +162,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | Signal | Emitted by | |--------|------------| -| `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/weight` | +| `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` | | `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 9d96d10..b16f1e5 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -92,12 +92,610 @@ "name": "Stock", "description": "Manual stock additions and removals" }, + { + "name": "Warehouse", + "description": "Company-scoped warehouses" + }, { "name": "Serper", "description": "Serper Google image search and thumbnail streaming" } ], "paths": { + "/warehouse": { + "get": { + "tags": [ + "Warehouse" + ], + "summary": "List warehouses", + "description": "Lists warehouses for the selected company, ordered by sort and name.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "warehouse/list.md", + "responses": { + "200": { + "description": "List of warehouses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Warehouse" + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "post": { + "tags": [ + "Warehouse" + ], + "summary": "Create warehouse", + "description": "Creates a new warehouse for the selected company.\nSort order will be automatically appended if omitted.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "warehouse/create.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarehouseCreateInput" + } + } + } + }, + "responses": { + "201": { + "description": "Created warehouse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouse": { + "$ref": "#/components/schemas/Warehouse" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or missing fields", + "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" + } + } + } + }, + "409": { + "description": "Warehouse already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/warehouse/order": { + "put": { + "tags": [ + "Warehouse" + ], + "summary": "Reorder warehouses", + "description": "Reorders warehouses for the selected company.\n`warehouseIds` must include all existing warehouse IDs exactly once.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "warehouse/order.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "warehouseIds" + ], + "properties": { + "warehouseIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Applied warehouse order", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouseIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid warehouse order 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" + } + } + } + } + } + } + }, + "/warehouse/{id}": { + "get": { + "tags": [ + "Warehouse" + ], + "summary": "Get warehouse by ID", + "description": "Retrieves a single warehouse for the authenticated user's selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Warehouse ID" + } + ], + "x-markdown": "warehouse/get.md", + "responses": { + "200": { + "description": "Warehouse details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouse": { + "$ref": "#/components/schemas/Warehouse" + } + } + } + } + } + }, + "400": { + "description": "Invalid ID 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": "Warehouse not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Warehouse" + ], + "summary": "Update warehouse (partial)", + "description": "Updates specified fields of an existing warehouse for the selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Warehouse ID" + } + ], + "x-markdown": "warehouse/update.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarehouseUpdateInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated warehouse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouse": { + "$ref": "#/components/schemas/Warehouse" + } + } + } + } + } + }, + "400": { + "description": "Invalid input", + "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": "Warehouse not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Warehouse name conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "tags": [ + "Warehouse" + ], + "summary": "Update warehouse", + "description": "Updates specified fields of an existing warehouse for the selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Warehouse ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarehouseUpdateInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated warehouse", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "warehouse": { + "$ref": "#/components/schemas/Warehouse" + } + } + } + } + } + }, + "400": { + "description": "Invalid input", + "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": "Warehouse not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Warehouse name conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Warehouse" + ], + "summary": "Delete warehouse", + "description": "Deletes a warehouse if not in use in the ledger.", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Warehouse ID" + } + ], + "x-markdown": "warehouse/delete.md", + "responses": { + "204": { + "description": "Warehouse deleted" + }, + "400": { + "description": "Invalid ID 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": "Warehouse not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Warehouse in use in ledger", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/unit": { "get": { "tags": [ @@ -8995,6 +9593,10 @@ }, "name": { "type": "string" + }, + "track_stock": { + "type": "boolean", + "description": "Whether to track inventory stock (defaults to true)" } }, "example": { @@ -9277,6 +9879,10 @@ "unitSi": { "type": "string", "description": "Existing company unit `si`" + }, + "track_stock": { + "type": "boolean", + "description": "Whether to track inventory stock" } }, "example": { @@ -10277,6 +10883,113 @@ } } }, + "/article/{sku}/track_stock": { + "put": { + "tags": [ + "Article" + ], + "summary": "Set article track_stock flag by SKU", + "description": "Enables or disables inventory stock tracking for this article.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-signals": [ + "article-" + ], + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + }, + "description": "Article SKU" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "track_stock" + ], + "properties": { + "track_stock": { + "type": "boolean", + "description": "Whether to track inventory stock for this article" + } + }, + "example": { + "track_stock": true + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated article detail", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "article": { + "$ref": "#/components/schemas/ArticleDetail" + } + } + } + } + } + }, + "400": { + "description": "Invalid track_stock", + "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": [ @@ -10889,12 +11602,15 @@ }, "sku": { "type": "string" + }, + "track_stock": { + "type": "boolean" } } }, "ArticleDetail": { "type": "object", - "description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, …)", + "description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, track_stock, …)", "additionalProperties": true }, "ArticleHistoryEntry": { @@ -12116,6 +12832,53 @@ } } }, + "Warehouse": { + "type": "object", + "required": [ + "id", + "name", + "sort" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "sort": { + "type": "integer", + "description": "Sort order" + } + } + }, + "WarehouseCreateInput": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "sort": { + "type": "integer", + "description": "Optional sort order (defaults to auto-increment)" + } + } + }, + "WarehouseUpdateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sort": { + "type": "integer", + "description": "Sort order" + } + } + }, "StockAdjustmentInput": { "type": "object", "properties": { diff --git a/docs/api/routes/article/:sku/track_stock.md b/docs/api/routes/article/:sku/track_stock.md new file mode 100644 index 0000000..1c3e9b6 --- /dev/null +++ b/docs/api/routes/article/:sku/track_stock.md @@ -0,0 +1,49 @@ +# `PUT /article/{sku}/track_stock` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Set article track_stock flag by SKU + +Enables or disables inventory stock tracking for this article. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | Article SKU | + +## Body (JSON) + +```json +{ + "track_stock": true +} +``` + +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + +## Response `200` + +Updated article detail + +```json +{ + "article": {} +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid track_stock — `{"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/create.md b/docs/api/routes/article/create.md index 8296c40..f4f352b 100644 --- a/docs/api/routes/article/create.md +++ b/docs/api/routes/article/create.md @@ -29,7 +29,8 @@ Created "id": 1, "parentId": 1, "name": "…", - "sku": "…" + "sku": "…", + "track_stock": true } } ``` diff --git a/docs/api/routes/warehouse/:id.put.md b/docs/api/routes/warehouse/:id.put.md new file mode 100644 index 0000000..3302311 --- /dev/null +++ b/docs/api/routes/warehouse/:id.put.md @@ -0,0 +1,50 @@ +# `PUT /warehouse/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update warehouse + +Updates specified fields of an existing warehouse for the selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Warehouse ID | + +## Body (JSON) + +```json +{ + "name": "…", + "sort": 1 +} +``` + +## Response `200` + +Updated warehouse + +```json +{ + "warehouse": { + "id": 1, + "name": "…", + "sort": 1 + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid input — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Warehouse not found — `{"error":"…"}` | +| 409 | Warehouse name conflict — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/create.md b/docs/api/routes/warehouse/create.md new file mode 100644 index 0000000..d0af428 --- /dev/null +++ b/docs/api/routes/warehouse/create.md @@ -0,0 +1,45 @@ +# `POST /warehouse` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create warehouse + +Creates a new warehouse for the selected company. +Sort order will be automatically appended if omitted. + + +## Body (JSON) + +```json +{ + "name": "…", + "sort": 1 +} +``` + +## Response `201` + +Created warehouse + +```json +{ + "warehouse": { + "id": 1, + "name": "…", + "sort": 1 + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid input or missing fields — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 409 | Warehouse already exists — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/delete.md b/docs/api/routes/warehouse/delete.md new file mode 100644 index 0000000..725af0b --- /dev/null +++ b/docs/api/routes/warehouse/delete.md @@ -0,0 +1,33 @@ +# `DELETE /warehouse/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete warehouse + +Deletes a warehouse if not in use in the ledger. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Warehouse ID | + +## Response `204` + +Warehouse deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid ID or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Warehouse not found — `{"error":"…"}` | +| 409 | Warehouse in use in ledger — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/get.md b/docs/api/routes/warehouse/get.md new file mode 100644 index 0000000..10e19b9 --- /dev/null +++ b/docs/api/routes/warehouse/get.md @@ -0,0 +1,40 @@ +# `GET /warehouse/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get warehouse by ID + +Retrieves a single warehouse for the authenticated user's selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Warehouse ID | + +## Response `200` + +Warehouse details + +```json +{ + "warehouse": { + "id": 1, + "name": "…", + "sort": 1 + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid ID or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Warehouse not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/list.md b/docs/api/routes/warehouse/list.md new file mode 100644 index 0000000..5bdc755 --- /dev/null +++ b/docs/api/routes/warehouse/list.md @@ -0,0 +1,35 @@ +# `GET /warehouse` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List warehouses + +Lists warehouses for the selected company, ordered by sort and name. + +## Response `200` + +List of warehouses + +```json +{ + "warehouses": [ + { + "id": 1, + "name": "…", + "sort": 1 + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/order.md b/docs/api/routes/warehouse/order.md new file mode 100644 index 0000000..64b8438 --- /dev/null +++ b/docs/api/routes/warehouse/order.md @@ -0,0 +1,43 @@ +# `PUT /warehouse/order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Reorder warehouses + +Reorders warehouses for the selected company. +`warehouseIds` must include all existing warehouse IDs exactly once. + + +## Body (JSON) + +```json +{ + "warehouseIds": [ + 1 + ] +} +``` + +## Response `200` + +Applied warehouse order + +```json +{ + "warehouseIds": [ + 1 + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid warehouse order or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/warehouse/update.md b/docs/api/routes/warehouse/update.md new file mode 100644 index 0000000..b740896 --- /dev/null +++ b/docs/api/routes/warehouse/update.md @@ -0,0 +1,50 @@ +# `PATCH /warehouse/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update warehouse (partial) + +Updates specified fields of an existing warehouse for the selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Warehouse ID | + +## Body (JSON) + +```json +{ + "name": "…", + "sort": 1 +} +``` + +## Response `200` + +Updated warehouse + +```json +{ + "warehouse": { + "id": 1, + "name": "…", + "sort": 1 + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid input — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Warehouse not found — `{"error":"…"}` | +| 409 | Warehouse name conflict — `{"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 b71bf3c..fd7a781 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -48,6 +48,7 @@ const options = { { name: 'Printer', description: 'Company-scoped receipt and label printers' }, { name: 'Ledger', description: 'Append-only ledger transactions' }, { name: 'Stock', description: 'Manual stock additions and removals' }, + { name: 'Warehouse', description: 'Company-scoped warehouses' }, { name: 'Serper', description: 'Serper Google image search and thumbnail streaming' }, ], }, diff --git a/src/api/openapi.js b/src/api/openapi.js index 974a0b4..6ac13bd 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -93,9 +93,11 @@ * type: string * sku: * type: string + * track_stock: + * type: boolean * ArticleDetail: * type: object - * description: Full article payload (unit, brand, categoryIds, photos, eans, prices, …) + * description: Full article payload (unit, brand, categoryIds, photos, eans, prices, track_stock, …) * additionalProperties: true * ArticleHistoryEntry: * type: object @@ -822,6 +824,34 @@ * desc: * type: string * nullable: true + * Warehouse: + * type: object + * required: [id, name, sort] + * properties: + * id: + * type: integer + * name: + * type: string + * sort: + * type: integer + * description: Sort order + * WarehouseCreateInput: + * type: object + * required: [name] + * properties: + * name: + * type: string + * sort: + * type: integer + * description: Optional sort order (defaults to auto-increment) + * WarehouseUpdateInput: + * type: object + * properties: + * name: + * type: string + * sort: + * type: integer + * description: Sort order * StockAdjustmentInput: * type: object * properties: diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 8900406..c16e08d 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -61,6 +61,7 @@ function articleErrorStatus(error) { if (error.message === 'Company not found') return 404; if (error.message === 'Brand not found') return 404; if (error.message === 'Invalid weight') return 400; + if (error.message === 'Invalid track_stock') return 400; if (error.message === 'Invalid categoryIds') return 400; if (error.message?.includes('already exists') || error.code === 'SQLITE_CONSTRAINT_UNIQUE') return 409; return 500; @@ -96,6 +97,9 @@ export function register(app, basePath = '') { * description: Category id * name: * type: string + * track_stock: + * type: boolean + * description: Whether to track inventory stock (defaults to true) * example: * parentId: 10 * name: New article @@ -151,11 +155,18 @@ export function register(app, basePath = '') { return res.status(400).json({ error: 'name is required' }); } + const rawTrack = req.body?.track_stock ?? req.body?.trackStock; + if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) { + return res.status(400).json({ error: 'Invalid track_stock' }); + } + const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true; + try { const article = await db.createUnderParent({ googleAuthId: req.user.id, parentId: parent.id, name, + track_stock, }); emitArticlesSignal(req); res.status(201).json({ article }); @@ -353,6 +364,9 @@ export function register(app, basePath = '') { * unitSi: * type: string * description: Existing company unit `si` + * track_stock: + * type: boolean + * description: Whether to track inventory stock * example: * unitamount: 0.24 * unitSi: l @@ -550,9 +564,14 @@ export function register(app, basePath = '') { if (Object.prototype.hasOwnProperty.call(body, 'unitSi')) { patch.unitSi = body.unitSi; } + if (Object.prototype.hasOwnProperty.call(body, 'track_stock')) { + patch.track_stock = body.track_stock; + } else if (Object.prototype.hasOwnProperty.call(body, 'trackStock')) { + patch.track_stock = body.trackStock; + } - if (!('name' in patch) && !('unitamount' in patch) && !('unitSi' in patch)) { - return res.status(400).json({ error: 'name, unitamount, or unitSi is required' }); + if (!('name' in patch) && !('unitamount' in patch) && !('unitSi' in patch) && !('track_stock' in patch)) { + return res.status(400).json({ error: 'name, unitamount, unitSi, or track_stock is required' }); } try { @@ -1218,6 +1237,97 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /article/{sku}/track_stock: + * put: + * tags: [Article] + * summary: Set article track_stock flag by SKU + * description: | + * Enables or disables inventory stock tracking for this article. + * security: + * - cookieAuth: [] + * x-signals: [article-] + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [track_stock] + * properties: + * track_stock: + * type: boolean + * description: Whether to track inventory stock for this article + * example: + * track_stock: true + * responses: + * 200: + * description: Updated article detail + * content: + * application/json: + * schema: + * type: object + * properties: + * article: + * $ref: '#/components/schemas/ArticleDetail' + * 400: + * description: Invalid track_stock + * 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' + */ + async function handleSetTrackStock(req, res) { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const raw = req.body?.track_stock ?? req.body?.trackStock; + if (raw === undefined || (typeof raw !== 'boolean' && raw !== 0 && raw !== 1)) { + return res.status(400).json({ error: 'Invalid track_stock' }); + } + + try { + const article = await db.setTrackStockBySku(req.user.id, skuParsed.sku, Boolean(raw)); + emitArticleSignal(req, skuParsed.sku); + res.status(200).json({ article }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Set article track_stock failed:', error); + res.status(status).json({ error: error.message }); + } + } + + app.put(`${basePath}/article/:sku/track_stock`, handleSetTrackStock); + app.put(`${basePath}/article/:sku/track-stock`, handleSetTrackStock); + /** * @openapi * /article/{sku}/categories: diff --git a/src/api/routes/warehouse.js b/src/api/routes/warehouse.js index fc7ada3..971e6cd 100644 --- a/src/api/routes/warehouse.js +++ b/src/api/routes/warehouse.js @@ -9,6 +9,14 @@ function requireSelectedCompany(req) { return { companyId }; } +function parsePositiveInt(val, field = 'id') { + const id = Number(val); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + function warehouseErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; @@ -17,6 +25,8 @@ function warehouseErrorStatus(error) { if (error.message === 'Name required') return 400; if (error.message === 'Warehouse already exists') return 409; if (error.message === 'Warehouse in use in ledger') return 409; + if (error.message === 'Invalid warehouse order') return 400; + if (error.message === 'Invalid sort') return 400; return 500; } @@ -26,6 +36,47 @@ function warehouseErrorStatus(error) { * @param {string} [basePath=''] */ export function register(app, basePath = '') { + /** + * @openapi + * /warehouse: + * get: + * tags: [Warehouse] + * summary: List warehouses + * description: Lists warehouses for the selected company, ordered by sort and name. + * security: + * - cookieAuth: [] + * x-markdown: warehouse/list.md + * responses: + * 200: + * description: List of warehouses + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouses: + * type: array + * items: + * $ref: '#/components/schemas/Warehouse' + * 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' + */ app.get(`${basePath}/warehouse`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { @@ -42,19 +93,145 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /warehouse/order: + * put: + * tags: [Warehouse] + * summary: Reorder warehouses + * description: | + * Reorders warehouses for the selected company. + * `warehouseIds` must include all existing warehouse IDs exactly once. + * security: + * - cookieAuth: [] + * x-markdown: warehouse/order.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [warehouseIds] + * properties: + * warehouseIds: + * type: array + * items: + * type: integer + * responses: + * 200: + * description: Applied warehouse order + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouseIds: + * type: array + * items: + * type: integer + * 400: + * description: Invalid warehouse order 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' + */ + app.put(`${basePath}/warehouse/order`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const { warehouseIds } = req.body ?? {}; + + try { + const result = await db.order(req.user.id, { warehouseIds }); + emit('warehouse', { companyId: selected.companyId }); + res.status(200).json(result); + } catch (error) { + const status = warehouseErrorStatus(error); + if (status === 500) console.error('Order warehouses failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /warehouse/{id}: + * get: + * tags: [Warehouse] + * summary: Get warehouse by ID + * description: Retrieves a single warehouse for the authenticated user's selected company. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Warehouse ID + * x-markdown: warehouse/get.md + * responses: + * 200: + * description: Warehouse details + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouse: + * $ref: '#/components/schemas/Warehouse' + * 400: + * description: Invalid ID 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: Warehouse not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.get(`${basePath}/warehouse/: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' }); + const parsedId = parsePositiveInt(req.params.id, 'id'); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); } try { - const warehouse = await db.getById(req.user.id, id); + const warehouse = await db.getById(req.user.id, parsedId.id); if (!warehouse) { return res.status(404).json({ error: 'Warehouse not found' }); } @@ -66,16 +243,69 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /warehouse: + * post: + * tags: [Warehouse] + * summary: Create warehouse + * description: | + * Creates a new warehouse for the selected company. + * Sort order will be automatically appended if omitted. + * security: + * - cookieAuth: [] + * x-markdown: warehouse/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WarehouseCreateInput' + * responses: + * 201: + * description: Created warehouse + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouse: + * $ref: '#/components/schemas/Warehouse' + * 400: + * description: Invalid input or missing fields + * 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' + * 409: + * description: Warehouse already exists + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.post(`${basePath}/warehouse`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } - const { name } = req.body ?? {}; + const { name, sort } = req.body ?? {}; try { - const warehouse = await db.create(req.user.id, { name }); + const warehouse = await db.create(req.user.id, { name, sort }); emit('warehouse', { companyId: selected.companyId }); res.status(201).json({ warehouse }); } catch (error) { @@ -91,15 +321,15 @@ export function register(app, basePath = '') { 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' }); + const parsedId = parsePositiveInt(req.params.id, 'id'); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); } - const { name } = req.body ?? {}; + const { name, sort } = req.body ?? {}; try { - const warehouse = await db.update(req.user.id, id, { name }); + const warehouse = await db.update(req.user.id, parsedId.id, { name, sort }); emit('warehouse', { companyId: selected.companyId }); res.status(200).json({ warehouse }); } catch (error) { @@ -109,22 +339,196 @@ export function register(app, basePath = '') { } } + /** + * @openapi + * /warehouse/{id}: + * patch: + * tags: [Warehouse] + * summary: Update warehouse (partial) + * description: Updates specified fields of an existing warehouse for the selected company. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Warehouse ID + * x-markdown: warehouse/update.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WarehouseUpdateInput' + * responses: + * 200: + * description: Updated warehouse + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouse: + * $ref: '#/components/schemas/Warehouse' + * 400: + * description: Invalid input + * 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: Warehouse not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Warehouse name conflict + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * put: + * tags: [Warehouse] + * summary: Update warehouse + * description: Updates specified fields of an existing warehouse for the selected company. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Warehouse ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WarehouseUpdateInput' + * responses: + * 200: + * description: Updated warehouse + * content: + * application/json: + * schema: + * type: object + * properties: + * warehouse: + * $ref: '#/components/schemas/Warehouse' + * 400: + * description: Invalid input + * 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: Warehouse not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Warehouse name conflict + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.patch(`${basePath}/warehouse/:id`, handleUpdate); app.put(`${basePath}/warehouse/:id`, handleUpdate); + /** + * @openapi + * /warehouse/{id}: + * delete: + * tags: [Warehouse] + * summary: Delete warehouse + * description: Deletes a warehouse if not in use in the ledger. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Warehouse ID + * x-markdown: warehouse/delete.md + * responses: + * 204: + * description: Warehouse deleted + * 400: + * description: Invalid ID 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: Warehouse not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Warehouse in use in ledger + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ app.delete(`${basePath}/warehouse/: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' }); + const parsedId = parsePositiveInt(req.params.id, 'id'); + if (parsedId.error) { + return res.status(400).json({ error: parsedId.error }); } try { - await db.remove(req.user.id, id); + await db.remove(req.user.id, parsedId.id); emit('warehouse', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { diff --git a/src/db/article.js b/src/db/article.js index a82b5be..32f669c 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -1,8 +1,8 @@ import { getStore } from './store.js'; -export async function createUnderParent({ googleAuthId, parentId, name }) { +export async function createUnderParent({ googleAuthId, parentId, name, track_stock }) { const store = await getStore(); - return store.article.createUnderParent({ googleAuthId, parentId, name }); + return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock }); } export async function getBySku(googleAuthId, sku) { @@ -60,6 +60,11 @@ export async function setWeightBySku(googleAuthId, sku, weight) { return store.article.setWeightBySku(googleAuthId, sku, weight); } +export async function setTrackStockBySku(googleAuthId, sku, trackStock) { + const store = await getStore(); + return store.article.setTrackStockBySku(googleAuthId, sku, trackStock); +} + export async function setCategoryIdsBySku(googleAuthId, sku, categoryIds) { const store = await getStore(); return store.article.setCategoryIdsBySku(googleAuthId, sku, categoryIds); diff --git a/src/db/warehouse.js b/src/db/warehouse.js index 1fbde1e..3b08736 100644 --- a/src/db/warehouse.js +++ b/src/db/warehouse.js @@ -20,6 +20,11 @@ export async function update(googleAuthId, id, data) { return store.warehouse.update(googleAuthId, id, data); } +export async function order(googleAuthId, data) { + const store = await getStore(); + return store.warehouse.order(googleAuthId, data); +} + export async function remove(googleAuthId, id) { const store = await getStore(); return store.warehouse.delete(googleAuthId, id);