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:<id>` room.
- `routes/groups.ts`:
  - PATCH /groups/🆔 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
This commit is contained in:
riyadhafraa
2026-04-22 10:20:04 +00:00
parent bc0737aed2
commit 6dd927350d
4 changed files with 53 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
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");
}
}
+36
View File
@@ -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<number[]> {
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<void> => {
res.status(400).json({ error: validation.error });
return;
}
const previousMemberIds = await getGroupMemberIds(id);
const updates: Partial<typeof groupsTable.$inferInsert> = {};
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<void> => {
.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<number>();
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<void> => {
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);
});
+2
View File
@@ -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<void> => {
.onConflictDoNothing();
}
});
await emitAppsChangedToUsers([userId]);
}
const roles = await getUserRoles(user.id);
@@ -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();
};