From 6dd927350d99bc021655eb2ccbdeb2b175e81056 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 22 Apr 2026 10:20:04 +0000 Subject: [PATCH] Reflect group memberships live in the user's app grid (Task #77) Group/membership changes now push to affected users over Socket.IO so their app dock updates without a manual refresh. Server: - New helper `artifacts/api-server/src/lib/realtime.ts` exposing `emitAppsChangedToUsers(userIds)` which lazy-imports the io instance (matching the pattern used in conversations/service-orders) and emits `apps_changed` to each `user:` room. - `routes/groups.ts`: - PATCH /groups/:id: snapshot members before the write, union with members after, and emit to that set when membership changes; emit to current members when only apps/roles change. - POST/DELETE /groups/:id/users/:targetId emit to the target user. - POST/DELETE /groups/:id/apps|roles/:targetId emit to all current members of the group. - DELETE /groups/:id emits to all (former) members. - `routes/users.ts` PATCH /users/:id emits to the affected user when groupIds is changed. Client: - `artifacts/teaboy-os/src/hooks/use-notifications-socket.ts` now also listens for `apps_changed` and invalidates `getListAppsQueryKey()` and `getGetMeQueryKey()`, refreshing the dock and AuthUser context. Validation: - API tests pass for all groups/membership cases. One pre-existing flaky service-orders parallel-receipt test failed; unrelated to this change. Replit-Task-Id: 3e5ae44b-9dcf-42f4-8d5d-a0f2b05b6065 --- artifacts/api-server/src/lib/realtime.ts | 8 +++++ artifacts/api-server/src/routes/groups.ts | 36 +++++++++++++++++++ artifacts/api-server/src/routes/users.ts | 2 ++ .../src/hooks/use-notifications-socket.ts | 7 ++++ 4 files changed, 53 insertions(+) create mode 100644 artifacts/api-server/src/lib/realtime.ts diff --git a/artifacts/api-server/src/lib/realtime.ts b/artifacts/api-server/src/lib/realtime.ts new file mode 100644 index 00000000..b65662ec --- /dev/null +++ b/artifacts/api-server/src/lib/realtime.ts @@ -0,0 +1,8 @@ +export async function emitAppsChangedToUsers(userIds: number[]): Promise { + 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("apps_changed"); + } +} diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index 00b47eab..7eed2b1a 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -11,11 +11,20 @@ import { rolesTable, } from "@workspace/db"; import { requireAdmin } from "../middlewares/auth"; +import { emitAppsChangedToUsers } from "../lib/realtime"; import { CreateGroupBody, UpdateGroupBody, } from "@workspace/api-zod"; +async function getGroupMemberIds(groupId: number): Promise { + const rows = await db + .select({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where(eq(userGroupsTable.groupId, groupId)); + return rows.map((r) => r.userId); +} + const router: IRouter = Router(); function serializeGroup(g: typeof groupsTable.$inferSelect) { @@ -276,6 +285,7 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { res.status(400).json({ error: validation.error }); return; } + const previousMemberIds = await getGroupMemberIds(id); const updates: Partial = {}; if (parsed.data.name !== undefined) updates.name = parsed.data.name; if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr; @@ -306,6 +316,22 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { .select({ userId: userGroupsTable.userId }) .from(userGroupsTable) .where(eq(userGroupsTable.groupId, id)); + + const currentMemberIds = users.map((u) => u.userId); + const membershipChanged = parsed.data.userIds !== undefined; + const grantsChanged = + parsed.data.appIds !== undefined || parsed.data.roleIds !== undefined; + const affected = new Set(); + if (membershipChanged) { + for (const uid of previousMemberIds) affected.add(uid); + for (const uid of currentMemberIds) affected.add(uid); + } else if (grantsChanged) { + for (const uid of currentMemberIds) affected.add(uid); + } + if (affected.size > 0) { + await emitAppsChangedToUsers(Array.from(affected)); + } + res.json({ ...serializeGroup(group), appIds: apps.map((a) => a.appId), @@ -356,16 +382,19 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi .insert(userGroupsTable) .values({ groupId: id, userId: targetId }) .onConflictDoNothing(); + await emitAppsChangedToUsers([targetId]); } else if (kind === "apps") { await db .insert(groupAppsTable) .values({ groupId: id, appId: targetId }) .onConflictDoNothing(); + await emitAppsChangedToUsers(await getGroupMemberIds(id)); } else { await db .insert(groupRolesTable) .values({ groupId: id, roleId: targetId }) .onConflictDoNothing(); + await emitAppsChangedToUsers(await getGroupMemberIds(id)); } res.sendStatus(204); }); @@ -387,14 +416,19 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro await db .delete(userGroupsTable) .where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`); + await emitAppsChangedToUsers([targetId]); } else if (kind === "apps") { + const memberIds = await getGroupMemberIds(id); await db .delete(groupAppsTable) .where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`); + await emitAppsChangedToUsers(memberIds); } else { + const memberIds = await getGroupMemberIds(id); await db .delete(groupRolesTable) .where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`); + await emitAppsChangedToUsers(memberIds); } res.sendStatus(204); }); @@ -414,7 +448,9 @@ router.delete("/groups/:id", requireAdmin, async (req, res): Promise => { res.status(400).json({ error: "Cannot delete a system group" }); return; } + const memberIds = await getGroupMemberIds(id); await db.delete(groupsTable).where(eq(groupsTable.id, id)); + await emitAppsChangedToUsers(memberIds); res.sendStatus(204); }); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 75369aec..bb93870e 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -9,6 +9,7 @@ import { userGroupsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; +import { emitAppsChangedToUsers } from "../lib/realtime"; import { RegisterBody, CreateUserBody, @@ -342,6 +343,7 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise => { .onConflictDoNothing(); } }); + await emitAppsChangedToUsers([userId]); } const roles = await getUserRoles(user.id); diff --git a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts index d8060718..eec3f6c1 100644 --- a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts @@ -6,6 +6,8 @@ import { getGetHomeStatsQueryKey, getListMyServiceOrdersQueryKey, getListIncomingServiceOrdersQueryKey, + getListAppsQueryKey, + getGetMeQueryKey, } from "@workspace/api-client-react"; import { useAuth } from "@/contexts/AuthContext"; @@ -59,6 +61,11 @@ export function useNotificationsSocket() { }); }); + socket.on("apps_changed", () => { + queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + }); + return () => { socket.disconnect(); };