This commit is contained in:
seb
2026-10-28 19:31:41 +01:00
parent 01d6aa0e3f
commit 1881830b0a
13 changed files with 81 additions and 27 deletions

View File

@@ -345,6 +345,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `shipping` | `POST /stock/ship` | | `shipping` | `POST /stock/ship` |
| `stock` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `stock` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |
| `subagentschedule` | `POST /subagentschedule`, `DELETE /subagentschedule/{id}`, `PUT /subagentschedule/{id}` | | `subagentschedule` | `POST /subagentschedule`, `DELETE /subagentschedule/{id}`, `PUT /subagentschedule/{id}` |
| `user` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}` |
| `vfs` | `POST /vfs/applets/restore`, `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/skills/restore`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` | | `vfs` | `POST /vfs/applets/restore`, `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/skills/restore`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` |
| `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | | `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` |

View File

@@ -478,6 +478,7 @@ _none_
- Returns: `200 SSE` - Returns: `200 SSE`
- Events: - Events:
- `user`: `{ selectedCompanyId }`
- `company`: `{ companyId }` - `company`: `{ companyId }`
- `category`: `{}` - `category`: `{}`
- `articles`: `{}` - `articles`: `{}`

View File

@@ -20,7 +20,7 @@ Sets the authenticated user's selected company.
## Signals ## Signals
On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). On success, emits SSE signals: `user`, `company` (see [`GET /events`](../events.md)).
## Response `200` ## Response `200`

View File

@@ -26,7 +26,7 @@ Creates a company and selects it for the current user.
## Signals ## Signals
On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). On success, emits SSE signals: `user`, `company` (see [`GET /events`](../events.md)).
## Response `201` ## Response `201`

View File

@@ -14,7 +14,7 @@ Deletes a company.
## Signals ## Signals
On success, emits SSE signal: `company` (see [`GET /events`](../events.md)). On success, emits SSE signals: `user`, `company` (see [`GET /events`](../events.md)).
## Response `204` ## Response `204`

View File

@@ -6,8 +6,9 @@ Subscribe to update signals (SSE)
Opens a Server-Sent Events stream for the authenticated user. Opens a Server-Sent Events stream for the authenticated user.
Can be opened before a company is selected (`selectedCompanyId == null`); Can be opened before a company is selected (`selectedCompanyId == null`);
when a company is selected or changed, the stream receives a `company` when a company is selected or changed, the stream receives `user`
event (`{ companyId }`) and updates its company scope in-place. (`{ selectedCompanyId }`) and `company` (`{ companyId }`) events and
updates its company scope in-place.
Clients also receive named signals when another request successfully Clients also receive named signals when another request successfully
mutates shared data for the active company (including the same mutates shared data for the active company (including the same
browser's other tabs). browser's other tabs).
@@ -16,6 +17,7 @@ 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('user', () => { refetchAuthMe(); });
es.addEventListener('company', () => { refetchCompany(); }); es.addEventListener('company', () => { refetchCompany(); });
es.addEventListener('category', () => { refetchCategoryTree(); }); es.addEventListener('category', () => { refetchCategoryTree(); });
es.addEventListener('articles', () => { refetchArticleIndex(); }); es.addEventListener('articles', () => { refetchArticleIndex(); });
@@ -27,11 +29,13 @@ Keep-alive comments (`: heartbeat`) are sent roughly every 20s.
## 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 `{}`. `company` emits JSON and most events send an empty object `{}`. `user` emits
`{ companyId }` and `brand` emits `{ sender, brandId }`. `{ selectedCompanyId }`, `company` emits `{ companyId }`, and `brand`
emits `{ sender, brandId }`.
| Signal | Payload | Emitted after successful | | Signal | Payload | Emitted after successful |
|--------|---------|--------------------------| |--------|---------|--------------------------|
| `user` | `{ selectedCompanyId }` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}` |
| `company` | `{ companyId }` | `PUT /auth/selected-company`, `POST /company`, `PUT /company/{id}`, `DELETE /company/{id}` | | `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 |
@@ -63,7 +67,8 @@ 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;
`company` sends `{ "companyId": 1 }` and `brand` sends `user` sends `{ "selectedCompanyId": 1 }`, `company` sends
`{ "companyId": 1 }`, and `brand` sends
`{ "sender": "<x-client-id>", "brandId": 12 }`. `{ "sender": "<x-client-id>", "brandId": 12 }`.

View File

