e00e015b8b
Introduce separation of direct and inherited roles in user profiles and API responses. Modify the admin UI to disable the "order receiver" toggle when a role is inherited, providing a clearer user experience. Update API endpoints and schemas to reflect these changes, alongside locale updates for translated strings. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 33aee19f-8f0f-4399-98e0-39fe09a87e1b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/m92e9kU Replit-Helium-Checkpoint-Created: true
822 lines
25 KiB
TypeScript
822 lines
25 KiB
TypeScript
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<void> => {
|
|
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<GroupSummary[]> {
|
|
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<void> => {
|
|
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<number>(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<void> => {
|
|
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<SQL> = [];
|
|
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<number, string[]>();
|
|
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<number, Set<string>>();
|
|
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<string>();
|
|
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<number, GroupSummary[]>();
|
|
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<number>`count(*)::int`,
|
|
})
|
|
.from(notesTable)
|
|
.where(inArray(notesTable.userId, userIds))
|
|
.groupBy(notesTable.userId);
|
|
const orderRows = await db
|
|
.select({
|
|
userId: serviceOrdersTable.userId,
|
|
count: sql<number>`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<void> => {
|
|
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 "<count> 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<string, unknown> }): {
|
|
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<boolean> {
|
|
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<void> => {
|
|
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<number>`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<void> => {
|
|
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<number>`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<void> => {
|
|
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<void> => {
|
|
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<typeof usersTable.$inferInsert> = {};
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<number>`count(*)::int` })
|
|
.from(notesTable)
|
|
.where(eq(notesTable.userId, userId));
|
|
const [orderRow] = await db
|
|
.select({ count: sql<number>`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;
|