import { Router, type IRouter } from "express"; import { eq, and, or, ilike, inArray, asc, sql, type SQL } from "drizzle-orm"; import { db } from "@workspace/db"; import { usersTable, userRolesTable, rolesTable, groupsTable, groupRolesTable, userGroupsTable, notesTable, serviceOrdersTable, servicesTable, auditLogsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles, getDirectUserRoles } from "../middlewares/auth"; import { emitAppsChangedToUsers } from "../lib/realtime"; import { listPermissionAudit, parsePermissionAuditFilters, recordPermissionAudit, } from "../lib/permission-audit"; import { RegisterBody, CreateUserBody, GetUserParams, UpdateUserParams, UpdateUserBody, DeleteUserParams, AddUserRoleBody, } from "@workspace/api-zod"; import bcrypt from "bcryptjs"; const router: IRouter = Router(); router.get("/users/directory", requireAuth, async (_req, res): Promise => { const users = await db .select({ id: usersTable.id, username: usersTable.username, displayNameAr: usersTable.displayNameAr, displayNameEn: usersTable.displayNameEn, avatarUrl: usersTable.avatarUrl, isActive: usersTable.isActive, }) .from(usersTable) .where(eq(usersTable.isActive, true)); res.json(users); }); export type GroupSummary = { id: number; name: string; descriptionAr: string | null; descriptionEn: string | null; }; export async function getGroupsForUser(userId: number): Promise { const rows = await db .select({ id: groupsTable.id, name: groupsTable.name, descriptionAr: groupsTable.descriptionAr, descriptionEn: groupsTable.descriptionEn, }) .from(userGroupsTable) .innerJoin(groupsTable, eq(userGroupsTable.groupId, groupsTable.id)) .where(eq(userGroupsTable.userId, userId)) .orderBy(groupsTable.name); return rows; } type DependencyCounts = { noteCount: number; orderCount: number; }; function buildUserProfile( user: typeof usersTable.$inferSelect, roles: string[], directRoles: string[], groups: GroupSummary[], counts?: DependencyCounts, ) { return { id: user.id, username: user.username, email: user.email, displayNameAr: user.displayNameAr, displayNameEn: user.displayNameEn, preferredLanguage: user.preferredLanguage, clockStyle: user.clockStyle, clockHour12: user.clockHour12, avatarUrl: user.avatarUrl, isActive: user.isActive, roles, directRoles, groups, createdAt: user.createdAt, ...(counts ?? {}), }; } router.post("/users", requireAdmin, async (req, res): Promise => { const parsed = CreateUserBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const existing = await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.username, parsed.data.username)); if (existing.length > 0) { res.status(409).json({ error: "Username already taken" }); return; } const requestedGroupIds = Array.isArray((parsed.data as { groupIds?: unknown }).groupIds) ? ((parsed.data as { groupIds?: number[] }).groupIds ?? []).filter( (id): id is number => Number.isInteger(id), ) : []; if (requestedGroupIds.length > 0) { const found = await db .select({ id: groupsTable.id }) .from(groupsTable) .where(inArray(groupsTable.id, requestedGroupIds)); if (found.length !== new Set(requestedGroupIds).size) { res.status(400).json({ error: "One or more groupIds are invalid" }); return; } } const passwordHash = await bcrypt.hash(parsed.data.password, 12); const user = await db.transaction(async (tx) => { const [created] = await tx .insert(usersTable) .values({ username: parsed.data.username, email: parsed.data.email, passwordHash, displayNameAr: parsed.data.displayNameAr ?? null, displayNameEn: parsed.data.displayNameEn ?? null, preferredLanguage: parsed.data.preferredLanguage, }) .returning(); if (!created) throw new Error("Failed to create user"); // Auto-assign Everyone group if it exists const [everyone] = await tx .select({ id: groupsTable.id }) .from(groupsTable) .where(eq(groupsTable.name, "Everyone")); const groupIdsToAssign = new Set(requestedGroupIds); if (everyone) groupIdsToAssign.add(everyone.id); if (groupIdsToAssign.size > 0) { await tx .insert(userGroupsTable) .values( Array.from(groupIdsToAssign).map((groupId) => ({ userId: created.id, groupId, })), ) .onConflictDoNothing(); } return created; }); const groups = await getGroupsForUser(user.id); res.status(201).json(buildUserProfile(user, [], [], groups)); }); router.get("/users", requireAdmin, async (req, res): Promise => { const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; const groupIdRaw = req.query.groupId; const groupId = typeof groupIdRaw === "string" && /^\d+$/.test(groupIdRaw) ? Number(groupIdRaw) : undefined; const status = typeof req.query.status === "string" ? req.query.status : ""; const conditions: Array = []; if (q) { const pattern = `%${q}%`; const orExpr = or( ilike(usersTable.username, pattern), ilike(usersTable.email, pattern), ilike(usersTable.displayNameAr, pattern), ilike(usersTable.displayNameEn, pattern), ); if (orExpr) conditions.push(orExpr); } if (status === "active") conditions.push(eq(usersTable.isActive, true)); if (status === "disabled" || status === "inactive") conditions.push(eq(usersTable.isActive, false)); if (groupId !== undefined) { const memberRows = await db .select({ userId: userGroupsTable.userId }) .from(userGroupsTable) .where(eq(userGroupsTable.groupId, groupId)); const userIdsForGroup = memberRows.map((r) => r.userId); if (userIdsForGroup.length === 0) { res.json([]); return; } conditions.push(inArray(usersTable.id, userIdsForGroup)); } const whereClause = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions); const users = whereClause ? await db.select().from(usersTable).where(whereClause).orderBy(asc(usersTable.createdAt)) : await db.select().from(usersTable).orderBy(asc(usersTable.createdAt)); if (users.length === 0) { res.json([]); return; } const userIds = users.map((u) => u.id); // Batch load direct roles (user_roles table only). const directRoleRows = await db .select({ userId: userRolesTable.userId, roleName: rolesTable.name }) .from(userRolesTable) .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) .where(inArray(userRolesTable.userId, userIds)); const directRolesByUser = new Map(); for (const r of directRoleRows) { const list = directRolesByUser.get(r.userId) ?? []; list.push(r.roleName); directRolesByUser.set(r.userId, list); } // Batch load roles inherited via group membership so the effective `roles` // field matches GET /users/:id and the auth middleware. Without this the // admin Users list shows direct-only roles while the actual permission // check uses the effective set — confusing when a user receives orders // via a group but the row in the list shows no order_receiver role. const inheritedRoleRows = await db .select({ userId: userGroupsTable.userId, roleName: rolesTable.name }) .from(userGroupsTable) .innerJoin(groupRolesTable, eq(groupRolesTable.groupId, userGroupsTable.groupId)) .innerJoin(rolesTable, eq(groupRolesTable.roleId, rolesTable.id)) .where(inArray(userGroupsTable.userId, userIds)); const rolesByUser = new Map>(); for (const [uid, names] of directRolesByUser) { rolesByUser.set(uid, new Set(names)); } for (const r of inheritedRoleRows) { const set = rolesByUser.get(r.userId) ?? new Set(); set.add(r.roleName); rolesByUser.set(r.userId, set); } // Batch load groups const groupRows = await db .select({ userId: userGroupsTable.userId, id: groupsTable.id, name: groupsTable.name, descriptionAr: groupsTable.descriptionAr, descriptionEn: groupsTable.descriptionEn, }) .from(userGroupsTable) .innerJoin(groupsTable, eq(userGroupsTable.groupId, groupsTable.id)) .where(inArray(userGroupsTable.userId, userIds)) .orderBy(groupsTable.name); const groupsByUser = new Map(); for (const g of groupRows) { const list = groupsByUser.get(g.userId) ?? []; list.push({ id: g.id, name: g.name, descriptionAr: g.descriptionAr, descriptionEn: g.descriptionEn, }); groupsByUser.set(g.userId, list); } // Batch load dependency counts so the admin delete dialog can warn on the // first click. Mirrors the pattern used by GET /groups. The lazy 409 // fallback in DELETE /users/:id remains as a safety net. const noteRows = await db .select({ userId: notesTable.userId, count: sql`count(*)::int`, }) .from(notesTable) .where(inArray(notesTable.userId, userIds)) .groupBy(notesTable.userId); const orderRows = await db .select({ userId: serviceOrdersTable.userId, count: sql`count(*)::int`, }) .from(serviceOrdersTable) .where(inArray(serviceOrdersTable.userId, userIds)) .groupBy(serviceOrdersTable.userId); const noteMap = new Map(noteRows.map((r) => [r.userId, r.count])); const orderMap = new Map(orderRows.map((r) => [r.userId, r.count])); res.json( users.map((u) => buildUserProfile( u, Array.from(rolesByUser.get(u.id) ?? []), directRolesByUser.get(u.id) ?? [], groupsByUser.get(u.id) ?? [], { noteCount: noteMap.get(u.id) ?? 0, orderCount: orderMap.get(u.id) ?? 0, }, ), ), ); }); router.get( "/users/:id/audit", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id)) { res.status(400).json({ error: "Invalid id" }); return; } const limitRaw = Number(req.query.limit); const offsetRaw = Number(req.query.offset); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 10; const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0; const [user] = await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.id, id)); if (!user) { res.status(404).json({ error: "User not found" }); return; } const filters = parsePermissionAuditFilters(req, res); if (!filters) return; const result = await listPermissionAudit({ targetKind: "user", targetId: id, limit, offset, filters, }); res.json(result); }, ); // ===== Dependent-item drill-ins ===== // // Each " X" badge on the admin Users row is backed by one of these // endpoints so admins can click through to see *which* notes, orders, // conversations, and messages the user has produced. Pagination shape // mirrors /admin/apps/:id/dependents/* so a single drill-in component on // the frontend can consume them all. const USER_DEPENDENTS_DEFAULT_LIMIT = 50; const USER_DEPENDENTS_MAX_LIMIT = 200; function parseUserDependentsPaging(req: { query: Record }): { limit: number; offset: number; } { const limitRaw = Number(req.query.limit); const offsetRaw = Number(req.query.offset); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), USER_DEPENDENTS_MAX_LIMIT) : USER_DEPENDENTS_DEFAULT_LIMIT; const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0; return { limit, offset }; } async function ensureUserExists(id: number): Promise { const [row] = await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.id, id)); return !!row; } router.get( "/admin/users/:id/dependents/notes", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { res.status(400).json({ error: "Invalid id" }); return; } if (!(await ensureUserExists(id))) { res.status(404).json({ error: "User not found" }); return; } const { limit, offset } = parseUserDependentsPaging(req); const [{ count: totalCount }] = await db .select({ count: sql`count(*)::int` }) .from(notesTable) .where(eq(notesTable.userId, id)); const items = await db .select({ id: notesTable.id, title: notesTable.title, isPinned: notesTable.isPinned, isArchived: notesTable.isArchived, updatedAt: notesTable.updatedAt, }) .from(notesTable) .where(eq(notesTable.userId, id)) .orderBy(sql`${notesTable.updatedAt} desc`) .limit(limit) .offset(offset); const nextOffset = offset + items.length < totalCount ? offset + items.length : null; res.json({ items, totalCount, limit, offset, nextOffset }); }, ); router.get( "/admin/users/:id/dependents/orders", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { res.status(400).json({ error: "Invalid id" }); return; } if (!(await ensureUserExists(id))) { res.status(404).json({ error: "User not found" }); return; } const { limit, offset } = parseUserDependentsPaging(req); const [{ count: totalCount }] = await db .select({ count: sql`count(*)::int` }) .from(serviceOrdersTable) .where(eq(serviceOrdersTable.userId, id)); // Join service so the popover can identify what was ordered without // a follow-up fetch — the count badge says "12 orders" but admins // need the service names to investigate before reassigning. const items = await db .select({ id: serviceOrdersTable.id, status: serviceOrdersTable.status, notes: serviceOrdersTable.notes, createdAt: serviceOrdersTable.createdAt, serviceId: servicesTable.id, serviceNameAr: servicesTable.nameAr, serviceNameEn: servicesTable.nameEn, }) .from(serviceOrdersTable) .innerJoin(servicesTable, eq(serviceOrdersTable.serviceId, servicesTable.id)) .where(eq(serviceOrdersTable.userId, id)) .orderBy(sql`${serviceOrdersTable.createdAt} desc`) .limit(limit) .offset(offset); const nextOffset = offset + items.length < totalCount ? offset + items.length : null; res.json({ items, totalCount, limit, offset, nextOffset }); }, ); router.get("/users/:id", requireAdmin, async (req, res): Promise => { const params = GetUserParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const [user] = await db .select() .from(usersTable) .where(eq(usersTable.id, params.data.id)); if (!user) { res.status(404).json({ error: "User not found" }); return; } const roles = await getUserRoles(user.id); const directRoles = await getDirectUserRoles(user.id); const groups = await getGroupsForUser(user.id); res.json(buildUserProfile(user, roles, directRoles, groups)); }); router.patch("/users/:id", requireAdmin, async (req, res): Promise => { const params = UpdateUserParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const parsed = UpdateUserBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const updateData: Partial = {}; if (parsed.data.displayNameAr !== undefined) updateData.displayNameAr = parsed.data.displayNameAr; if (parsed.data.displayNameEn !== undefined) updateData.displayNameEn = parsed.data.displayNameEn; if (parsed.data.isActive !== undefined) updateData.isActive = parsed.data.isActive; if (parsed.data.preferredLanguage !== undefined) updateData.preferredLanguage = parsed.data.preferredLanguage; const userId = params.data.id; // Validate groupIds (if provided) BEFORE any destructive change to memberships. if (parsed.data.groupIds !== undefined && parsed.data.groupIds.length > 0) { const requested = parsed.data.groupIds; const found = await db .select({ id: groupsTable.id }) .from(groupsTable) .where(inArray(groupsTable.id, requested)); if (found.length !== new Set(requested).size) { res.status(400).json({ error: "One or more groupIds are invalid" }); return; } } let user: typeof usersTable.$inferSelect | undefined; if (Object.keys(updateData).length > 0) { [user] = await db .update(usersTable) .set(updateData) .where(eq(usersTable.id, userId)) .returning(); } else { [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); } if (!user) { res.status(404).json({ error: "User not found" }); return; } if (parsed.data.groupIds !== undefined) { // Same-transaction audit: read previous group ids, apply the change, and // write the permission_audit row atomically so the history can never // disagree with reality. await db.transaction(async (tx) => { const previousIds = ( await tx .select({ groupId: userGroupsTable.groupId }) .from(userGroupsTable) .where(eq(userGroupsTable.userId, userId)) ).map((r) => r.groupId); await tx.delete(userGroupsTable).where(eq(userGroupsTable.userId, userId)); if (parsed.data.groupIds && parsed.data.groupIds.length > 0) { await tx .insert(userGroupsTable) .values(parsed.data.groupIds.map((groupId) => ({ userId, groupId }))) .onConflictDoNothing(); } await recordPermissionAudit(tx, { targetKind: "user", targetId: userId, changeKind: "user.groups", actorUserId: req.session.userId ?? null, previousIds, newIds: parsed.data.groupIds ?? [], }); // Mirror onto each affected group so its history shows the // membership change regardless of which editor was used. const newIds = parsed.data.groupIds ?? []; const added = newIds.filter((gid) => !previousIds.includes(gid)); const removed = previousIds.filter((gid) => !newIds.includes(gid)); for (const gid of [...added, ...removed]) { const newUserIds = ( await tx .select({ userId: userGroupsTable.userId }) .from(userGroupsTable) .where(eq(userGroupsTable.groupId, gid)) ).map((r) => r.userId); const previousUserIds = added.includes(gid) ? newUserIds.filter((u) => u !== userId) : [...newUserIds, userId]; await recordPermissionAudit(tx, { targetKind: "group", targetId: gid, changeKind: "group.users", actorUserId: req.session.userId ?? null, previousIds: previousUserIds, newIds: newUserIds, }); } }); await emitAppsChangedToUsers([userId]); } const roles = await getUserRoles(user.id); const directRoles = await getDirectUserRoles(user.id); const groups = await getGroupsForUser(user.id); res.json(buildUserProfile(user, roles, directRoles, groups)); }); router.post("/users/:id/roles", requireAdmin, async (req, res): Promise => { const params = GetUserParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const parsed = AddUserRoleBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const [user] = await db .select() .from(usersTable) .where(eq(usersTable.id, params.data.id)); if (!user) { res.status(404).json({ error: "User not found" }); return; } const [role] = await db .select({ id: rolesTable.id }) .from(rolesTable) .where(eq(rolesTable.name, parsed.data.roleName)); if (!role) { res.status(404).json({ error: "Role not found" }); return; } const inserted = await db.transaction(async (tx) => { const previousRoleIds = ( await tx .select({ roleId: userRolesTable.roleId }) .from(userRolesTable) .where(eq(userRolesTable.userId, user.id)) ).map((r) => r.roleId); const ins = await tx .insert(userRolesTable) .values({ userId: user.id, roleId: role.id }) .onConflictDoNothing() .returning(); if (ins.length > 0) { await recordPermissionAudit(tx, { targetKind: "user", targetId: user.id, changeKind: "user.roles", actorUserId: req.session.userId ?? null, previousIds: previousRoleIds, newIds: [...previousRoleIds, role.id], }); } return ins; }); if (inserted.length > 0) { await emitAppsChangedToUsers([user.id]); } const roles = await getUserRoles(user.id); const directRoles = await getDirectUserRoles(user.id); const groups = await getGroupsForUser(user.id); res.json(buildUserProfile(user, roles, directRoles, groups)); }); router.delete( "/users/:id/roles/:roleName", requireAdmin, async (req, res): Promise => { const params = GetUserParams.safeParse({ id: req.params.id }); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const roleName = String(req.params.roleName ?? "").trim(); if (!roleName) { res.status(400).json({ error: "roleName required" }); return; } const [user] = await db .select() .from(usersTable) .where(eq(usersTable.id, params.data.id)); if (!user) { res.status(404).json({ error: "User not found" }); return; } const [role] = await db .select({ id: rolesTable.id }) .from(rolesTable) .where(eq(rolesTable.name, roleName)); if (role) { const deleted = await db.transaction(async (tx) => { const previousRoleIds = ( await tx .select({ roleId: userRolesTable.roleId }) .from(userRolesTable) .where(eq(userRolesTable.userId, user.id)) ).map((r) => r.roleId); const del = await tx .delete(userRolesTable) .where( and( eq(userRolesTable.userId, user.id), eq(userRolesTable.roleId, role.id), ), ) .returning(); if (del.length > 0) { await recordPermissionAudit(tx, { targetKind: "user", targetId: user.id, changeKind: "user.roles", actorUserId: req.session.userId ?? null, previousIds: previousRoleIds, newIds: previousRoleIds.filter((rid) => rid !== role.id), }); } return del; }); if (deleted.length > 0) { await emitAppsChangedToUsers([user.id]); } } const roles = await getUserRoles(user.id); const directRoles = await getDirectUserRoles(user.id); const groups = await getGroupsForUser(user.id); res.json(buildUserProfile(user, roles, directRoles, groups)); }, ); router.delete("/users/:id", requireAdmin, async (req, res): Promise => { const params = DeleteUserParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } if (req.session.userId === params.data.id) { res.status(400).json({ error: "You cannot delete your own account" }); return; } const userId = params.data.id; const [existing] = await db .select() .from(usersTable) .where(eq(usersTable.id, userId)); if (!existing) { res.status(404).json({ error: "User not found" }); return; } const force = req.query.force === "true" || req.query.force === "1"; const [noteRow] = await db .select({ count: sql`count(*)::int` }) .from(notesTable) .where(eq(notesTable.userId, userId)); const [orderRow] = await db .select({ count: sql`count(*)::int` }) .from(serviceOrdersTable) .where(eq(serviceOrdersTable.userId, userId)); const noteCount = noteRow?.count ?? 0; const orderCount = orderRow?.count ?? 0; const hasDeps = noteCount > 0 || orderCount > 0; if (hasDeps && !force) { res.status(409).json({ error: "User has dependent records", noteCount, orderCount, }); return; } await db.transaction(async (tx) => { await tx.delete(usersTable).where(eq(usersTable.id, userId)); }); await db.insert(auditLogsTable).values({ actorUserId: req.session.userId ?? null, action: "user.delete", targetType: "user", targetId: userId, metadata: { username: existing.username, displayNameEn: existing.displayNameEn, displayNameAr: existing.displayNameAr, email: existing.email, force, ...(hasDeps ? { noteCount, orderCount } : {}), }, }); res.sendStatus(204); }); export default router;