From 1c5385061cd832c82c1876a7407aaa74735bafbb Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 28 Oct 2026 00:08:02 +0100 Subject: [PATCH] u --- docs/api/README.md | 1 + docs/api/routes-all.md | 1 + docs/api/routes/auth/selected-company.md | 4 ++ docs/api/routes/company/create.md | 4 ++ docs/api/routes/company/delete.md | 4 ++ docs/api/routes/company/update.md | 4 ++ docs/api/routes/events.md | 23 +++++----- src/api/routes/auth/selectedCompany.js | 3 ++ src/api/routes/company.js | 7 ++++ src/api/routes/events.js | 38 ++++++++--------- src/api/sse.js | 53 ++++++++++++++++++++---- 11 files changed, 105 insertions(+), 37 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index c362ef6..70abfc8 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -331,6 +331,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` | | `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` | +| `company` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}`, `PUT /company/{id}` | | `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` | | `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` | | `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 8513a96..efc6900 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -478,6 +478,7 @@ _none_ - Returns: `200 SSE` - Events: + - `company`: `{ companyId }` - `category`: `{}` - `articles`: `{}` - `article-`: `{}` diff --git a/docs/api/routes/auth/selected-company.md b/docs/api/routes/auth/selected-company.md index 31a6899..41736e3 100644 --- a/docs/api/routes/auth/selected-company.md +++ b/docs/api/routes/auth/selected-company.md @@ -18,6 +18,10 @@ Sets the authenticated user's selected company. } ``` +## Signals + +On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). + ## Response `200` Updated user diff --git a/docs/api/routes/company/create.md b/docs/api/routes/company/create.md index f8cd089..3317bdd 100644 --- a/docs/api/routes/company/create.md +++ b/docs/api/routes/company/create.md @@ -24,6 +24,10 @@ Creates a company and selects it for the current user. } ``` +## Signals + +On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). + ## Response `201` Created; selected company updated diff --git a/docs/api/routes/company/delete.md b/docs/api/routes/company/delete.md index 21bfccd..c107709 100644 --- a/docs/api/routes/company/delete.md +++ b/docs/api/routes/company/delete.md @@ -12,6 +12,10 @@ Deletes a company. |-------|----------|------|-------------| | `id` | yes | integer | | +## Signals + +On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). + ## Response `204` Deleted diff --git a/docs/api/routes/company/update.md b/docs/api/routes/company/update.md index fb7f65f..d664a55 100644 --- a/docs/api/routes/company/update.md +++ b/docs/api/routes/company/update.md @@ -30,6 +30,10 @@ Updates a company. } ``` +## Signals + +On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). + ## Response `200` Updated diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index 74583de..3ccc7ab 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -4,32 +4,35 @@ 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). +Opens a Server-Sent Events stream for the authenticated user. +Can be opened before a company is selected (`selectedCompanyId == null`); +when a company is selected or changed, the stream receives a `company` +event (`{ companyId }`) and updates its company scope in-place. +Clients also receive named signals when another request successfully +mutates shared data for the active 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('company', () => { refetchCompany(); }); es.addEventListener('category', () => { refetchCategoryTree(); }); es.addEventListener('articles', () => { refetchArticleIndex(); }); es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); ``` Keep-alive comments (`: heartbeat`) are sent roughly every 20s. -Reconnect after changing the selected company (the stream is bound -to the company at connect time). ## Signals Every SSE event uses the event name as the signal id; payloads are -JSON and most events send an empty object `{}`. Only `brand` emits a -richer payload. +JSON and most events send an empty object `{}`. `company` emits +`{ companyId }` and `brand` emits `{ sender, brandId }`. | Signal | Payload | Emitted after successful | |--------|---------|--------------------------| +| `company` | `{ companyId }` | `PUT /auth/selected-company`, `POST /company`, `PUT /company/{id}`, `DELETE /company/{id}` | | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` | | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations | | `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}`, supplier/article-property mutations affecting the article | @@ -60,14 +63,14 @@ runtime (for example `article-42000045` and `modelconversation-123`). SSE stream (`text/event-stream`). Each signal is an SSE event whose name is the signal id. Most signals send `{}` as data; -`brand` sends `{ "sender": "", "brandId": 12 }`. +`company` sends `{ "companyId": 1 }` and `brand` sends +`{ "sender": "", "brandId": 12 }`. ## Errors | Status | Description | |--------|-------------| -| 400 | No company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | --- diff --git a/src/api/routes/auth/selectedCompany.js b/src/api/routes/auth/selectedCompany.js index 0a3b9be..ae790bb 100644 --- a/src/api/routes/auth/selectedCompany.js +++ b/src/api/routes/auth/selectedCompany.js @@ -1,5 +1,6 @@ import * as db from '../../../db/googleAuth.js'; import { clearCachedUser } from '../../middleware/auth.js'; +import { setUserCompany } from '../../sse.js'; /** * Registers the selected-company route (requires auth middleware). @@ -17,6 +18,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: auth/selected-company.md + * x-signals: [company] * requestBody: * required: true * content: @@ -76,6 +78,7 @@ export function register(app, basePath = '') { selectedCompanyId: updated.selectedCompanyId, }; + setUserCompany(req.user.id, updated.selectedCompanyId); res.status(200).json({ user }); } catch (error) { console.error('Set selected company failed:', error); diff --git a/src/api/routes/company.js b/src/api/routes/company.js index 3418b7c..d4b3cd9 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -5,6 +5,7 @@ import JSZip from 'jszip'; import * as db from '../../db/company.js'; import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; +import { clearDeletedCompany, emit, setUserCompany } from '../sse.js'; import { initCompanyVfs } from '../../tools/vfs.js'; import { executeCompanyImport } from '../../services/companyImport.js'; import { executeCompanyExport } from '../../services/companyExport.js'; @@ -137,6 +138,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: company/create.md + * x-signals: [company] * requestBody: * required: true * content: @@ -215,6 +217,7 @@ export function register(app, basePath = '') { console.error('Failed to initialize company VFS:', vfsErr); } const authRow = await googleAuth.getAllowedEmail(req.user.email); + setUserCompany(req.user.id, authRow.selectedCompanyId); res.status(201).json({ company, user: userPayload(authRow) }); } catch (error) { const status = companyErrorStatus(error); @@ -834,6 +837,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: company/update.md + * x-signals: [company] * parameters: * - in: path * name: id @@ -891,6 +895,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: company/delete.md + * x-signals: [company] * parameters: * - in: path * name: id @@ -934,6 +939,7 @@ export function register(app, basePath = '') { try { const company = await db.updateCompany(parsedId.companyId, body); + emit('company', { companyId: parsedId.companyId, data: { companyId: parsedId.companyId } }); res.status(200).json({ company }); } catch (error) { const status = companyErrorStatus(error); @@ -954,6 +960,7 @@ export function register(app, basePath = '') { try { await db.deleteCompany(parsedId.companyId); + clearDeletedCompany(parsedId.companyId); res.sendStatus(204); } catch (error) { console.error('Delete company failed:', error); diff --git a/src/api/routes/events.js b/src/api/routes/events.js index a4695df..4604256 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -13,32 +13,35 @@ export function register(app, basePath = '') { * 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). + * Opens a Server-Sent Events stream for the authenticated user. + * Can be opened before a company is selected (`selectedCompanyId == null`); + * when a company is selected or changed, the stream receives a `company` + * event (`{ companyId }`) and updates its company scope in-place. + * Clients also receive named signals when another request successfully + * mutates shared data for the active 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('company', () => { refetchCompany(); }); * es.addEventListener('category', () => { refetchCategoryTree(); }); * es.addEventListener('articles', () => { refetchArticleIndex(); }); * es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); * ``` * * Keep-alive comments (`: heartbeat`) are sent roughly every 20s. - * Reconnect after changing the selected company (the stream is bound - * to the company at connect time). * * ## Signals * * Every SSE event uses the event name as the signal id; payloads are - * JSON and most events send an empty object `{}`. Only `brand` emits a - * richer payload. + * JSON and most events send an empty object `{}`. `company` emits + * `{ companyId }` and `brand` emits `{ sender, brandId }`. * * | Signal | Payload | Emitted after successful | * |--------|---------|--------------------------| + * | `company` | `{ companyId }` | `PUT /auth/selected-company`, `POST /company`, `PUT /company/{id}`, `DELETE /company/{id}` | * | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` | * | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations | * | `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}`, supplier/article-property mutations affecting the article | @@ -67,6 +70,8 @@ export function register(app, basePath = '') { * - cookieAuth: [] * x-markdown: events.md * x-sse-events: + * - name: company + * payload: "{ companyId }" * - name: category * payload: "{}" * - name: articles @@ -100,12 +105,16 @@ export function register(app, basePath = '') { * description: | * SSE stream (`text/event-stream`). Each signal is an SSE event * whose name is the signal id. Most signals send `{}` as data; - * `brand` sends `{ "sender": "", "brandId": 12 }`. + * `company` sends `{ "companyId": 1 }` and `brand` sends + * `{ "sender": "", "brandId": 12 }`. * content: * text/event-stream: * schema: * type: string * example: | + * event: company + * data: {"companyId":1} + * * event: category * data: {} * @@ -117,12 +126,6 @@ export function register(app, basePath = '') { * * event: brand * data: {"sender":"tab-1","brandId":12} - * 400: - * description: No company selected - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: @@ -138,10 +141,7 @@ export function register(app, basePath = '') { } export function handleSse(req, res) { - const companyId = req.user?.selectedCompanyId; - if (companyId == null) { - return res.status(400).json({ error: 'No company selected' }); - } + const companyId = req.user?.selectedCompanyId ?? null; req.socket?.setTimeout(0); res.setTimeout?.(0); diff --git a/src/api/sse.js b/src/api/sse.js index f87cd5e..70e732d 100644 --- a/src/api/sse.js +++ b/src/api/sse.js @@ -3,7 +3,7 @@ * after a successful mutation so other tabs/users can refetch. */ -/** @typedef {{ res: import('express').Response, companyId: number, userId: number, clientId: string|null, keepAlive: NodeJS.Timeout }} SseClient */ +/** @typedef {{ res: import('express').Response, companyId: number|null, userId: number, clientId: string|null, keepAlive: NodeJS.Timeout }} SseClient */ /** @type {Set} */ const clients = new Set(); @@ -13,16 +13,16 @@ const KEEP_ALIVE_MS = 20_000; /** * Register an open SSE response. Caller must write the initial headers. * @param {import('express').Response} res - * @param {{ companyId: number, userId: number, clientId?: string|null }} meta + * @param {{ companyId: number|null, userId: number, clientId?: string|null }} meta * @param {import('express').Request} [req] * @returns {() => void} unsubscribe */ -export function subscribe(res, { companyId, userId, clientId }, req) { +export function subscribe(res, { companyId = null, userId, clientId }, req) { const resolvedClientId = clientId || req?.headers?.['x-client-id'] || req?.query?.cid || null; /** @type {SseClient} */ const client = { res, - companyId, + companyId: companyId ?? null, userId, clientId: resolvedClientId ? String(resolvedClientId) : null, keepAlive: setInterval(() => { @@ -42,6 +42,43 @@ export function subscribe(res, { companyId, userId, clientId }, req) { return unsubscribe; } +/** + * Updates the active company scope for all open SSE streams of a user + * (e.g. when the user selects or switches company on a pre-company stream) + * and emits a `company` event to those streams. + * @param {number} userId + * @param {number|null} companyId + */ +export function setUserCompany(userId, companyId) { + if (userId == null) return; + const nextCompanyId = companyId ?? null; + const frame = `event: company\ndata: ${JSON.stringify({ companyId: nextCompanyId })}\n\n`; + for (const client of clients) { + if (client.userId !== userId) continue; + client.companyId = nextCompanyId; + if (!client.res.writableEnded) { + client.res.write(frame); + } + } +} + +/** + * Clears a deleted company from any open SSE streams bound to it + * and emits a `company` event (`{ companyId: null }`) to those streams. + * @param {number} companyId + */ +export function clearDeletedCompany(companyId) { + if (companyId == null) return; + const frame = `event: company\ndata: ${JSON.stringify({ companyId: null })}\n\n`; + for (const client of clients) { + if (client.companyId !== companyId) continue; + client.companyId = null; + if (!client.res.writableEnded) { + client.res.write(frame); + } + } +} + /** * End every open SSE stream so `server.close()` can finish (e.g. node --watch). * EventSource clients reconnect to the new process. @@ -76,10 +113,10 @@ export function hasActiveClient({ companyId, userId, clientId } = {}) { * If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user. * * @param {string} signal - SSE event name (e.g. `category`, `articles`, `modelconversation`) - * @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options + * @param {{ companyId?: number|null, data?: string|object, targetClientId?: string, targetUserId?: number }} options */ export function emit(signal, { companyId, data, targetClientId, targetUserId }) { - if (companyId == null) return; + if (companyId == null && targetUserId == null) return; const payload = data == null ? '{}' : (typeof data === 'string' ? data : JSON.stringify(data)); const frame = `event: ${signal}\ndata: ${payload}\n\n`; @@ -88,7 +125,7 @@ export function emit(signal, { companyId, data, targetClientId, targetUserId }) let sent = false; if (targetClientId) { for (const client of clients) { - if (client.companyId !== companyId) continue; + if (companyId != null && client.companyId !== companyId) continue; if (targetUserId != null && client.userId !== targetUserId) continue; if (client.clientId === targetClientId && !client.res.writableEnded) { client.res.write(frame); @@ -98,7 +135,7 @@ export function emit(signal, { companyId, data, targetClientId, targetUserId }) } if (!sent && targetUserId != null) { for (const client of clients) { - if (client.companyId !== companyId) continue; + if (companyId != null && client.companyId !== companyId) continue; if (client.userId === targetUserId && !client.res.writableEnded) { client.res.write(frame); sent = true;