Files
TX/artifacts/api-server/src/routes/users.ts
T
riyadhafraa e00e015b8b Improve how user roles are managed and displayed on the admin page
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
2026-05-19 06:41:10 +00:00

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;