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(); };