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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user