@@ -39,6 +39,14 @@ export function clearCachedUser(authId) {
userCache.delete(authId); userCache.delete(authId);
} }
export function clearCachedCompanyUsers(companyId) {
for (const [k, v] of userCache) {
if (v.user?.selectedCompanyId === companyId) {
userCache.delete(k);
}
}
}
function respondWithAuthPayload(res, req, status, body) { function respondWithAuthPayload(res, req, status, body) {
if (req.path === '/api/auth/me') { if (req.path === '/api/auth/me') {
return respondWithAuthenticatedUser(res, status, body); return respondWithAuthenticatedUser(res, status, body);

View File

@@ -1,6 +1,5 @@
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).
@@ -18,7 +17,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] * x-signals: [user, company]
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -78,7 +77,6 @@ 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);

View File

@@ -5,7 +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 { emit } 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';
@@ -138,7 +138,7 @@ export function register(app, basePath = '') {
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: company/create.md * x-markdown: company/create.md
* x-signals: [company] * x-signals: [user, company]
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -217,7 +217,6 @@ 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);
@@ -895,7 +894,7 @@ export function register(app, basePath = '') {
* security: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: company/delete.md * x-markdown: company/delete.md
* x-signals: [company] * x-signals: [user, company]
* parameters: * parameters:
* - in: path * - in: path
* name: id * name: id
@@ -960,7 +959,6 @@ 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);

View File

@@ -15,8 +15,9 @@ export function register(app, basePath = '') {
* description: | * description: |
* Opens a Server-Sent Events stream for the authenticated user. * Opens a Server-Sent Events stream for the authenticated user.
* Can be opened before a company is selected (`selectedCompanyId == null`); * Can be opened before a company is selected (`selectedCompanyId == null`);
* when a company is selected or changed, the stream receives a `company` * when a company is selected or changed, the stream receives `user`
* event (`{ companyId }`) and updates its company scope in-place. * (`{ selectedCompanyId }`) and `company` (`{ companyId }`) events and
* updates its company scope in-place.
* Clients also receive named signals when another request successfully * Clients also receive named signals when another request successfully
* mutates shared data for the active company (including the same * mutates shared data for the active company (including the same
* browser's other tabs). * browser's other tabs).
@@ -25,6 +26,7 @@ export function register(app, basePath = '') {
* *
* ```js * ```js
* const es = new EventSource('https://…/api/events', { withCredentials: true }); * const es = new EventSource('https://…/api/events', { withCredentials: true });
* es.addEventListener('user', () => { refetchAuthMe(); });
* es.addEventListener('company', () => { refetchCompany(); }); * es.addEventListener('company', () => { refetchCompany(); });
* es.addEventListener('category', () => { refetchCategoryTree(); }); * es.addEventListener('category', () => { refetchCategoryTree(); });
* es.addEventListener('articles', () => { refetchArticleIndex(); }); * es.addEventListener('articles', () => { refetchArticleIndex(); });
@@ -36,11 +38,13 @@ export function register(app, basePath = '') {
* ## 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 `{}`. `company` emits * JSON and most events send an empty object `{}`. `user` emits
* `{ companyId }` and `brand` emits `{ sender, brandId }`. * `{ selectedCompanyId }`, `company` emits `{ companyId }`, and `brand`
* emits `{ sender, brandId }`.
* *
* | Signal | Payload | Emitted after successful | * | Signal | Payload | Emitted after successful |
* |--------|---------|--------------------------| * |--------|---------|--------------------------|
* | `user` | `{ selectedCompanyId }` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}` |
* | `company` | `{ companyId }` | `PUT /auth/selected-company`, `POST /company`, `PUT /company/{id}`, `DELETE /company/{id}` | * | `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 |
@@ -70,6 +74,8 @@ export function register(app, basePath = '') {
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: events.md * x-markdown: events.md
* x-sse-events: * x-sse-events:
* - name: user
* payload: "{ selectedCompanyId }"
* - name: company * - name: company
* payload: "{ companyId }" * payload: "{ companyId }"
* - name: category * - name: category
@@ -105,13 +111,17 @@ 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;
* `company` sends `{ "companyId": 1 }` and `brand` sends * `user` sends `{ "selectedCompanyId": 1 }`, `company` sends
* `{ "companyId": 1 }`, and `brand` sends
* `{ "sender": "<x-client-id>", "brandId": 12 }`. * `{ "sender": "<x-client-id>", "brandId": 12 }`.
* content: * content:
* text/event-stream: * text/event-stream:
* schema: * schema:
* type: string * type: string
* example: | * example: |
* event: user
* data: {"selectedCompanyId":1}
*
* event: company * event: company
* data: {"companyId":1} * data: {"companyId":1}
* *

View File

@@ -45,36 +45,41 @@ export function subscribe(res, { companyId = null, userId, clientId }, req) {
/** /**
* Updates the active company scope for all open SSE streams of a user * 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) * (e.g. when the user selects or switches company on a pre-company stream)
* and emits a `company` event to those streams. * and emits `user` and `company` events to those streams.
* @param {number} userId * @param {number} userId
* @param {number|null} companyId * @param {number|null} companyId
*/ */
export function setUserCompany(userId, companyId) { export function setUserCompany(userId, companyId) {
if (userId == null) return; if (userId == null) return;
const nextCompanyId = companyId ?? null; const nextCompanyId = companyId ?? null;
const frame = `event: company\ndata: ${JSON.stringify({ companyId: nextCompanyId })}\n\n`; const userFrame = `event: user\ndata: ${JSON.stringify({ selectedCompanyId: nextCompanyId })}\n\n`;
const companyFrame = `event: company\ndata: ${JSON.stringify({ companyId: nextCompanyId })}\n\n`;
for (const client of clients) { for (const client of clients) {
if (client.userId !== userId) continue; if (client.userId !== userId) continue;
client.companyId = nextCompanyId; client.companyId = nextCompanyId;
if (!client.res.writableEnded) { if (!client.res.writableEnded) {
client.res.write(frame); client.res.write(userFrame);
client.res.write(companyFrame);
} }
} }
} }
/** /**
* Clears a deleted company from any open SSE streams bound to it * Clears a deleted company from any open SSE streams bound to it
* and emits a `company` event (`{ companyId: null }`) to those streams. * and emits `user` (`{ selectedCompanyId: null }`) and `company` (`{ companyId: null }`)
* events to those streams.
* @param {number} companyId * @param {number} companyId
*/ */
export function clearDeletedCompany(companyId) { export function clearDeletedCompany(companyId) {
if (companyId == null) return; if (companyId == null) return;
const frame = `event: company\ndata: ${JSON.stringify({ companyId: null })}\n\n`; const userFrame = `event: user\ndata: ${JSON.stringify({ selectedCompanyId: null })}\n\n`;
const companyFrame = `event: company\ndata: ${JSON.stringify({ companyId: null })}\n\n`;
for (const client of clients) { for (const client of clients) {
if (client.companyId !== companyId) continue; if (client.companyId !== companyId) continue;
client.companyId = null; client.companyId = null;
if (!client.res.writableEnded) { if (!client.res.writableEnded) {
client.res.write(frame); client.res.write(userFrame);
client.res.write(companyFrame);
} }
} }
} }

View File

@@ -1,5 +1,7 @@
import env from '../config/env.js'; import env from '../config/env.js';
import { getProvider } from '../db/store.js'; import { getProvider } from '../db/store.js';
import { clearCachedUser } from '../api/middleware/auth.js';
import { setUserCompany } from '../api/sse.js';
/** /**
* Returns dialect ('sqlite' or 'mssql') * Returns dialect ('sqlite' or 'mssql')
@@ -284,6 +286,14 @@ export async function updateRow(tableName, { primaryKey, changes } = {}) {
const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`; const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`;
const result = await provider.query(sql, params); const result = await provider.query(sql, params);
if (tableName.toLowerCase() === 'google_auth' && Object.prototype.hasOwnProperty.call(changes, 'selected_company') && primaryKey.id != null) {
const userId = Number(primaryKey.id);
const rawCompany = changes.selected_company;
const companyId = rawCompany === null || rawCompany === undefined || rawCompany === '' ? null : Number(rawCompany);
clearCachedUser(userId);
setUserCompany(userId, companyId);
}
return { return {
success: true, success: true,
rowsAffected: result.rowsAffected, rowsAffected: result.rowsAffected,

View File

@@ -3,6 +3,8 @@ import { MssqlProvider } from 'libdashstore/providers/mssql';
import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3'; import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3';
import env from '../config/env.js'; import env from '../config/env.js';
import { createLogger, chalk } from '../util/logger.js'; import { createLogger, chalk } from '../util/logger.js';
import { clearCachedCompanyUsers, clearCachedUser } from '../api/middleware/auth.js';
import { clearDeletedCompany, setUserCompany } from '../api/sse.js';
const storeLog = createLogger('libstore'); const storeLog = createLogger('libstore');
@@ -79,6 +81,22 @@ function wrapStoreWithTiming(rawStore) {
try { try {
const result = await fn(...args); const result = await fn(...args);
storeLog.debug(`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`); storeLog.debug(`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`);
if (namespace === 'google_auth' && method === 'setSelectedCompany') {
const [googleAuthId, companyId] = args;
if (googleAuthId != null) {
const normalizedUserId = Number(googleAuthId);
const normalizedCompanyId = companyId != null ? Number(companyId) : null;
clearCachedUser(normalizedUserId);
setUserCompany(normalizedUserId, normalizedCompanyId);
}
} else if (namespace === 'company' && method === 'delete') {
const [companyId] = args;
if (companyId != null) {
const normalizedCompanyId = Number(companyId);
clearCachedCompanyUsers(normalizedCompanyId);
clearDeletedCompany(normalizedCompanyId);
}
}
return result; return result;
} catch (err) { } catch (err) {
const isNotFound = /not found/i.test(err?.message); const isNotFound = /not found/i.test(err?.message);