Push role permission changes to signed-in users in real time

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
This commit is contained in:
riyadhafraa
2026-04-29 13:24:16 +00:00
parent cdacb079e9
commit 10a48657c7
3 changed files with 62 additions and 10 deletions
+31 -9
View File
@@ -15,14 +15,7 @@ export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
}
}
/**
* 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<void> {
if (!Number.isInteger(roleId)) return;
async function getRoleHolderUserIds(roleId: number): Promise<number[]> {
const directRows = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
@@ -41,9 +34,38 @@ export async function emitAppsChangedToRoleHolders(roleId: number): Promise<void
.where(inArray(userGroupsTable.groupId, groupIds))
: [];
const userIds = [
return [
...directRows.map((r) => 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<void> {
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<void> {
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 });
}
}
+5 -1
View File
@@ -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<voi
}
});
await emitAppsChangedToRoleHolders(id);
await emitRolePermissionsChangedToHolders(id);
const rows = await db
.select({
id: permissionsTable.id,
@@ -8,6 +8,11 @@ import {
getListIncomingServiceOrdersQueryKey,
getListAppsQueryKey,
getGetMeQueryKey,
getListRolesQueryKey,
getListPermissionsQueryKey,
getGetRolePermissionsQueryKey,
getGetRolePermissionAuditQueryKey,
getGetRoleUsageQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/contexts/AuthContext";
@@ -66,6 +71,27 @@ export function useNotificationsSocket() {
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
});
socket.on(
"role_permissions_changed",
(payload?: { roleId?: number }) => {
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();
};