Files
TX/artifacts/api-server/src/routes/roles.ts
T
riyadhafraa 10a48657c7 Push role permission changes to signed-in users in real time
Original task (#101): When an admin saves the role editor, role holders
need their permission-driven UI (admin nav items, permission-gated
action buttons, etc.) to refresh without waiting for a page reload.
The server already emitted `apps_changed`, but that event is
semantically about visible apps, not the broader cached permission
state, and the client only invalidated the apps list and `/api/auth/me`.

Changes:
- artifacts/api-server/src/lib/realtime.ts
  - Extracted role-holder resolution (direct user_roles + indirect via
    group_roles -> user_groups) into a private helper.
  - Added `emitRolePermissionsChangedToHolders(roleId)` that emits a
    new `role_permissions_changed` socket event with `{ roleId }` to
    every holder of the role.
- artifacts/api-server/src/routes/roles.ts
  - After `PUT /api/roles/:id/permissions` succeeds, call the new
    emitter alongside the existing `emitAppsChangedToRoleHolders`.
- artifacts/tx-os/src/hooks/use-notifications-socket.ts
  - Subscribed to `role_permissions_changed`. Invalidates
    `getMe`, `listRoles`, `listPermissions`, and—when the payload
    includes the role id—`getRolePermissions`, the role permission
    audit, and role usage queries so admin-only UI re-evaluates without
    a refresh.

Scope notes / deviations:
- The task spec called out `PUT /api/roles/:id/permissions` only, so
  the matching POST/DELETE single-permission endpoints still emit only
  `apps_changed`. Filed as follow-up #150 to keep them consistent.
- No new automated tests added (filed as follow-up #151). Pre-existing
  typecheck errors in artifacts/api-server/src/routes/executive-meetings.ts
  are unrelated and untouched.
- `replit.md` not updated; this is a behavioral refinement of an
  existing socket flow, not an architectural change.

Replit-Task-Id: bd31a9a9-dbf1-497a-8505-96672f92bc79
2026-04-29 13:24:16 +00:00

685 lines
22 KiB
TypeScript

import { Router, type IRouter } from "express";
import { desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
rolesTable,
userRolesTable,
groupRolesTable,
groupsTable,
userGroupsTable,
rolePermissionsTable,
permissionsTable,
auditLogsTable,
rolePermissionAuditTable,
usersTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import {
CreateRoleBody,
UpdateRoleBody,
ReplaceRolePermissionsBody,
} from "@workspace/api-zod";
import {
emitAppsChangedToRoleHolders,
emitRolePermissionsChangedToHolders,
} from "../lib/realtime.js";
const router: IRouter = Router();
const PROTECTED_ROLE_NAMES = new Set(["admin", "user", "order_receiver"]);
function isSystemRole(role: { isSystem: number | boolean; name: string }): boolean {
return Boolean(role.isSystem) || PROTECTED_ROLE_NAMES.has(role.name);
}
function serializePermission(p: typeof permissionsTable.$inferSelect) {
return {
id: p.id,
name: p.name,
descriptionAr: p.descriptionAr,
descriptionEn: p.descriptionEn,
};
}
function serializeRole(r: typeof rolesTable.$inferSelect) {
return {
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
isSystem: Boolean(r.isSystem),
createdAt: r.createdAt,
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(rows.map(serializeRole));
});
router.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(permissionsTable).orderBy(permissionsTable.name);
res.json(rows.map(serializePermission));
});
router.get("/roles/: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 limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 50)
: 10;
const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
const rows = await db
.select({
id: rolePermissionAuditTable.id,
roleId: rolePermissionAuditTable.roleId,
previousPermissionIds: rolePermissionAuditTable.previousPermissionIds,
newPermissionIds: rolePermissionAuditTable.newPermissionIds,
createdAt: rolePermissionAuditTable.createdAt,
actorUserId: rolePermissionAuditTable.actorUserId,
actorUsername: usersTable.username,
actorDisplayNameAr: usersTable.displayNameAr,
actorDisplayNameEn: usersTable.displayNameEn,
actorAvatarUrl: usersTable.avatarUrl,
})
.from(rolePermissionAuditTable)
.leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId))
.where(eq(rolePermissionAuditTable.roleId, id))
.orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id))
.limit(limit);
const entries = rows.map((r) => {
const prev = (r.previousPermissionIds ?? []) as number[];
const next = (r.newPermissionIds ?? []) as number[];
const prevSet = new Set(prev);
const nextSet = new Set(next);
return {
id: r.id,
roleId: r.roleId,
previousPermissionIds: prev,
newPermissionIds: next,
addedPermissionIds: next.filter((p) => !prevSet.has(p)),
removedPermissionIds: prev.filter((p) => !nextSet.has(p)),
createdAt: r.createdAt,
actor:
r.actorUserId != null && r.actorUsername != null
? {
id: r.actorUserId,
username: r.actorUsername,
displayNameAr: r.actorDisplayNameAr,
displayNameEn: r.actorDisplayNameEn,
avatarUrl: r.actorAvatarUrl,
}
: null,
};
});
res.json(entries);
});
router.get("/roles/:id/usage", 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 [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
const [userCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, id));
const [groupCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, id));
res.json({
userCount: userCountRow?.count ?? 0,
groupCount: groupCountRow?.count ?? 0,
});
});
router.post("/roles", requireAdmin, async (req, res): Promise<void> => {
const parsed = CreateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const name = parsed.data.name.trim();
if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
res.status(400).json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
return;
}
const existing = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.name, name));
if (existing.length > 0) {
res.status(409).json({ error: "Role name already taken" });
return;
}
const [role] = await db
.insert(rolesTable)
.values({
name,
descriptionAr: parsed.data.descriptionAr ?? null,
descriptionEn: parsed.data.descriptionEn ?? null,
})
.returning();
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.create",
targetType: "role",
targetId: role.id,
metadata: {
name: role.name,
descriptionAr: role.descriptionAr,
descriptionEn: role.descriptionEn,
},
});
res.status(201).json(serializeRole(role));
});
router.patch("/roles/:id", 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 parsed = UpdateRoleBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
res.status(404).json({ error: "Role not found" });
return;
}
const updates: Partial<typeof rolesTable.$inferInsert> = {};
if (parsed.data.name !== undefined) {
const name = parsed.data.name.trim();
if (name !== existing.name) {
if (isSystemRole(existing)) {
res.status(400).json({ error: "Cannot rename a system role", code: "system_role_rename" });
return;
}
if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
res
.status(400)
.json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" });
return;
}
const taken = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.name, name));
if (taken.some((r) => r.id !== id)) {
res.status(409).json({ error: "Role name already taken" });
return;
}
updates.name = name;
}
}
if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr;
if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn;
if (Object.keys(updates).length > 0) {
await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id));
const renamed = updates.name !== undefined && updates.name !== existing.name;
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.update",
targetType: "role",
targetId: id,
metadata: {
previousName: existing.name,
name: updates.name ?? existing.name,
renamed,
...(renamed
? { renamedFrom: existing.name, renamedTo: updates.name }
: {}),
changes: updates,
},
});
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
res.json(serializeRole(role));
});
router.delete("/roles/:id", 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 [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!existing) {
res.status(404).json({ error: "Role not found" });
return;
}
if (isSystemRole(existing)) {
res.status(400).json({ error: "Cannot delete a system role" });
return;
}
const [userCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, id));
const [groupCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, id));
const userCount = userCountRow?.count ?? 0;
const groupCount = groupCountRow?.count ?? 0;
if (userCount > 0 || groupCount > 0) {
res.status(409).json({
error: "Role is in use",
userCount,
groupCount,
});
return;
}
await db.transaction(async (tx) => {
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
await tx.delete(rolesTable).where(eq(rolesTable.id, id));
});
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.delete",
targetType: "role",
targetId: id,
metadata: {
name: existing.name,
descriptionAr: existing.descriptionAr,
descriptionEn: existing.descriptionEn,
},
});
res.sendStatus(204);
});
router.get("/roles/:id/permissions", 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 [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
const rows = await db
.select({
id: permissionsTable.id,
name: permissionsTable.name,
descriptionAr: permissionsTable.descriptionAr,
descriptionEn: permissionsTable.descriptionEn,
})
.from(rolePermissionsTable)
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
.where(eq(rolePermissionsTable.roleId, id))
.orderBy(permissionsTable.name);
res.json(rows);
});
router.post(
"/roles/:id/permissions/impact-preview",
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 candidate = req.body?.permissionIds;
if (!Array.isArray(candidate) || !candidate.every((n) => Number.isInteger(n) && n > 0)) {
res.status(400).json({ error: "permissionIds must be an array of positive integers" });
return;
}
const [role] = await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
const candidateSet = new Set<number>(candidate as number[]);
const currentPerms = await db
.select({ id: permissionsTable.id, name: permissionsTable.name })
.from(rolePermissionsTable)
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
.where(eq(rolePermissionsTable.roleId, id));
const removed = currentPerms.filter((p) => !candidateSet.has(p.id));
if (removed.length === 0) {
res.json({ removed: [], totalAffectedUsers: 0 });
return;
}
const groups = await db
.select({ id: groupsTable.id, name: groupsTable.name })
.from(groupRolesTable)
.innerJoin(groupsTable, eq(groupRolesTable.groupId, groupsTable.id))
.where(eq(groupRolesTable.roleId, id))
.orderBy(groupsTable.name);
// Users that currently hold this role: directly OR via any group that grants it.
const directUsers = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, id));
const groupIds = groups.map((g) => g.id);
const indirectUsers = groupIds.length
? await db
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, groupIds))
: [];
const roleHolderIds = Array.from(
new Set<number>([
...directUsers.map((r) => r.userId),
...indirectUsers.map((r) => r.userId),
]),
);
const allAffected = new Set<number>();
const items: Array<{
permissionId: number;
permissionName: string;
userCount: number;
groupCount: number;
groups: Array<{ id: number; name: string }>;
}> = [];
if (roleHolderIds.length === 0) {
for (const p of removed) {
items.push({
permissionId: p.id,
permissionName: p.name,
userCount: 0,
groupCount: groups.length,
groups,
});
}
res.json({ removed: items, totalAffectedUsers: 0 });
return;
}
// For each removed permission, find which role-holders would still have it
// through some OTHER role (direct or via any group).
for (const p of removed) {
const directOther = await db
.selectDistinct({ userId: userRolesTable.userId })
.from(userRolesTable)
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, userRolesTable.roleId),
)
.where(
sql`${inArray(userRolesTable.userId, roleHolderIds)} AND ${userRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
);
const indirectOther = await db
.selectDistinct({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.innerJoin(
groupRolesTable,
eq(groupRolesTable.groupId, userGroupsTable.groupId),
)
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
)
.where(
sql`${inArray(userGroupsTable.userId, roleHolderIds)} AND ${groupRolesTable.roleId} <> ${id} AND ${rolePermissionsTable.permissionId} = ${p.id}`,
);
const stillHave = new Set<number>([
...directOther.map((r) => r.userId),
...indirectOther.map((r) => r.userId),
]);
let lost = 0;
for (const uid of roleHolderIds) {
if (!stillHave.has(uid)) {
lost += 1;
allAffected.add(uid);
}
}
items.push({
permissionId: p.id,
permissionName: p.name,
userCount: lost,
groupCount: groups.length,
groups,
});
}
items.sort((a, b) => a.permissionName.localeCompare(b.permissionName));
res.json({ removed: items, totalAffectedUsers: allAffected.size });
},
);
router.put("/roles/:id/permissions", 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 parsed = ReplaceRolePermissionsBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
if (isSystemRole(role)) {
res
.status(400)
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
return;
}
if (!parsed.data.permissionIds.every((n) => Number.isInteger(n) && n > 0)) {
res.status(400).json({ error: "permissionIds must be positive integers" });
return;
}
const requestedIds = Array.from(new Set(parsed.data.permissionIds));
if (requestedIds.length > 0) {
const found = await db
.select({ id: permissionsTable.id })
.from(permissionsTable)
.where(inArray(permissionsTable.id, requestedIds));
if (found.length !== requestedIds.length) {
res.status(404).json({ error: "Permission not found" });
return;
}
}
// Apply the permission replacement, the generic audit_logs row, and the
// dedicated role_permission_audit row in a single transaction so the
// permission set and its audit trail are guaranteed to commit together.
// Reading the previous IDs inside the transaction also avoids racing with
// a concurrent edit between the read and the write.
await db.transaction(async (tx) => {
const previousIds = (
await tx
.select({ permissionId: rolePermissionsTable.permissionId })
.from(rolePermissionsTable)
.where(eq(rolePermissionsTable.roleId, id))
).map((r) => r.permissionId);
const previousSet = new Set(previousIds);
const requestedSet = new Set(requestedIds);
const added = requestedIds.filter((p) => !previousSet.has(p));
const removed = previousIds.filter((p) => !requestedSet.has(p));
const changed = added.length > 0 || removed.length > 0;
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
if (requestedIds.length > 0) {
await tx
.insert(rolePermissionsTable)
.values(requestedIds.map((permissionId) => ({ roleId: id, permissionId })));
}
// Dedicated role-permission audit row written on EVERY PUT call (per
// task spec) so we have a complete record of who reviewed/saved the
// permission set at any point in time, even when the resulting set
// happens to be unchanged. The History UI uses
// addedPermissionIds/removedPermissionIds (computed in the GET
// handler) to surface meaningful changes vs. no-op saves.
await tx.insert(rolePermissionAuditTable).values({
roleId: id,
actorUserId: req.session.userId ?? null,
previousPermissionIds: [...previousIds].sort((a, b) => a - b),
newPermissionIds: [...requestedIds].sort((a, b) => a - b),
});
// The legacy audit_logs entry is kept for backward compatibility
// with the existing audit log UI, but only when the set actually
// changes (matches its long-standing semantics for this action).
if (changed) {
await tx.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.permissions.replace",
targetType: "role",
targetId: id,
metadata: {
roleName: role.name,
added,
removed,
},
});
}
});
await emitAppsChangedToRoleHolders(id);
await emitRolePermissionsChangedToHolders(id);
const rows = await db
.select({
id: permissionsTable.id,
name: permissionsTable.name,
descriptionAr: permissionsTable.descriptionAr,
descriptionEn: permissionsTable.descriptionEn,
})
.from(rolePermissionsTable)
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
.where(eq(rolePermissionsTable.roleId, id))
.orderBy(permissionsTable.name);
res.json(rows);
});
router.post("/roles/:id/permissions", 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 permissionId = Number(req.body?.permissionId);
if (!Number.isInteger(permissionId)) {
res.status(400).json({ error: "permissionId is required" });
return;
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
if (isSystemRole(role)) {
res
.status(400)
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
return;
}
const [perm] = await db
.select({ id: permissionsTable.id, name: permissionsTable.name })
.from(permissionsTable)
.where(eq(permissionsTable.id, permissionId));
if (!perm) {
res.status(404).json({ error: "Permission not found" });
return;
}
const existing = await db
.select({ roleId: rolePermissionsTable.roleId })
.from(rolePermissionsTable)
.where(sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`);
if (existing.length === 0) {
await db.insert(rolePermissionsTable).values({ roleId: id, permissionId });
await emitAppsChangedToRoleHolders(id);
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.permission.add",
targetType: "role",
targetId: id,
metadata: {
roleName: role.name,
permissionId,
permissionName: perm.name,
},
});
}
res.status(201).json({ roleId: id, permissionId });
});
router.delete("/roles/:id/permissions/:permissionId", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
const permissionId = Number(req.params.permissionId);
if (!Number.isInteger(id) || !Number.isInteger(permissionId)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
if (isSystemRole(role)) {
res
.status(400)
.json({ error: "Cannot modify a system role's permissions", code: "system_role_permissions" });
return;
}
const [perm] = await db
.select({ name: permissionsTable.name })
.from(permissionsTable)
.where(eq(permissionsTable.id, permissionId));
const deleted = await db
.delete(rolePermissionsTable)
.where(
sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`,
)
.returning();
if (deleted.length > 0) {
await emitAppsChangedToRoleHolders(id);
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "role.permission.remove",
targetType: "role",
targetId: id,
metadata: {
roleName: role.name,
permissionId,
permissionName: perm?.name ?? null,
},
});
}
res.sendStatus(204);
});
export default router;