u
This commit is contained in:
@@ -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` |
|
| `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}` |
|
| `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` |
|
| `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` |
|
| `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}` |
|
| `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` |
|
| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ _none_
|
|||||||
- Returns: `200 SSE`
|
- Returns: `200 SSE`
|
||||||
|
|
||||||
- Events:
|
- Events:
|
||||||
|
- `company`: `{ companyId }`
|
||||||
- `category`: `{}`
|
- `category`: `{}`
|
||||||
- `articles`: `{}`
|
- `articles`: `{}`
|
||||||
- `article-<sku>`: `{}`
|
- `article-<sku>`: `{}`
|
||||||
|
|||||||
@@ -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`
|
## Response `200`
|
||||||
|
|
||||||
Updated user
|
Updated user
|
||||||
|
|||||||
@@ -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`
|
## Response `201`
|
||||||
|
|
||||||
Created; selected company updated
|
Created; selected company updated
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ Deletes a company.
|
|||||||
|-------|----------|------|-------------|
|
|-------|----------|------|-------------|
|
||||||
| `id` | yes | integer | |
|
| `id` | yes | integer | |
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
On success, emits SSE signal: `company` (see [`GET /events`](../events.md)).
|
||||||
|
|
||||||
## Response `204`
|
## Response `204`
|
||||||
|
|
||||||
Deleted
|
Deleted
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ Updates a company.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
On success, emits SSE signal: `company` (see [`GET /events`](../events.md)).
|
||||||
|
|
||||||
## Response `200`
|
## Response `200`
|
||||||
|
|
||||||
Updated
|
Updated
|
||||||
|
|||||||
@@ -4,32 +4,35 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
|
|||||||
|
|
||||||
Subscribe to update signals (SSE)
|
Subscribe to update signals (SSE)
|
||||||
|
|
||||||
Opens a Server-Sent Events stream for the authenticated user's
|
Opens a Server-Sent Events stream for the authenticated user.
|
||||||
**selected company**. Clients receive named signals when another
|
Can be opened before a company is selected (`selectedCompanyId == null`);
|
||||||
request successfully mutates shared data for that company
|
when a company is selected or changed, the stream receives a `company`
|
||||||
(including the same browser's other tabs).
|
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:
|
Use `EventSource` with credentials so the session cookie is sent:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const es = new EventSource('https://…/api/events', { withCredentials: true });
|
const es = new EventSource('https://…/api/events', { withCredentials: true });
|
||||||
|
es.addEventListener('company', () => { refetchCompany(); });
|
||||||
es.addEventListener('category', () => { refetchCategoryTree(); });
|
es.addEventListener('category', () => { refetchCategoryTree(); });
|
||||||
es.addEventListener('articles', () => { refetchArticleIndex(); });
|
es.addEventListener('articles', () => { refetchArticleIndex(); });
|
||||||
es.addEventListener('article-42000045', () => { refetchArticle('42000045'); });
|
es.addEventListener('article-42000045', () => { refetchArticle('42000045'); });
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep-alive comments (`: heartbeat`) are sent roughly every 20s.
|
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
|
## Signals
|
||||||
|
|
||||||
Every SSE event uses the event name as the signal id; payloads are
|
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
|
JSON and most events send an empty object `{}`. `company` emits
|
||||||
richer payload.
|
`{ companyId }` and `brand` emits `{ sender, brandId }`.
|
||||||
|
|
||||||
| Signal | Payload | Emitted after successful |
|
| 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` |
|
| `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 |
|
| `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-<sku>` | `{}` | `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 |
|
| `article-<sku>` | `{}` | `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
|
SSE stream (`text/event-stream`). Each signal is an SSE event
|
||||||
whose name is the signal id. Most signals send `{}` as data;
|
whose name is the signal id. Most signals send `{}` as data;
|
||||||
`brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
|
`company` sends `{ "companyId": 1 }` and `brand` sends
|
||||||
|
`{ "sender": "<x-client-id>", "brandId": 12 }`.
|
||||||
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
| Status | Description |
|
| Status | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| 400 | No company selected — `{"error":"…"}` |
|
|
||||||
| 401 | Unauthorized — `{"error":"…"}` |
|
| 401 | Unauthorized — `{"error":"…"}` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as db from '../../../db/googleAuth.js';
|
import * as db from '../../../db/googleAuth.js';
|
||||||
import { clearCachedUser } from '../../middleware/auth.js';
|
import { clearCachedUser } from '../../middleware/auth.js';
|
||||||
|
import { setUserCompany } from '../../sse.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the selected-company route (requires auth middleware).
|
* Registers the selected-company route (requires auth middleware).
|
||||||
@@ -17,6 +18,7 @@ export function register(app, basePath = '') {
|
|||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - cookieAuth: []
|
||||||
* x-markdown: auth/selected-company.md
|
* x-markdown: auth/selected-company.md
|
||||||
|
* x-signals: [company]
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -76,6 +78,7 @@ export function register(app, basePath = '') {
|
|||||||
selectedCompanyId: updated.selectedCompanyId,
|
selectedCompanyId: updated.selectedCompanyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setUserCompany(req.user.id, updated.selectedCompanyId);
|
||||||
res.status(200).json({ user });
|
res.status(200).json({ user });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Set selected company failed:', error);
|
console.error('Set selected company failed:', error);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import JSZip from 'jszip';
|
|||||||
import * as db from '../../db/company.js';
|
import * as db from '../../db/company.js';
|
||||||
import * as googleAuth from '../../db/googleAuth.js';
|
import * as googleAuth from '../../db/googleAuth.js';
|
||||||
import { clearCachedUser } from '../middleware/auth.js';
|
import { clearCachedUser } from '../middleware/auth.js';
|
||||||
|
import { clearDeletedCompany, emit, setUserCompany } from '../sse.js';
|
||||||
import { initCompanyVfs } from '../../tools/vfs.js';
|
import { initCompanyVfs } from '../../tools/vfs.js';
|
||||||
import { executeCompanyImport } from '../../services/companyImport.js';
|
import { executeCompanyImport } from '../../services/companyImport.js';
|
||||||
import { executeCompanyExport } from '../../services/companyExport.js';
|
import { executeCompanyExport } from '../../services/companyExport.js';
|
||||||
@@ -137,6 +138,7 @@ export function register(app, basePath = '') {
|
|||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - cookieAuth: []
|
||||||
* x-markdown: company/create.md
|
* x-markdown: company/create.md
|
||||||
|
* x-signals: [company]
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -215,6 +217,7 @@ export function register(app, basePath = '') {
|
|||||||
console.error('Failed to initialize company VFS:', vfsErr);
|
console.error('Failed to initialize company VFS:', vfsErr);
|
||||||
}
|
}
|
||||||
const authRow = await googleAuth.getAllowedEmail(req.user.email);
|
const authRow = await googleAuth.getAllowedEmail(req.user.email);
|
||||||
|
setUserCompany(req.user.id, authRow.selectedCompanyId);
|
||||||
res.status(201).json({ company, user: userPayload(authRow) });
|
res.status(201).json({ company, user: userPayload(authRow) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = companyErrorStatus(error);
|
const status = companyErrorStatus(error);
|
||||||
@@ -834,6 +837,7 @@ export function register(app, basePath = '') {
|
|||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - cookieAuth: []
|
||||||
* x-markdown: company/update.md
|
* x-markdown: company/update.md
|
||||||
|
* x-signals: [company]
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: id
|
* name: id
|
||||||
@@ -891,6 +895,7 @@ export function register(app, basePath = '') {
|
|||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - cookieAuth: []
|
||||||
* x-markdown: company/delete.md
|
* x-markdown: company/delete.md
|
||||||
|
* x-signals: [company]
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: path
|
* - in: path
|
||||||
* name: id
|
* name: id
|
||||||
@@ -934,6 +939,7 @@ export function register(app, basePath = '') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const company = await db.updateCompany(parsedId.companyId, body);
|
const company = await db.updateCompany(parsedId.companyId, body);
|
||||||
|
emit('company', { companyId: parsedId.companyId, data: { companyId: parsedId.companyId } });
|
||||||
res.status(200).json({ company });
|
res.status(200).json({ company });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = companyErrorStatus(error);
|
const status = companyErrorStatus(error);
|
||||||
@@ -954,6 +960,7 @@ export function register(app, basePath = '') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await db.deleteCompany(parsedId.companyId);
|
await db.deleteCompany(parsedId.companyId);
|
||||||
|
clearDeletedCompany(parsedId.companyId);
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete company failed:', error);
|
console.error('Delete company failed:', error);
|
||||||
|
|||||||
@@ -13,32 +13,35 @@ export function register(app, basePath = '') {
|
|||||||
* tags: [Events]
|
* tags: [Events]
|
||||||
* summary: Subscribe to update signals (SSE)
|
* summary: Subscribe to update signals (SSE)
|
||||||
* description: |
|
* description: |
|
||||||
* Opens a Server-Sent Events stream for the authenticated user's
|
* Opens a Server-Sent Events stream for the authenticated user.
|
||||||
* **selected company**. Clients receive named signals when another
|
* Can be opened before a company is selected (`selectedCompanyId == null`);
|
||||||
* request successfully mutates shared data for that company
|
* when a company is selected or changed, the stream receives a `company`
|
||||||
* (including the same browser's other tabs).
|
* 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:
|
* Use `EventSource` with credentials so the session cookie is sent:
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* const es = new EventSource('https://…/api/events', { withCredentials: true });
|
* const es = new EventSource('https://…/api/events', { withCredentials: true });
|
||||||
|
* es.addEventListener('company', () => { refetchCompany(); });
|
||||||
* es.addEventListener('category', () => { refetchCategoryTree(); });
|
* es.addEventListener('category', () => { refetchCategoryTree(); });
|
||||||
* es.addEventListener('articles', () => { refetchArticleIndex(); });
|
* es.addEventListener('articles', () => { refetchArticleIndex(); });
|
||||||
* es.addEventListener('article-42000045', () => { refetchArticle('42000045'); });
|
* es.addEventListener('article-42000045', () => { refetchArticle('42000045'); });
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Keep-alive comments (`: heartbeat`) are sent roughly every 20s.
|
* 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
|
* ## Signals
|
||||||
*
|
*
|
||||||
* Every SSE event uses the event name as the signal id; payloads are
|
* 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
|
* JSON and most events send an empty object `{}`. `company` emits
|
||||||
* richer payload.
|
* `{ companyId }` and `brand` emits `{ sender, brandId }`.
|
||||||
*
|
*
|
||||||
* | Signal | Payload | Emitted after successful |
|
* | 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` |
|
* | `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 |
|
* | `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-<sku>` | `{}` | `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 |
|
* | `article-<sku>` | `{}` | `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: []
|
* - cookieAuth: []
|
||||||
* x-markdown: events.md
|
* x-markdown: events.md
|
||||||
* x-sse-events:
|
* x-sse-events:
|
||||||
|
* - name: company
|
||||||
|
* payload: "{ companyId }"
|
||||||
* - name: category
|
* - name: category
|
||||||
* payload: "{}"
|
* payload: "{}"
|
||||||
* - name: articles
|
* - name: articles
|
||||||
@@ -100,12 +105,16 @@ export function register(app, basePath = '') {
|
|||||||
* description: |
|
* description: |
|
||||||
* SSE stream (`text/event-stream`). Each signal is an SSE event
|
* SSE stream (`text/event-stream`). Each signal is an SSE event
|
||||||
* whose name is the signal id. Most signals send `{}` as data;
|
* whose name is the signal id. Most signals send `{}` as data;
|
||||||
* `brand` sends `{ "sender": "<x-client-id>", "brandId": 12 }`.
|
* `company` sends `{ "companyId": 1 }` and `brand` sends
|
||||||
|
* `{ "sender": "<x-client-id>", "brandId": 12 }`.
|
||||||
* content:
|
* content:
|
||||||
* text/event-stream:
|
* text/event-stream:
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* example: |
|
* example: |
|
||||||
|
* event: company
|
||||||
|
* data: {"companyId":1}
|
||||||
|
*
|
||||||
* event: category
|
* event: category
|
||||||
* data: {}
|
* data: {}
|
||||||
*
|
*
|
||||||
@@ -117,12 +126,6 @@ export function register(app, basePath = '') {
|
|||||||
*
|
*
|
||||||
* event: brand
|
* event: brand
|
||||||
* data: {"sender":"tab-1","brandId":12}
|
* data: {"sender":"tab-1","brandId":12}
|
||||||
* 400:
|
|
||||||
* description: No company selected
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* $ref: '#/components/schemas/Error'
|
|
||||||
* 401:
|
* 401:
|
||||||
* description: Unauthorized
|
* description: Unauthorized
|
||||||
* content:
|
* content:
|
||||||
@@ -138,10 +141,7 @@ export function register(app, basePath = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleSse(req, res) {
|
export function handleSse(req, res) {
|
||||||
const companyId = req.user?.selectedCompanyId;
|
const companyId = req.user?.selectedCompanyId ?? null;
|
||||||
if (companyId == null) {
|
|
||||||
return res.status(400).json({ error: 'No company selected' });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.socket?.setTimeout(0);
|
req.socket?.setTimeout(0);
|
||||||
res.setTimeout?.(0);
|
res.setTimeout?.(0);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* after a successful mutation so other tabs/users can refetch.
|
* 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<SseClient>} */
|
/** @type {Set<SseClient>} */
|
||||||
const clients = new 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.
|
* Register an open SSE response. Caller must write the initial headers.
|
||||||
* @param {import('express').Response} res
|
* @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]
|
* @param {import('express').Request} [req]
|
||||||
* @returns {() => void} unsubscribe
|
* @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;
|
const resolvedClientId = clientId || req?.headers?.['x-client-id'] || req?.query?.cid || null;
|
||||||
/** @type {SseClient} */
|
/** @type {SseClient} */
|
||||||
const client = {
|
const client = {
|
||||||
res,
|
res,
|
||||||
companyId,
|
companyId: companyId ?? null,
|
||||||
userId,
|
userId,
|
||||||
clientId: resolvedClientId ? String(resolvedClientId) : null,
|
clientId: resolvedClientId ? String(resolvedClientId) : null,
|
||||||
keepAlive: setInterval(() => {
|
keepAlive: setInterval(() => {
|
||||||
@@ -42,6 +42,43 @@ export function subscribe(res, { companyId, userId, clientId }, req) {
|
|||||||
return unsubscribe;
|
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).
|
* End every open SSE stream so `server.close()` can finish (e.g. node --watch).
|
||||||
* EventSource clients reconnect to the new process.
|
* 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.
|
* 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 {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 }) {
|
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 payload = data == null ? '{}' : (typeof data === 'string' ? data : JSON.stringify(data));
|
||||||
const frame = `event: ${signal}\ndata: ${payload}\n\n`;
|
const frame = `event: ${signal}\ndata: ${payload}\n\n`;
|
||||||
@@ -88,7 +125,7 @@ export function emit(signal, { companyId, data, targetClientId, targetUserId })
|
|||||||
let sent = false;
|
let sent = false;
|
||||||
if (targetClientId) {
|
if (targetClientId) {
|
||||||
for (const client of clients) {
|
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 (targetUserId != null && client.userId !== targetUserId) continue;
|
||||||
if (client.clientId === targetClientId && !client.res.writableEnded) {
|
if (client.clientId === targetClientId && !client.res.writableEnded) {
|
||||||
client.res.write(frame);
|
client.res.write(frame);
|
||||||
@@ -98,7 +135,7 @@ export function emit(signal, { companyId, data, targetClientId, targetUserId })
|
|||||||
}
|
}
|
||||||
if (!sent && targetUserId != null) {
|
if (!sent && targetUserId != null) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.companyId !== companyId) continue;
|
if (companyId != null && client.companyId !== companyId) continue;
|
||||||
if (client.userId === targetUserId && !client.res.writableEnded) {
|
if (client.userId === targetUserId && !client.res.writableEnded) {
|
||||||
client.res.write(frame);
|
client.res.write(frame);
|
||||||
sent = true;
|
sent = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user