From 10a48657c73647e2ef312b2753cb9c970b3459a0 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 13:24:16 +0000 Subject: [PATCH] Push role permission changes to signed-in users in real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task (#101): When an admin saves the role editor, role holders need their permission-driven UI (admin nav items, permission-gated action buttons, etc.) to refresh without waiting for a page reload. The server already emitted `apps_changed`, but that event is semantically about visible apps, not the broader cached permission state, and the client only invalidated the apps list and `/api/auth/me`. Changes: - artifacts/api-server/src/lib/realtime.ts - Extracted role-holder resolution (direct user_roles + indirect via group_roles -> user_groups) into a private helper. - Added `emitRolePermissionsChangedToHolders(roleId)` that emits a new `role_permissions_changed` socket event with `{ roleId }` to every holder of the role. - artifacts/api-server/src/routes/roles.ts - After `PUT /api/roles/:id/permissions` succeeds, call the new emitter alongside the existing `emitAppsChangedToRoleHolders`. - artifacts/tx-os/src/hooks/use-notifications-socket.ts - Subscribed to `role_permissions_changed`. Invalidates `getMe`, `listRoles`, `listPermissions`, and—when the payload includes the role id—`getRolePermissions`, the role permission audit, and role usage queries so admin-only UI re-evaluates without a refresh. Scope notes / deviations: - The task spec called out `PUT /api/roles/:id/permissions` only, so the matching POST/DELETE single-permission endpoints still emit only `apps_changed`. Filed as follow-up #150 to keep them consistent. - No new automated tests added (filed as follow-up #151). Pre-existing typecheck errors in artifacts/api-server/src/routes/executive-meetings.ts are unrelated and untouched. - `replit.md` not updated; this is a behavioral refinement of an existing socket flow, not an architectural change. Replit-Task-Id: bd31a9a9-dbf1-497a-8505-96672f92bc79 --- artifacts/api-server/src/lib/realtime.ts | 40 ++++++++++++++----- artifacts/api-server/src/routes/roles.ts | 6 ++- .../src/hooks/use-notifications-socket.ts | 26 ++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/artifacts/api-server/src/lib/realtime.ts b/artifacts/api-server/src/lib/realtime.ts index d1705902..1dfb2276 100644 --- a/artifacts/api-server/src/lib/realtime.ts +++ b/artifacts/api-server/src/lib/realtime.ts @@ -15,14 +15,7 @@ export async function emitAppsChangedToUsers(userIds: number[]): Promise { } } -/** - * Resolve every user that currently carries `roleId`, either directly via - * `user_roles` or indirectly via `group_roles` -> `user_groups`, and emit - * `apps_changed` to each of them. - */ -export async function emitAppsChangedToRoleHolders(roleId: number): Promise { - if (!Number.isInteger(roleId)) return; - +async function getRoleHolderUserIds(roleId: number): Promise { const directRows = await db .select({ userId: userRolesTable.userId }) .from(userRolesTable) @@ -41,9 +34,38 @@ export async function emitAppsChangedToRoleHolders(roleId: number): Promise r.userId), ...indirectRows.map((r) => r.userId), ]; +} + +/** + * Resolve every user that currently carries `roleId`, either directly via + * `user_roles` or indirectly via `group_roles` -> `user_groups`, and emit + * `apps_changed` to each of them. + */ +export async function emitAppsChangedToRoleHolders(roleId: number): Promise { + if (!Number.isInteger(roleId)) return; + const userIds = await getRoleHolderUserIds(roleId); await emitAppsChangedToUsers(userIds); } + +/** + * Notify every holder of `roleId` that the role's permission set has been + * updated, so the client can invalidate cached `/api/auth/me` and + * role/permission queries and re-evaluate permission-driven UI without a + * page refresh. + */ +export async function emitRolePermissionsChangedToHolders( + roleId: number, +): Promise { + if (!Number.isInteger(roleId)) return; + const userIds = await getRoleHolderUserIds(roleId); + const unique = Array.from(new Set(userIds.filter((id) => Number.isInteger(id)))); + if (unique.length === 0) return; + const { io } = await import("../index.js"); + for (const userId of unique) { + io.to(`user:${userId}`).emit("role_permissions_changed", { roleId }); + } +} diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index 5421acc3..d4652915 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -19,7 +19,10 @@ import { UpdateRoleBody, ReplaceRolePermissionsBody, } from "@workspace/api-zod"; -import { emitAppsChangedToRoleHolders } from "../lib/realtime.js"; +import { + emitAppsChangedToRoleHolders, + emitRolePermissionsChangedToHolders, +} from "../lib/realtime.js"; const router: IRouter = Router(); @@ -566,6 +569,7 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise { + queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + queryClient.invalidateQueries({ queryKey: getListPermissionsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() }); + const roleId = payload?.roleId; + if (typeof roleId === "number") { + queryClient.invalidateQueries({ + queryKey: getGetRolePermissionsQueryKey(roleId), + }); + queryClient.invalidateQueries({ + queryKey: getGetRolePermissionAuditQueryKey(roleId), + }); + queryClient.invalidateQueries({ + queryKey: getGetRoleUsageQueryKey(roleId), + }); + } + }, + ); + return () => { socket.disconnect(); };