From 782c49a6807d9546ca068385d84d124d897d8493 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 09:54:56 +0200 Subject: [PATCH] Add article history by SKU and SSE update signals for live clients. Expose getHistoryBySku as GET /article/:sku/history, and document mutation-emitted company signals via GET /events. --- docs/api/README.md | 12 ++ docs/api/openapi.json | 226 ++++++++++++++++++++++ docs/api/routes/article/add-image.md | 4 + docs/api/routes/article/create.md | 4 + docs/api/routes/article/delete-image.md | 4 + docs/api/routes/article/delete.md | 4 + docs/api/routes/article/history.md | 49 +++++ docs/api/routes/article/order-images.md | 4 + docs/api/routes/article/set-brand.md | 4 + docs/api/routes/article/set-categories.md | 4 + docs/api/routes/article/set-eans.md | 4 + docs/api/routes/article/set-prices.md | 4 + docs/api/routes/article/set-weight.md | 4 + docs/api/routes/article/update.md | 4 + docs/api/routes/category/add-image.md | 4 + docs/api/routes/category/create.md | 4 + docs/api/routes/category/delete-image.md | 4 + docs/api/routes/category/delete.md | 4 + docs/api/routes/category/order.md | 4 + docs/api/routes/category/update.md | 4 + docs/api/routes/events.md | 53 +++++ scripts/generateApiDocs.js | 40 ++++ src/api/openapi.js | 11 ++ src/api/routes/article.js | 124 +++++++++++- src/api/routes/category.js | 20 ++ src/api/routes/events.js | 99 ++++++++++ src/api/sse.js | 52 +++++ src/db/article.js | 5 + 28 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 docs/api/routes/article/history.md create mode 100644 docs/api/routes/events.md create mode 100644 src/api/routes/events.js create mode 100644 src/api/sse.js diff --git a/docs/api/README.md b/docs/api/README.md index 162adac..ede72c9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -38,6 +38,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`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/history.md`](routes/article/history.md) | `GET /article/{sku}/history` | | [`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` | @@ -64,6 +65,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | | [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` | | [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` | +| [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | | [`routes/posterminal/list.md`](routes/posterminal/list.md) | `GET /posterminal` | @@ -80,6 +82,16 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route Machine-readable spec: [`openapi.json`](openapi.json). +## Signals (SSE) + +Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). After a successful mutation, the API emits a named SSE event (signal) to subscribers of the same selected company. + +| 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`, `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` | + ## DESIGN - for every route we have a file in /src/api/routes diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 9944e57..4620839 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -20,6 +20,10 @@ "name": "Health", "description": "Connectivity checks" }, + { + "name": "Events", + "description": "SSE update signals for live clients" + }, { "name": "Article", "description": "Articles under categories" @@ -1150,6 +1154,54 @@ } } }, + "/events": { + "get": { + "tags": [ + "Events" + ], + "summary": "Subscribe to update signals (SSE)", + "description": "Opens a Server-Sent Events stream for the authenticated user's\n**selected company**. Clients receive named signals when another\nrequest successfully mutates shared data for that company\n(including the same browser's other tabs).\n\nUse `EventSource` with credentials so the session cookie is sent:\n\n```js\nconst es = new EventSource('https://…/api/events', { withCredentials: true });\nes.addEventListener('category', () => { refetchCategoryTree(); });\nes.addEventListener('articles', () => { refetchArticleIndex(); });\nes.addEventListener('article-42000045', () => { refetchArticle('42000045'); });\n```\n\nKeep-alive comments (`: keepalive`) are sent roughly every 25s.\nReconnect after changing the selected company (the stream is bound\nto the company at connect time).\n\n## Signals\n\n| Signal | Emitted after successful |\n|--------|--------------------------|\n| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` |\n| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` |\n| `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` |\n\nSignals are only sent when the mutation actually succeeds (2xx).\nFailed or no-op validation errors do not emit. The `article-`\nevent name uses the concrete SKU (e.g. `article-42000045`).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "events.md", + "responses": { + "200": { + "description": "SSE stream (`text/event-stream`). Each signal is an SSE event\nwhose name is the signal id and whose data is `{}`.\n", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "example": "event: category\ndata: {}\n\nevent: articles\ndata: {}\n\nevent: article-42000045\ndata: {}\n" + } + } + } + }, + "400": { + "description": "No company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/company": { "get": { "tags": [ @@ -1639,6 +1691,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/create.md", "requestBody": { "required": true, @@ -1814,6 +1869,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/update.md", "parameters": [ { @@ -1941,6 +1999,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/delete.md", "parameters": [ { @@ -2002,6 +2063,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/order.md", "requestBody": { "required": true, @@ -2112,6 +2176,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/add-image.md", "parameters": [ { @@ -2218,6 +2285,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "category" + ], "x-markdown": "category/delete-image.md", "parameters": [ { @@ -2558,6 +2628,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "articles" + ], "x-markdown": "article/create.md", "requestBody": { "required": true, @@ -2816,6 +2889,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "articles" + ], "x-markdown": "article/update.md", "parameters": [ { @@ -2924,6 +3000,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "articles" + ], "x-markdown": "article/delete.md", "parameters": [ { @@ -2983,6 +3062,109 @@ } } }, + "/article/{sku}/history": { + "get": { + "tags": [ + "Article" + ], + "summary": "Get article history by SKU", + "description": "Returns `articlehistory` rows for the article, newest first (`date` DESC, `id` DESC).\nAuth like GET by SKU.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "article/history.md", + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + }, + "description": "Article SKU" + } + ], + "responses": { + "200": { + "description": "Article history (newest first)", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "history" + ], + "properties": { + "history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArticleHistoryEntry" + } + } + }, + "example": { + "history": [ + { + "id": 42, + "date": "2026-08-05T07:15:00.000Z", + "text": "Name geändert von \"Tee\" auf \"Grüner Tee\"\nEinheit geändert von \"500g\" auf \"1kg\"" + }, + { + "id": 41, + "date": "2026-08-01T09:00:00.000Z", + "text": "Name geändert von \"\" auf \"Tee\"" + } + ] + } + } + } + } + }, + "400": { + "description": "Missing sku", + "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}/prices": { "put": { "tags": [ @@ -2995,6 +3177,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/set-prices.md", "parameters": [ { @@ -3129,6 +3314,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/add-image.md", "parameters": [ { @@ -3223,6 +3411,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/delete-image.md", "parameters": [ { @@ -3302,6 +3493,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/order-images.md", "parameters": [ { @@ -3415,6 +3609,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/set-eans.md", "parameters": [ { @@ -3523,6 +3720,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/set-brand.md", "parameters": [ { @@ -3627,6 +3827,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/set-weight.md", "parameters": [ { @@ -3730,6 +3933,9 @@ "cookieAuth": [] } ], + "x-signals": [ + "article-" + ], "x-markdown": "article/set-categories.md", "parameters": [ { @@ -4319,6 +4525,26 @@ "description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, …)", "additionalProperties": true }, + "ArticleHistoryEntry": { + "type": "object", + "required": [ + "id", + "date", + "text" + ], + "properties": { + "id": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "text": { + "type": "string" + } + } + }, "Brand": { "type": "object", "required": [ diff --git a/docs/api/routes/article/add-image.md b/docs/api/routes/article/add-image.md index 9f4fe97..38d8a2e 100644 --- a/docs/api/routes/article/add-image.md +++ b/docs/api/routes/article/add-image.md @@ -20,6 +20,10 @@ Auth like GET by SKU. Raw binary (`image/avif`). +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `201` Image stored and linked diff --git a/docs/api/routes/article/create.md b/docs/api/routes/article/create.md index 858d802..9f4dae6 100644 --- a/docs/api/routes/article/create.md +++ b/docs/api/routes/article/create.md @@ -15,6 +15,10 @@ Creates an article under an authorized category (`parentId`). } ``` +## Signals + +On success, emits SSE signal: `articles` (see [`GET /events`](../events.md)). + ## Response `201` Created diff --git a/docs/api/routes/article/delete-image.md b/docs/api/routes/article/delete-image.md index 2ba8502..8cbb39d 100644 --- a/docs/api/routes/article/delete-image.md +++ b/docs/api/routes/article/delete-image.md @@ -13,6 +13,10 @@ Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like | `sku` | yes | string | | | `fullHash` | yes | string | | +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `204` Deleted diff --git a/docs/api/routes/article/delete.md b/docs/api/routes/article/delete.md index 352fc0d..081848b 100644 --- a/docs/api/routes/article/delete.md +++ b/docs/api/routes/article/delete.md @@ -15,6 +15,10 @@ Auth like GET by SKU. |-------|----------|------|-------------| | `sku` | yes | string | Article SKU | +## Signals + +On success, emits SSE signal: `articles` (see [`GET /events`](../events.md)). + ## Response `204` Deleted diff --git a/docs/api/routes/article/history.md b/docs/api/routes/article/history.md new file mode 100644 index 0000000..cb421f2 --- /dev/null +++ b/docs/api/routes/article/history.md @@ -0,0 +1,49 @@ +# `GET /article/{sku}/history` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get article history by SKU + +Returns `articlehistory` rows for the article, newest first (`date` DESC, `id` DESC). +Auth like GET by SKU. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | Article SKU | + +## Response `200` + +Article history (newest first) + +```json +{ + "history": [ + { + "id": 42, + "date": "2026-08-05T07:15:00.000Z", + "text": "Name geändert von \"Tee\" auf \"Grüner Tee\"\nEinheit geändert von \"500g\" auf \"1kg\"" + }, + { + "id": 41, + "date": "2026-08-01T09:00:00.000Z", + "text": "Name geändert von \"\" auf \"Tee\"" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Missing sku — `{"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/order-images.md b/docs/api/routes/article/order-images.md index 6b7c5b6..c6445a8 100644 --- a/docs/api/routes/article/order-images.md +++ b/docs/api/routes/article/order-images.md @@ -25,6 +25,10 @@ Sets `articlepic.sort` to `0..n-1` in the given order. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` New order diff --git a/docs/api/routes/article/set-brand.md b/docs/api/routes/article/set-brand.md index e8fe44b..fa66108 100644 --- a/docs/api/routes/article/set-brand.md +++ b/docs/api/routes/article/set-brand.md @@ -22,6 +22,10 @@ Pass `null` to clear the brand. Returns the same shape as GET by SKU. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/article/set-categories.md b/docs/api/routes/article/set-categories.md index 994a5c5..e91eb11 100644 --- a/docs/api/routes/article/set-categories.md +++ b/docs/api/routes/article/set-categories.md @@ -26,6 +26,10 @@ article from all categories. Returns the same shape as GET by SKU. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/article/set-eans.md b/docs/api/routes/article/set-eans.md index ba8e1b5..c1e028a 100644 --- a/docs/api/routes/article/set-eans.md +++ b/docs/api/routes/article/set-eans.md @@ -24,6 +24,10 @@ empty array clears all EANs. Returns the same shape as GET by SKU. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/article/set-prices.md b/docs/api/routes/article/set-prices.md index f10249b..60ce9cb 100644 --- a/docs/api/routes/article/set-prices.md +++ b/docs/api/routes/article/set-prices.md @@ -33,6 +33,10 @@ Empty `prices` clears all POS prices. Returns the same shape as GET by SKU. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/article/set-weight.md b/docs/api/routes/article/set-weight.md index 87cd13f..2c1d35d 100644 --- a/docs/api/routes/article/set-weight.md +++ b/docs/api/routes/article/set-weight.md @@ -22,6 +22,10 @@ Returns the same shape as GET by SKU. } ``` +## Signals + +On success, emits SSE signal: `article-` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/article/update.md b/docs/api/routes/article/update.md index 112015a..132f5dd 100644 --- a/docs/api/routes/article/update.md +++ b/docs/api/routes/article/update.md @@ -23,6 +23,10 @@ Patches article fields. At least one of `name`, `unitamount`, `unitSi` required. } ``` +## Signals + +On success, emits SSE signal: `articles` (see [`GET /events`](../events.md)). + ## Response `200` Updated article detail diff --git a/docs/api/routes/category/add-image.md b/docs/api/routes/category/add-image.md index 1536c61..658f9ec 100644 --- a/docs/api/routes/category/add-image.md +++ b/docs/api/routes/category/add-image.md @@ -20,6 +20,10 @@ Auth like GET /category/tree — user must be authorized for the category's comp Raw binary (`image/avif`). +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `201` Image stored and linked diff --git a/docs/api/routes/category/create.md b/docs/api/routes/category/create.md index c01aec1..06da01a 100644 --- a/docs/api/routes/category/create.md +++ b/docs/api/routes/category/create.md @@ -15,6 +15,10 @@ Creates a category under an authorized parent. } ``` +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `201` Created diff --git a/docs/api/routes/category/delete-image.md b/docs/api/routes/category/delete-image.md index 995ba7d..cddf5ec 100644 --- a/docs/api/routes/category/delete-image.md +++ b/docs/api/routes/category/delete-image.md @@ -14,6 +14,10 @@ Auth like GET /category/tree — user must be authorized for the category's comp |-------|----------|------|-------------| | `id` | yes | integer | Category id | +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `200` Image removed diff --git a/docs/api/routes/category/delete.md b/docs/api/routes/category/delete.md index 91e3a9b..8667d3f 100644 --- a/docs/api/routes/category/delete.md +++ b/docs/api/routes/category/delete.md @@ -12,6 +12,10 @@ Deletes a category the user is authorized for (not the company root). |-------|----------|------|-------------| | `id` | yes | integer | Category id | +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `204` Deleted diff --git a/docs/api/routes/category/order.md b/docs/api/routes/category/order.md index 7d19f7b..fa1c63c 100644 --- a/docs/api/routes/category/order.md +++ b/docs/api/routes/category/order.md @@ -24,6 +24,10 @@ to its index `0..n-1` in one transaction. } ``` +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `200` New sibling order diff --git a/docs/api/routes/category/update.md b/docs/api/routes/category/update.md index 29dd95a..70e7588 100644 --- a/docs/api/routes/category/update.md +++ b/docs/api/routes/category/update.md @@ -24,6 +24,10 @@ Either `name` or `parentId` (or both) is required. } ``` +## Signals + +On success, emits SSE signal: `category` (see [`GET /events`](../events.md)). + ## Response `200` Updated category (renamed and/or moved) diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md new file mode 100644 index 0000000..e007ff8 --- /dev/null +++ b/docs/api/routes/events.md @@ -0,0 +1,53 @@ +# `GET /events` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Subscribe to update signals (SSE) + +Opens a Server-Sent Events stream for the authenticated user's +**selected company**. Clients receive named signals when another +request successfully mutates shared data for that company +(including the same browser's other tabs). + +Use `EventSource` with credentials so the session cookie is sent: + +```js +const es = new EventSource('https://…/api/events', { withCredentials: true }); +es.addEventListener('category', () => { refetchCategoryTree(); }); +es.addEventListener('articles', () => { refetchArticleIndex(); }); +es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); +``` + +Keep-alive comments (`: keepalive`) are sent roughly every 25s. +Reconnect after changing the selected company (the stream is bound +to the company at connect time). + +## Signals + +| Signal | Emitted after successful | +|--------|--------------------------| +| `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | +| `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | +| `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | + +Signals are only sent when the mutation actually succeeds (2xx). +Failed or no-op validation errors do not emit. The `article-` +event name uses the concrete SKU (e.g. `article-42000045`). + + +## Response `200` + +SSE stream (`text/event-stream`). Each signal is an SSE event +whose name is the signal id and whose data is `{}`. + + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"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 f22bc1d..843415c 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -30,6 +30,7 @@ const options = { tags: [ { name: 'Auth', description: 'Google SSO and session' }, { name: 'Health', description: 'Connectivity checks' }, + { name: 'Events', description: 'SSE update signals for live clients' }, { name: 'Article', description: 'Articles under categories' }, { name: 'Category', description: 'Category tree and membership' }, { name: 'Company', description: 'Company CRUD' }, @@ -211,6 +212,16 @@ function renderOperation(apiPath, method, operation) { if (bodyLines) lines.push(...bodyLines); } + const signals = operation['x-signals']; + if (Array.isArray(signals) && signals.length) { + lines.push('## Signals'); + lines.push(''); + lines.push( + `On success, emits SSE signal${signals.length === 1 ? '' : 's'}: ${signals.map((s) => `\`${s}\``).join(', ')} (see [\`GET /events\`](../events.md)).`, + ); + lines.push(''); + } + const responses = operation.responses || {}; const successCodes = Object.keys(responses) .filter((c) => c.startsWith('2')) @@ -305,6 +316,35 @@ function renderReadme(operations) { lines.push(''); lines.push('Machine-readable spec: [`openapi.json`](openapi.json).'); lines.push(''); + lines.push('## Signals (SSE)'); + lines.push(''); + lines.push( + 'Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). After a successful mutation, the API emits a named SSE event (signal) to subscribers of the same selected company.', + ); + lines.push(''); + lines.push('| Signal | Emitted by |'); + lines.push('|--------|------------|'); + + /** @type {Map} */ + const bySignal = new Map(); + for (const op of operations) { + const signals = spec.paths[op.path]?.[op.method]?.['x-signals']; + if (!Array.isArray(signals)) continue; + const label = `\`${op.method.toUpperCase()} ${op.path}\``; + for (const signal of signals) { + if (!bySignal.has(signal)) bySignal.set(signal, []); + bySignal.get(signal).push(label); + } + } + const signalNames = [...bySignal.keys()].sort(); + if (signalNames.length === 0) { + lines.push('| — | _(none yet)_ |'); + } else { + for (const signal of signalNames) { + lines.push(`| \`${signal}\` | ${bySignal.get(signal).join(', ')} |`); + } + } + lines.push(''); lines.push('## DESIGN'); lines.push(''); lines.push('- for every route we have a file in /src/api/routes'); diff --git a/src/api/openapi.js b/src/api/openapi.js index b2b22af..fad562a 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 + * ArticleHistoryEntry: + * type: object + * required: [id, date, text] + * properties: + * id: + * type: integer + * date: + * type: string + * format: date-time + * text: + * type: string * Brand: * type: object * required: [id, name] diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 37ab2e9..3be7c8d 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -2,11 +2,26 @@ import express from 'express'; import JSZip from 'jszip'; import * as db from '../../db/article.js'; import * as picDb from '../../db/pic.js'; +import { emit } from '../sse.js'; import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js'; const AVIF_BODY_LIMIT = '20mb'; const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/; +/** Notify SSE subscribers after create / rename-ish list-level article changes. */ +function emitArticlesSignal(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit('articles', { companyId }); +} + +/** Notify SSE subscribers of a single-article detail change. */ +function emitArticleSignal(req, sku) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit(`article-${sku}`, { companyId }); +} + function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { @@ -64,6 +79,7 @@ export function register(app, basePath = '') { * description: Creates an article under an authorized category (`parentId`). * security: * - cookieAuth: [] + * x-signals: [articles] * x-markdown: article/create.md * requestBody: * required: true @@ -133,6 +149,7 @@ export function register(app, basePath = '') { parentId: parent.id, name, }); + emitArticlesSignal(req); res.status(201).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -304,6 +321,7 @@ export function register(app, basePath = '') { * `unitSi` must already exist for the company. Returns the same detail shape as GET. * security: * - cookieAuth: [] + * x-signals: [articles] * x-markdown: article/update.md * parameters: * - in: path @@ -373,6 +391,7 @@ export function register(app, basePath = '') { * Auth like GET by SKU. * security: * - cookieAuth: [] + * x-signals: [articles] * x-markdown: article/delete.md * parameters: * - in: path @@ -425,6 +444,87 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /article/{sku}/history: + * get: + * tags: [Article] + * summary: Get article history by SKU + * description: | + * Returns `articlehistory` rows for the article, newest first (`date` DESC, `id` DESC). + * Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-markdown: article/history.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * responses: + * 200: + * description: Article history (newest first) + * content: + * application/json: + * schema: + * type: object + * required: [history] + * properties: + * history: + * type: array + * items: + * $ref: '#/components/schemas/ArticleHistoryEntry' + * example: + * history: + * - id: 42 + * date: '2026-08-05T07:15:00.000Z' + * text: "Name geändert von \"Tee\" auf \"Grüner Tee\"\nEinheit geändert von \"500g\" auf \"1kg\"" + * - id: 41 + * date: '2026-08-01T09:00:00.000Z' + * text: 'Name geändert von "" auf "Tee"' + * 400: + * description: Missing sku + * 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.get(`${basePath}/article/:sku/history`, async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku is required' }); + } + + try { + const history = await db.getHistoryBySku(req.user.id, decodeURIComponent(sku)); + res.status(200).json({ history }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Get article history by sku failed:', error); + res.status(status).json({ error: error.message }); + } + }); + app.patch(`${basePath}/article/:sku`, async (req, res) => { const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; if (!sku) { @@ -448,7 +548,9 @@ export function register(app, basePath = '') { } try { - const article = await db.updateBySku(req.user.id, decodeURIComponent(sku), patch); + const decodedSku = decodeURIComponent(sku); + const article = await db.updateBySku(req.user.id, decodedSku, patch); + emitArticlesSignal(req); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -465,6 +567,7 @@ export function register(app, basePath = '') { try { await db.remove(req.user.id, parsed.sku); + emitArticlesSignal(req); res.sendStatus(204); } catch (error) { const status = articleErrorStatus(error); @@ -485,6 +588,7 @@ export function register(app, basePath = '') { * Empty `prices` clears all POS prices. Returns the same shape as GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/set-prices.md * parameters: * - in: path @@ -569,6 +673,7 @@ export function register(app, basePath = '') { try { const article = await db.setPricesBySku(req.user.id, skuParsed.sku, req.body.prices); + emitArticleSignal(req, skuParsed.sku); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -590,6 +695,7 @@ export function register(app, basePath = '') { * Auth like GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/add-image.md * parameters: * - in: path @@ -655,7 +761,9 @@ export function register(app, basePath = '') { } try { - const image = await db.addImage(req.user.id, decodeURIComponent(sku), imageBuffer); + const decodedSku = decodeURIComponent(sku); + const image = await db.addImage(req.user.id, decodedSku, imageBuffer); + emitArticleSignal(req, decodedSku); res.status(201).json({ image }); } catch (error) { const status = articleErrorStatus(error); @@ -674,6 +782,7 @@ export function register(app, basePath = '') { * description: Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/delete-image.md * parameters: * - in: path @@ -727,6 +836,7 @@ export function register(app, basePath = '') { try { await db.deleteImage(req.user.id, skuParsed.sku, hashParsed.fullHash); + emitArticleSignal(req, skuParsed.sku); res.sendStatus(204); } catch (error) { const status = articleErrorStatus(error); @@ -746,6 +856,7 @@ export function register(app, basePath = '') { * `fullHashes` must list every image of the article exactly once. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/order-images.md * parameters: * - in: path @@ -829,6 +940,7 @@ export function register(app, basePath = '') { try { const result = await db.orderImages(req.user.id, skuParsed.sku, { fullHashes }); + emitArticleSignal(req, skuParsed.sku); res.status(200).json(result); } catch (error) { const status = articleErrorStatus(error); @@ -848,6 +960,7 @@ export function register(app, basePath = '') { * empty array clears all EANs. Returns the same shape as GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/set-eans.md * parameters: * - in: path @@ -916,6 +1029,7 @@ export function register(app, basePath = '') { try { const article = await db.setEansBySku(req.user.id, skuParsed.sku, req.body.eans); + emitArticleSignal(req, skuParsed.sku); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -935,6 +1049,7 @@ export function register(app, basePath = '') { * Pass `null` to clear the brand. Returns the same shape as GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/set-brand.md * parameters: * - in: path @@ -1000,6 +1115,7 @@ export function register(app, basePath = '') { try { const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId); + emitArticleSignal(req, skuParsed.sku); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -1019,6 +1135,7 @@ export function register(app, basePath = '') { * Returns the same shape as GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/set-weight.md * parameters: * - in: path @@ -1083,6 +1200,7 @@ export function register(app, basePath = '') { try { const article = await db.setWeightBySku(req.user.id, skuParsed.sku, weight); + emitArticleSignal(req, skuParsed.sku); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); @@ -1103,6 +1221,7 @@ export function register(app, basePath = '') { * article from all categories. Returns the same shape as GET by SKU. * security: * - cookieAuth: [] + * x-signals: [article-] * x-markdown: article/set-categories.md * parameters: * - in: path @@ -1175,6 +1294,7 @@ export function register(app, basePath = '') { skuParsed.sku, req.body.categoryIds, ); + emitArticleSignal(req, skuParsed.sku); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 2c72a4c..00cb7d1 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -2,10 +2,18 @@ import express from 'express'; import JSZip from 'jszip'; import * as db from '../../db/category.js'; import * as picDb from '../../db/pic.js'; +import { emit } from '../sse.js'; import { buildKategorienCsv, toWindows1252 } from '../../util/kategorienCsv.js'; const AVIF_BODY_LIMIT = '20mb'; +/** Notify SSE subscribers of the actor's selected company after a real category change. */ +function emitCategorySignal(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit('category', { companyId }); +} + function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { @@ -198,6 +206,7 @@ export function register(app, basePath = '') { * description: Creates a category under an authorized parent. * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/create.md * requestBody: * required: true @@ -266,6 +275,7 @@ export function register(app, basePath = '') { parentId: parent.id, name, }); + emitCategorySignal(req); res.status(201).json({ category }); } catch (error) { const status = categoryErrorStatus(error); @@ -352,6 +362,7 @@ export function register(app, basePath = '') { * Either `name` or `parentId` (or both) is required. * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/update.md * parameters: * - in: path @@ -426,6 +437,7 @@ export function register(app, basePath = '') { * description: Deletes a category the user is authorized for (not the company root). * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/delete.md * parameters: * - in: path @@ -467,6 +479,7 @@ export function register(app, basePath = '') { googleAuthId: req.user.id, id: parsed.id, }); + emitCategorySignal(req); res.sendStatus(204); } catch (error) { const status = categoryErrorStatus(error); @@ -517,6 +530,7 @@ export function register(app, basePath = '') { } } + emitCategorySignal(req); res.status(200).json({ category }); } catch (error) { const status = categoryErrorStatus(error); @@ -538,6 +552,7 @@ export function register(app, basePath = '') { * to its index `0..n-1` in one transaction. * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/order.md * requestBody: * required: true @@ -615,6 +630,7 @@ export function register(app, basePath = '') { try { const result = await db.order({ googleAuthId: req.user.id, parentId, categoryIds }); + emitCategorySignal(req); res.status(200).json(result); } catch (error) { const status = categoryErrorStatus(error); @@ -636,6 +652,7 @@ export function register(app, basePath = '') { * Auth like GET /category/tree — user must be authorized for the category's company. * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/add-image.md * parameters: * - in: path @@ -702,6 +719,7 @@ export function register(app, basePath = '') { * Auth like GET /category/tree — user must be authorized for the category's company. * security: * - cookieAuth: [] + * x-signals: [category] * x-markdown: category/delete-image.md * parameters: * - in: path @@ -767,6 +785,7 @@ export function register(app, basePath = '') { try { const category = await db.addImage(req.user.id, parsed.id, imageBuffer); + emitCategorySignal(req); res.status(201).json({ category }); } catch (error) { const status = categoryErrorStatus(error); @@ -784,6 +803,7 @@ export function register(app, basePath = '') { try { const category = await db.deleteImage(req.user.id, parsed.id); + emitCategorySignal(req); res.status(200).json({ category }); } catch (error) { const status = categoryErrorStatus(error); diff --git a/src/api/routes/events.js b/src/api/routes/events.js new file mode 100644 index 0000000..607b685 --- /dev/null +++ b/src/api/routes/events.js @@ -0,0 +1,99 @@ +import { subscribe } from '../sse.js'; + +/** + * Registers the SSE update-events stream (requires auth). + * @param {import('express').Application} app - Express app + * @param {string} [basePath=''] - Optional URL prefix for all routes in this module + */ +export function register(app, basePath = '') { + /** + * @openapi + * /events: + * get: + * tags: [Events] + * summary: Subscribe to update signals (SSE) + * description: | + * Opens a Server-Sent Events stream for the authenticated user's + * **selected company**. Clients receive named signals when another + * request successfully mutates shared data for that company + * (including the same browser's other tabs). + * + * Use `EventSource` with credentials so the session cookie is sent: + * + * ```js + * const es = new EventSource('https://…/api/events', { withCredentials: true }); + * es.addEventListener('category', () => { refetchCategoryTree(); }); + * es.addEventListener('articles', () => { refetchArticleIndex(); }); + * es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); + * ``` + * + * Keep-alive comments (`: keepalive`) are sent roughly every 25s. + * Reconnect after changing the selected company (the stream is bound + * to the company at connect time). + * + * ## Signals + * + * | Signal | Emitted after successful | + * |--------|--------------------------| + * | `category` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image` | + * | `articles` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}` | + * | `article-` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}` | + * + * Signals are only sent when the mutation actually succeeds (2xx). + * Failed or no-op validation errors do not emit. The `article-` + * event name uses the concrete SKU (e.g. `article-42000045`). + * security: + * - cookieAuth: [] + * x-markdown: events.md + * responses: + * 200: + * description: | + * SSE stream (`text/event-stream`). Each signal is an SSE event + * whose name is the signal id and whose data is `{}`. + * content: + * text/event-stream: + * schema: + * type: string + * example: | + * event: category + * data: {} + * + * event: articles + * data: {} + * + * event: article-42000045 + * data: {} + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/events`, (req, res) => { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + req.socket.setTimeout(0); + res.setTimeout(0); + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); + + res.write(': connected\n\n'); + + subscribe(res, { companyId, userId: req.user.id }); + }); +} diff --git a/src/api/sse.js b/src/api/sse.js new file mode 100644 index 0000000..1c01999 --- /dev/null +++ b/src/api/sse.js @@ -0,0 +1,52 @@ +/** + * In-process SSE hub. Clients subscribe via GET /events; handlers call emit() + * after a successful mutation so other tabs/users can refetch. + */ + +/** @typedef {{ res: import('express').Response, companyId: number, userId: number }} SseClient */ + +/** @type {Set} */ +const clients = new Set(); + +const KEEP_ALIVE_MS = 25_000; + +/** + * Register an open SSE response. Caller must write the initial headers. + * @param {import('express').Response} res + * @param {{ companyId: number, userId: number }} meta + * @returns {() => void} unsubscribe + */ +export function subscribe(res, { companyId, userId }) { + /** @type {SseClient} */ + const client = { res, companyId, userId }; + clients.add(client); + + const keepAlive = setInterval(() => { + if (res.writableEnded) return; + res.write(': keepalive\n\n'); + }, KEEP_ALIVE_MS); + + const unsubscribe = () => { + clearInterval(keepAlive); + clients.delete(client); + }; + + res.on('close', unsubscribe); + return unsubscribe; +} + +/** + * Broadcast a named signal to subscribers of the given company. + * @param {string} signal - SSE event name (e.g. `category`) + * @param {{ companyId: number }} options + */ +export function emit(signal, { companyId }) { + if (companyId == null) return; + + const frame = `event: ${signal}\ndata: {}\n\n`; + for (const client of clients) { + if (client.companyId !== companyId) continue; + if (client.res.writableEnded) continue; + client.res.write(frame); + } +} diff --git a/src/db/article.js b/src/db/article.js index 7a95b31..2b02306 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -10,6 +10,11 @@ export async function getBySku(googleAuthId, sku) { return store.article.getBySku(googleAuthId, sku); } +export async function getHistoryBySku(googleAuthId, sku) { + const store = await getStore(); + return store.article.getHistoryBySku(googleAuthId, sku); +} + export async function updateBySku(googleAuthId, sku, data) { const store = await getStore(); return store.article.updateBySku(googleAuthId, sku, data);