diff --git a/docs/api/README.md b/docs/api/README.md index 70abfc8..33be69d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -345,6 +345,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `shipping` | `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}` | +| `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}` | | `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index efc6900..91e5553 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -478,6 +478,7 @@ _none_ - Returns: `200 SSE` - Events: + - `user`: `{ selectedCompanyId }` - `company`: `{ companyId }` - `category`: `{}` - `articles`: `{}` diff --git a/docs/api/routes/auth/selected-company.md b/docs/api/routes/auth/selected-company.md index 41736e3..784e299 100644 --- a/docs/api/routes/auth/selected-company.md +++ b/docs/api/routes/auth/selected-company.md @@ -20,7 +20,7 @@ Sets the authenticated user's selected company. ## 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` diff --git a/docs/api/routes/company/create.md b/docs/api/routes/company/create.md index 3317bdd..305b126 100644 --- a/docs/api/routes/company/create.md +++ b/docs/api/routes/company/create.md @@ -26,7 +26,7 @@ Creates a company and selects it for the current user. ## 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` diff --git a/docs/api/routes/company/delete.md b/docs/api/routes/company/delete.md index c107709..25c5762 100644 --- a/docs/api/routes/company/delete.md +++ b/docs/api/routes/company/delete.md @@ -14,7 +14,7 @@ Deletes a company. ## 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` diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index 3ccc7ab..53e1b49 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -6,8 +6,9 @@ Subscribe to update signals (SSE) 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. +when a company is selected or changed, the stream receives `user` +(`{ selectedCompanyId }`) and `company` (`{ companyId }`) events 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). @@ -16,6 +17,7 @@ Use `EventSource` with credentials so the session cookie is sent: ```js const es = new EventSource('https://…/api/events', { withCredentials: true }); +es.addEventListener('user', () => { refetchAuthMe(); }); es.addEventListener('company', () => { refetchCompany(); }); es.addEventListener('category', () => { refetchCategoryTree(); }); es.addEventListener('articles', () => { refetchArticleIndex(); }); @@ -27,11 +29,13 @@ Keep-alive comments (`: heartbeat`) are sent roughly every 20s. ## Signals Every SSE event uses the event name as the signal id; payloads are -JSON and most events send an empty object `{}`. `company` emits -`{ companyId }` and `brand` emits `{ sender, brandId }`. +JSON and most events send an empty object `{}`. `user` emits +`{ selectedCompanyId }`, `company` emits `{ companyId }`, and `brand` +emits `{ sender, brandId }`. | 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}` | | `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 | @@ -63,7 +67,8 @@ 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; -`company` sends `{ "companyId": 1 }` and `brand` sends +`user` sends `{ "selectedCompanyId": 1 }`, `company` sends +`{ "companyId": 1 }`, and `brand` sends `{ "sender": "", "brandId": 12 }`. diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index da60e5b..bb7612a 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -39,6 +39,14 @@ export function clearCachedUser(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) { if (req.path === '/api/auth/me') { return respondWithAuthenticatedUser(res, status, body); diff --git a/src/api/routes/auth/selectedCompany.js b/src/api/routes/auth/selectedCompany.js index ae790bb..3359b50 100644 --- a/src/api/routes/auth/selectedCompany.js +++ b/src/api/routes/auth/selectedCompany.js @@ -1,6 +1,5 @@ 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). @@ -18,7 +17,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: auth/selected-company.md - * x-signals: [company] + * x-signals: [user, company] * requestBody: * required: true * content: @@ -78,7 +77,6 @@ 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 d4b3cd9..671615b 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -5,7 +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 { emit } from '../sse.js'; import { initCompanyVfs } from '../../tools/vfs.js'; import { executeCompanyImport } from '../../services/companyImport.js'; import { executeCompanyExport } from '../../services/companyExport.js'; @@ -138,7 +138,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: company/create.md - * x-signals: [company] + * x-signals: [user, company] * requestBody: * required: true * content: @@ -217,7 +217,6 @@ 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); @@ -895,7 +894,7 @@ export function register(app, basePath = '') { * security: * - cookieAuth: [] * x-markdown: company/delete.md - * x-signals: [company] + * x-signals: [user, company] * parameters: * - in: path * name: id @@ -960,7 +959,6 @@ 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 4604256..932c665 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -15,8 +15,9 @@ export function register(app, basePath = '') { * description: | * 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. + * when a company is selected or changed, the stream receives `user` + * (`{ selectedCompanyId }`) and `company` (`{ companyId }`) events 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). @@ -25,6 +26,7 @@ export function register(app, basePath = '') { * * ```js * const es = new EventSource('https://…/api/events', { withCredentials: true }); + * es.addEventListener('user', () => { refetchAuthMe(); }); * es.addEventListener('company', () => { refetchCompany(); }); * es.addEventListener('category', () => { refetchCategoryTree(); }); * es.addEventListener('articles', () => { refetchArticleIndex(); }); @@ -36,11 +38,13 @@ export function register(app, basePath = '') { * ## Signals * * Every SSE event uses the event name as the signal id; payloads are - * JSON and most events send an empty object `{}`. `company` emits - * `{ companyId }` and `brand` emits `{ sender, brandId }`. + * JSON and most events send an empty object `{}`. `user` emits + * `{ selectedCompanyId }`, `company` emits `{ companyId }`, and `brand` + * emits `{ sender, brandId }`. * * | 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}` | * | `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 | @@ -70,6 +74,8 @@ export function register(app, basePath = '') { * - cookieAuth: [] * x-markdown: events.md * x-sse-events: + * - name: user + * payload: "{ selectedCompanyId }" * - name: company * payload: "{ companyId }" * - name: category @@ -105,13 +111,17 @@ 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; - * `company` sends `{ "companyId": 1 }` and `brand` sends + * `user` sends `{ "selectedCompanyId": 1 }`, `company` sends + * `{ "companyId": 1 }`, and `brand` sends * `{ "sender": "", "brandId": 12 }`. * content: * text/event-stream: * schema: * type: string * example: | + * event: user + * data: {"selectedCompanyId":1} + * * event: company * data: {"companyId":1} * diff --git a/src/api/sse.js b/src/api/sse.js index 70e732d..b1490c1 100644 --- a/src/api/sse.js +++ b/src/api/sse.js @@ -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 * (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|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`; + 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) { if (client.userId !== userId) continue; client.companyId = nextCompanyId; 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 - * 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 */ export function clearDeletedCompany(companyId) { 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) { if (client.companyId !== companyId) continue; client.companyId = null; if (!client.res.writableEnded) { - client.res.write(frame); + client.res.write(userFrame); + client.res.write(companyFrame); } } } diff --git a/src/backendmonitor/dbHelper.js b/src/backendmonitor/dbHelper.js index 745a6c6..d86c860 100644 --- a/src/backendmonitor/dbHelper.js +++ b/src/backendmonitor/dbHelper.js @@ -1,5 +1,7 @@ import env from '../config/env.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') @@ -284,6 +286,14 @@ export async function updateRow(tableName, { primaryKey, changes } = {}) { const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`; 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 { success: true, rowsAffected: result.rowsAffected, diff --git a/src/db/store.js b/src/db/store.js index 275e9e9..1d1876e 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -3,6 +3,8 @@ import { MssqlProvider } from 'libdashstore/providers/mssql'; import { BetterSqlite3Provider } from 'libdashstore/providers/better-sqlite3'; import env from '../config/env.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'); @@ -79,6 +81,22 @@ function wrapStoreWithTiming(rawStore) { try { const result = await fn(...args); 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; } catch (err) { const isNotFound = /not found/i.test(err?.message);