87b16fd256
Landed 3 of 11 umbrella items, deferred the rest as 3 well-scoped follow-ups. #231 — POST /apps with permissionIds[] is now pinned by two tests in app-permissions-crud.test.mjs: success commits the app + permission rows together with an audit_logs row, and an unknown permissionId returns 404 without leaving an orphan app row or a stray app.create audit row. Extended the after() to clean up audit_logs + permission_audit so reruns stay idempotent. #215 — Added two socket tests in role-permissions-realtime.test.mjs for the per-permission POST and DELETE endpoints, mirroring the existing PUT coverage. Both assert direct + group-derived holders receive role_permissions_changed and outsiders do not. Each test creates a fresh role via makeFreshRoleWithMembers() so prior state can't bleed in. #216 — Found a real gap: apps.ts emitted nothing when an app's required- permission set changed. Added emitAppsChangedToPermissionHolders() to lib/realtime.ts (resolves users via role_permissions -> user_roles and group_roles -> user_groups, dedupes, reuses emitAppsChangedToUsers), and wired it into POST/DELETE /apps/:id/permissions — only emitted when an actual row was inserted/deleted, not on no-op retries. New test file apps-permissions-realtime.test.mjs covers direct holder + group-derived holder receipt and an idempotent no-op DELETE NOT emitting. Skipped (already done): #226 (non-admin gates already covered), #229 (impact-preview already handles the removal branch). Validation: 13/13 tests across the 3 modified files pass; 66/66 across related permission/audit suites pass; full server suite is 236/238 with the 2 failures (executive-meetings notifications, service-orders status matrix) being pre-existing in untouched files. Architect review: APPROVED with no critical/high findings; took the optional hardening suggestion to add group-holder coverage to the #216 tests so both legs of the helper's resolution path are exercised. Files: artifacts/api-server/src/lib/realtime.ts, artifacts/api-server/src/routes/apps.ts, artifacts/api-server/tests/app-permissions-crud.test.mjs, artifacts/api-server/tests/role-permissions-realtime.test.mjs, artifacts/api-server/tests/apps-permissions-realtime.test.mjs (new)
939 lines
29 KiB
TypeScript
939 lines
29 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
|
import { db } from "@workspace/db";
|
|
import {
|
|
appsTable,
|
|
appPermissionsTable,
|
|
userRolesTable,
|
|
rolePermissionsTable,
|
|
rolesTable,
|
|
userAppOrdersTable,
|
|
appOpensTable,
|
|
userGroupsTable,
|
|
groupAppsTable,
|
|
groupRolesTable,
|
|
groupsTable,
|
|
auditLogsTable,
|
|
permissionsTable,
|
|
usersTable,
|
|
} from "@workspace/db";
|
|
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
|
import {
|
|
listPermissionAudit,
|
|
parsePermissionAuditFilters,
|
|
recordPermissionAudit,
|
|
} from "../lib/permission-audit";
|
|
import { emitAppsChangedToPermissionHolders } from "../lib/realtime";
|
|
import {
|
|
CreateAppBody,
|
|
UpdateAppBody,
|
|
GetAppParams,
|
|
UpdateAppParams,
|
|
DeleteAppParams,
|
|
UpdateMyAppOrderBody,
|
|
} from "@workspace/api-zod";
|
|
|
|
const router: IRouter = Router();
|
|
|
|
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
|
|
const effectiveRoleIds = await getEffectiveRoleIds(userId);
|
|
|
|
const adminRoleRows = effectiveRoleIds.length > 0
|
|
? await db
|
|
.select({ id: rolesTable.id })
|
|
.from(rolesTable)
|
|
.where(and(inArray(rolesTable.id, effectiveRoleIds), eq(rolesTable.name, "admin")))
|
|
: [];
|
|
|
|
const isAdmin = adminRoleRows.length > 0;
|
|
|
|
// Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
|
|
// Admins receive ALL apps (including inactive ones) so they can re-enable them.
|
|
// Non-admins only receive active apps.
|
|
const baseQuery = db
|
|
.select({
|
|
app: appsTable,
|
|
userSort: userAppOrdersTable.sortOrder,
|
|
})
|
|
.from(appsTable)
|
|
.leftJoin(
|
|
userAppOrdersTable,
|
|
sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
|
|
)
|
|
.$dynamic();
|
|
|
|
const orderedRows = await (isAdmin
|
|
? baseQuery
|
|
: baseQuery.where(eq(appsTable.isActive, true))
|
|
).orderBy(
|
|
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
|
|
asc(appsTable.nameEn),
|
|
);
|
|
|
|
const apps = orderedRows.map((r) => r.app);
|
|
|
|
if (isAdmin) return apps;
|
|
|
|
const userRoleIds = effectiveRoleIds;
|
|
const userPermissionIds = userRoleIds.length > 0
|
|
? (await db
|
|
.select({ permissionId: rolePermissionsTable.permissionId })
|
|
.from(rolePermissionsTable)
|
|
.where(inArray(rolePermissionsTable.roleId, userRoleIds))
|
|
).map((r) => r.permissionId)
|
|
: [];
|
|
|
|
const appsWithPermissions = await db
|
|
.select({ appId: appPermissionsTable.appId })
|
|
.from(appPermissionsTable);
|
|
const restrictedAppIds = new Set(appsWithPermissions.map((r) => r.appId));
|
|
|
|
if (restrictedAppIds.size === 0) return apps;
|
|
|
|
const allowedRestrictedAppIds = userPermissionIds.length > 0
|
|
? (await db
|
|
.select({ appId: appPermissionsTable.appId })
|
|
.from(appPermissionsTable)
|
|
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
|
|
).map((r) => r.appId)
|
|
: [];
|
|
|
|
// Group-granted apps: any app explicitly assigned to one of the user's groups
|
|
// is visible regardless of the legacy permission gating.
|
|
const groupGrantedAppIds = (
|
|
await db
|
|
.select({ appId: groupAppsTable.appId })
|
|
.from(groupAppsTable)
|
|
.innerJoin(
|
|
userGroupsTable,
|
|
eq(userGroupsTable.groupId, groupAppsTable.groupId),
|
|
)
|
|
.where(eq(userGroupsTable.userId, userId))
|
|
).map((r) => r.appId);
|
|
const groupGrantedSet = new Set(groupGrantedAppIds);
|
|
|
|
const allowedAppIdSet = new Set(allowedRestrictedAppIds);
|
|
return apps.filter(
|
|
(app) =>
|
|
!restrictedAppIds.has(app.id) ||
|
|
allowedAppIdSet.has(app.id) ||
|
|
groupGrantedSet.has(app.id),
|
|
);
|
|
}
|
|
|
|
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const visible = await getVisibleAppsForUser(userId);
|
|
res.json(visible);
|
|
});
|
|
|
|
router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
|
|
const allApps = await db
|
|
.select()
|
|
.from(appsTable)
|
|
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
|
|
|
|
if (allApps.length === 0) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
// Compute dependency counts so the admin delete dialog can warn before
|
|
// the first click (mirrors the GET /groups behavior). The lazy 409
|
|
// fallback in DELETE /apps/:id remains as a safety net.
|
|
const appIds = allApps.map((a) => a.id);
|
|
|
|
const groupRows = await db
|
|
.select({
|
|
appId: groupAppsTable.appId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(groupAppsTable)
|
|
.where(inArray(groupAppsTable.appId, appIds))
|
|
.groupBy(groupAppsTable.appId);
|
|
const restrictionRows = await db
|
|
.select({
|
|
appId: appPermissionsTable.appId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(appPermissionsTable)
|
|
.where(inArray(appPermissionsTable.appId, appIds))
|
|
.groupBy(appPermissionsTable.appId);
|
|
const openRows = await db
|
|
.select({
|
|
appId: appOpensTable.appId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(appOpensTable)
|
|
.where(inArray(appOpensTable.appId, appIds))
|
|
.groupBy(appOpensTable.appId);
|
|
|
|
const groupMap = new Map(groupRows.map((r) => [r.appId, r.count]));
|
|
const restrictionMap = new Map(
|
|
restrictionRows.map((r) => [r.appId, r.count]),
|
|
);
|
|
const openMap = new Map(openRows.map((r) => [r.appId, r.count]));
|
|
|
|
res.json(
|
|
allApps.map((a) => ({
|
|
...a,
|
|
groupCount: groupMap.get(a.id) ?? 0,
|
|
restrictionCount: restrictionMap.get(a.id) ?? 0,
|
|
openCount: openMap.get(a.id) ?? 0,
|
|
})),
|
|
);
|
|
});
|
|
|
|
router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const parsed = UpdateMyAppOrderBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const { order } = parsed.data;
|
|
|
|
const seen = new Set<number>();
|
|
for (const id of order) {
|
|
if (seen.has(id)) {
|
|
res.status(400).json({ error: `Duplicate app id in order: ${id}` });
|
|
return;
|
|
}
|
|
seen.add(id);
|
|
}
|
|
|
|
const visible = await getVisibleAppsForUser(userId);
|
|
const visibleIds = new Set(visible.map((a) => a.id));
|
|
const invalid = order.filter((id) => !visibleIds.has(id));
|
|
if (invalid.length > 0) {
|
|
res.status(400).json({
|
|
error: `Unknown or unauthorized app id(s): ${invalid.join(", ")}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
|
|
if (order.length > 0) {
|
|
await tx.insert(userAppOrdersTable).values(
|
|
order.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
|
|
);
|
|
}
|
|
});
|
|
|
|
const updated = await getVisibleAppsForUser(userId);
|
|
res.json(updated);
|
|
});
|
|
|
|
router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
|
|
const parsed = CreateAppBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
// Accept an optional permissionIds[] alongside the app fields so admins
|
|
// can gate the app at creation time. Pulling it out before the insert
|
|
// keeps appsTable.values strictly typed against the Drizzle schema.
|
|
const { permissionIds: rawPermissionIds, ...appValues } = parsed.data;
|
|
// The zod schema already restricts permissionIds to numbers, but we
|
|
// explicitly reject non-integer / non-positive values with 400 here so
|
|
// a malformed request never silently drops ids — the admin should know
|
|
// their gate request was wrong instead of getting a partially gated app.
|
|
if (
|
|
rawPermissionIds !== undefined &&
|
|
rawPermissionIds.some((n) => !Number.isInteger(n) || n <= 0)
|
|
) {
|
|
res.status(400).json({
|
|
error: "permissionIds must be an array of positive integers",
|
|
});
|
|
return;
|
|
}
|
|
const requestedPermissionIds = Array.from(new Set(rawPermissionIds ?? []));
|
|
|
|
// If permission ids were supplied, look them up upfront so we can 404
|
|
// before creating the app row instead of leaving an orphaned app behind.
|
|
// We also need the names later for the per-permission audit entries.
|
|
const requestedPermissions = requestedPermissionIds.length > 0
|
|
? await db
|
|
.select({ id: permissionsTable.id, name: permissionsTable.name })
|
|
.from(permissionsTable)
|
|
.where(inArray(permissionsTable.id, requestedPermissionIds))
|
|
: [];
|
|
if (requestedPermissions.length !== requestedPermissionIds.length) {
|
|
const foundIds = new Set(requestedPermissions.map((p) => p.id));
|
|
const missing = requestedPermissionIds.filter((id) => !foundIds.has(id));
|
|
res.status(404).json({
|
|
error: `Permission(s) not found: ${missing.join(", ")}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create the app + insert its permission rows in a single transaction so
|
|
// the app never exists in an "unrestricted" state when the admin asked
|
|
// for permission gating. onConflictDoNothing keeps it idempotent against
|
|
// the (app_id, permission_id) primary key.
|
|
const { app, insertedPermissionIds } = await db.transaction(async (tx) => {
|
|
const [created] = await tx.insert(appsTable).values(appValues).returning();
|
|
let inserted: number[] = [];
|
|
if (requestedPermissionIds.length > 0) {
|
|
const ins = await tx
|
|
.insert(appPermissionsTable)
|
|
.values(
|
|
requestedPermissionIds.map((permissionId) => ({
|
|
appId: created.id,
|
|
permissionId,
|
|
})),
|
|
)
|
|
.onConflictDoNothing()
|
|
.returning({ permissionId: appPermissionsTable.permissionId });
|
|
inserted = ins.map((r) => r.permissionId);
|
|
// Brand-new app starts with no permissions, so previousIds=[] and the
|
|
// newIds is the sorted set of what we just attached. This matches the
|
|
// single-add endpoint's audit semantics so the history view renders
|
|
// a normal "added X" entry instead of a synthetic create marker.
|
|
await recordPermissionAudit(tx, {
|
|
targetKind: "app",
|
|
targetId: created.id,
|
|
changeKind: "app.permissions",
|
|
actorUserId: req.session.userId ?? null,
|
|
previousIds: [],
|
|
newIds: requestedPermissionIds,
|
|
});
|
|
}
|
|
return { app: created, insertedPermissionIds: inserted };
|
|
});
|
|
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.create",
|
|
targetType: "app",
|
|
targetId: app.id,
|
|
metadata: {
|
|
slug: app.slug,
|
|
nameAr: app.nameAr,
|
|
nameEn: app.nameEn,
|
|
route: app.route,
|
|
isActive: app.isActive,
|
|
permissionIds: requestedPermissionIds,
|
|
},
|
|
});
|
|
// Mirror the per-permission audit rows POST /apps/:id/permissions writes
|
|
// so the admin log shows the same "permission added" entries whether the
|
|
// gate was set at create time or in a follow-up dialog. We only emit
|
|
// entries for rows the transaction actually inserted.
|
|
if (insertedPermissionIds.length > 0) {
|
|
const nameById = new Map(requestedPermissions.map((p) => [p.id, p.name]));
|
|
await db.insert(auditLogsTable).values(
|
|
insertedPermissionIds.map((permissionId) => ({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.permission.add",
|
|
targetType: "app" as const,
|
|
targetId: app.id,
|
|
metadata: {
|
|
slug: app.slug,
|
|
nameEn: app.nameEn,
|
|
permissionId,
|
|
permissionName: nameById.get(permissionId) ?? null,
|
|
},
|
|
})),
|
|
);
|
|
}
|
|
res.status(201).json(app);
|
|
});
|
|
|
|
router.get("/apps/:id", requireAuth, async (req, res): Promise<void> => {
|
|
const params = GetAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
|
|
const [app] = await db
|
|
.select()
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, params.data.id));
|
|
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
res.json(app);
|
|
});
|
|
|
|
router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
|
const params = UpdateAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
|
|
const parsed = UpdateAppBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const [previous] = await db
|
|
.select()
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, params.data.id));
|
|
|
|
const [app] = await db
|
|
.update(appsTable)
|
|
.set(parsed.data)
|
|
.where(eq(appsTable.id, params.data.id))
|
|
.returning();
|
|
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
if (previous) {
|
|
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
for (const [key, value] of Object.entries(parsed.data)) {
|
|
const prevValue = (previous as Record<string, unknown>)[key];
|
|
if (prevValue !== value) {
|
|
changes[key] = { from: prevValue ?? null, to: value ?? null };
|
|
}
|
|
}
|
|
if (Object.keys(changes).length > 0) {
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.update",
|
|
targetType: "app",
|
|
targetId: app.id,
|
|
metadata: {
|
|
slug: app.slug,
|
|
nameEn: app.nameEn,
|
|
changes,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json(app);
|
|
});
|
|
|
|
router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
|
|
const params = GetAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
|
|
const [app] = await db
|
|
.select({ id: appsTable.id })
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, params.data.id));
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: permissionsTable.id,
|
|
name: permissionsTable.name,
|
|
descriptionAr: permissionsTable.descriptionAr,
|
|
descriptionEn: permissionsTable.descriptionEn,
|
|
})
|
|
.from(appPermissionsTable)
|
|
.innerJoin(
|
|
permissionsTable,
|
|
eq(appPermissionsTable.permissionId, permissionsTable.id),
|
|
)
|
|
.where(eq(appPermissionsTable.appId, app.id))
|
|
.orderBy(permissionsTable.name);
|
|
|
|
res.json(rows);
|
|
});
|
|
|
|
router.post(
|
|
"/apps/: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 [app] = await db
|
|
.select({ id: appsTable.id })
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, id));
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
const candidateIds = Array.from(new Set<number>(candidate as number[]));
|
|
const candidateSet = new Set<number>(candidateIds);
|
|
|
|
const currentRows = await db
|
|
.select({ permissionId: appPermissionsTable.permissionId })
|
|
.from(appPermissionsTable)
|
|
.where(eq(appPermissionsTable.appId, id));
|
|
const currentIds = currentRows.map((r) => r.permissionId);
|
|
const currentSet = new Set<number>(currentIds);
|
|
|
|
// Groups that grant this app via group_apps. These groups offset the
|
|
// permission gate: members of any of these groups always see the app
|
|
// regardless of permission changes, so we surface them so the admin
|
|
// knows which populations are protected.
|
|
const groupGrantRows = await db
|
|
.select({ id: groupsTable.id, name: groupsTable.name })
|
|
.from(groupAppsTable)
|
|
.innerJoin(groupsTable, eq(groupAppsTable.groupId, groupsTable.id))
|
|
.where(eq(groupAppsTable.appId, id))
|
|
.orderBy(groupsTable.name);
|
|
|
|
const sameSize = candidateSet.size === currentSet.size;
|
|
const noChange =
|
|
sameSize && currentIds.every((p) => candidateSet.has(p));
|
|
|
|
const groupGrantUserRows = await db
|
|
.selectDistinct({ userId: userGroupsTable.userId })
|
|
.from(userGroupsTable)
|
|
.innerJoin(
|
|
groupAppsTable,
|
|
eq(groupAppsTable.groupId, userGroupsTable.groupId),
|
|
)
|
|
.where(eq(groupAppsTable.appId, id));
|
|
const groupGrantUserSet = new Set<number>(
|
|
groupGrantUserRows.map((r) => r.userId),
|
|
);
|
|
|
|
// Admins are excluded from the affected/visible counts since they always
|
|
// see every app via the short-circuit in getVisibleAppsForUser. We treat
|
|
// an admin as anyone holding the "admin" role directly OR via a group.
|
|
const adminRoleRows = await db
|
|
.select({ id: rolesTable.id })
|
|
.from(rolesTable)
|
|
.where(eq(rolesTable.name, "admin"));
|
|
const adminUserSet = new Set<number>();
|
|
if (adminRoleRows.length > 0) {
|
|
const adminRoleId = adminRoleRows[0].id;
|
|
const directAdmins = await db
|
|
.select({ userId: userRolesTable.userId })
|
|
.from(userRolesTable)
|
|
.where(eq(userRolesTable.roleId, adminRoleId));
|
|
for (const r of directAdmins) adminUserSet.add(r.userId);
|
|
|
|
const adminGroupRows = await db
|
|
.select({ groupId: groupRolesTable.groupId })
|
|
.from(groupRolesTable)
|
|
.where(eq(groupRolesTable.roleId, adminRoleId));
|
|
const adminGroupIds = adminGroupRows.map((r) => r.groupId);
|
|
if (adminGroupIds.length > 0) {
|
|
const indirectAdmins = await db
|
|
.select({ userId: userGroupsTable.userId })
|
|
.from(userGroupsTable)
|
|
.where(inArray(userGroupsTable.groupId, adminGroupIds));
|
|
for (const r of indirectAdmins) adminUserSet.add(r.userId);
|
|
}
|
|
}
|
|
|
|
// Returns the set of user IDs that currently hold AT LEAST ONE of `perms`
|
|
// through any role they have (direct or via a group). Mirrors the OR
|
|
// semantics getVisibleAppsForUser uses for app permission gating.
|
|
const usersWithAnyPermission = async (
|
|
perms: number[],
|
|
): Promise<Set<number>> => {
|
|
const out = new Set<number>();
|
|
if (perms.length === 0) return out;
|
|
const directRows = await db
|
|
.selectDistinct({ userId: userRolesTable.userId })
|
|
.from(userRolesTable)
|
|
.innerJoin(
|
|
rolePermissionsTable,
|
|
eq(rolePermissionsTable.roleId, userRolesTable.roleId),
|
|
)
|
|
.where(inArray(rolePermissionsTable.permissionId, perms));
|
|
for (const r of directRows) out.add(r.userId);
|
|
const indirectRows = await db
|
|
.selectDistinct({ userId: userGroupsTable.userId })
|
|
.from(userGroupsTable)
|
|
.innerJoin(
|
|
groupRolesTable,
|
|
eq(groupRolesTable.groupId, userGroupsTable.groupId),
|
|
)
|
|
.innerJoin(
|
|
rolePermissionsTable,
|
|
eq(rolePermissionsTable.roleId, groupRolesTable.roleId),
|
|
)
|
|
.where(inArray(rolePermissionsTable.permissionId, perms));
|
|
for (const r of indirectRows) out.add(r.userId);
|
|
return out;
|
|
};
|
|
|
|
// Helper to materialise "all non-admin users" only when one of the
|
|
// candidate or current sets is empty (the unrestricted path). Skipping
|
|
// this query in the common restricted-to-restricted case keeps the
|
|
// endpoint cheap on installs with many users.
|
|
let allNonAdminUserIds: number[] | null = null;
|
|
const getAllNonAdminUsers = async (): Promise<Set<number>> => {
|
|
if (allNonAdminUserIds === null) {
|
|
const allUsers = await db
|
|
.select({ id: usersTable.id })
|
|
.from(usersTable);
|
|
allNonAdminUserIds = allUsers
|
|
.map((u) => u.id)
|
|
.filter((uid) => !adminUserSet.has(uid));
|
|
}
|
|
return new Set<number>(allNonAdminUserIds);
|
|
};
|
|
|
|
const computeVisible = async (perms: number[]): Promise<Set<number>> => {
|
|
if (perms.length === 0) {
|
|
// Unrestricted: every non-admin user can see the app.
|
|
return await getAllNonAdminUsers();
|
|
}
|
|
const visible = await usersWithAnyPermission(perms);
|
|
for (const uid of groupGrantUserSet) visible.add(uid);
|
|
for (const uid of adminUserSet) visible.delete(uid);
|
|
return visible;
|
|
};
|
|
|
|
const currentlyVisible = await computeVisible(currentIds);
|
|
// When the candidate set is identical to the current set we skip the
|
|
// second visibility query (it's guaranteed to be the same) and report
|
|
// the real currentlyVisibleUserCount so consumers reading a noChange
|
|
// response still get an accurate audience size for the app.
|
|
const futureVisible = noChange
|
|
? currentlyVisible
|
|
: await computeVisible(candidateIds);
|
|
|
|
let affected = 0;
|
|
if (!noChange) {
|
|
for (const uid of currentlyVisible) {
|
|
if (!futureVisible.has(uid)) affected += 1;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
affectedUserCount: affected,
|
|
currentlyVisibleUserCount: currentlyVisible.size,
|
|
groups: groupGrantRows,
|
|
noChange,
|
|
});
|
|
},
|
|
);
|
|
|
|
router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
|
|
const params = GetAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
const permissionId = Number(req.body?.permissionId);
|
|
if (!Number.isInteger(permissionId)) {
|
|
res.status(400).json({ error: "permissionId is required" });
|
|
return;
|
|
}
|
|
|
|
const [app] = await db
|
|
.select({
|
|
id: appsTable.id,
|
|
slug: appsTable.slug,
|
|
nameEn: appsTable.nameEn,
|
|
})
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, params.data.id));
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
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;
|
|
}
|
|
|
|
// The composite primary key on (app_id, permission_id) means a duplicate
|
|
// insert would otherwise fail with 23505. onConflictDoNothing makes the
|
|
// endpoint idempotent so the UI can safely re-add an existing pair.
|
|
// .returning() lets us tell a real insert from a no-op so the audit log
|
|
// only records actual permission tightening, not retry clicks.
|
|
const inserted = await db.transaction(async (tx) => {
|
|
const previousIds = (
|
|
await tx
|
|
.select({ permissionId: appPermissionsTable.permissionId })
|
|
.from(appPermissionsTable)
|
|
.where(eq(appPermissionsTable.appId, app.id))
|
|
).map((r) => r.permissionId);
|
|
const ins = await tx
|
|
.insert(appPermissionsTable)
|
|
.values({ appId: app.id, permissionId })
|
|
.onConflictDoNothing()
|
|
.returning({ appId: appPermissionsTable.appId });
|
|
if (ins.length > 0) {
|
|
await recordPermissionAudit(tx, {
|
|
targetKind: "app",
|
|
targetId: app.id,
|
|
changeKind: "app.permissions",
|
|
actorUserId: req.session.userId ?? null,
|
|
previousIds,
|
|
newIds: previousIds.includes(permissionId)
|
|
? previousIds
|
|
: [...previousIds, permissionId],
|
|
});
|
|
}
|
|
return ins;
|
|
});
|
|
|
|
if (inserted.length > 0) {
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.permission.add",
|
|
targetType: "app",
|
|
targetId: app.id,
|
|
metadata: {
|
|
slug: app.slug,
|
|
nameEn: app.nameEn,
|
|
permissionId: perm.id,
|
|
permissionName: perm.name,
|
|
},
|
|
});
|
|
// #216: visibility of this app for permission holders just changed —
|
|
// wake them up so /api/apps refetches without a manual reload. Only
|
|
// emitted when an actual row was inserted (not a no-op retry).
|
|
await emitAppsChangedToPermissionHolders(perm.id);
|
|
}
|
|
|
|
res.status(201).json({ appId: app.id, permissionId });
|
|
});
|
|
|
|
router.delete(
|
|
"/apps/:id/permissions/:permissionId",
|
|
requireAdmin,
|
|
async (req, res): Promise<void> => {
|
|
const appId = Number(req.params.id);
|
|
const permissionId = Number(req.params.permissionId);
|
|
if (!Number.isInteger(appId) || !Number.isInteger(permissionId)) {
|
|
res.status(400).json({ error: "Invalid id" });
|
|
return;
|
|
}
|
|
|
|
const [app] = await db
|
|
.select({
|
|
id: appsTable.id,
|
|
slug: appsTable.slug,
|
|
nameEn: appsTable.nameEn,
|
|
})
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, appId));
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
// Look up the permission name BEFORE deletion so the audit entry can
|
|
// identify the permission even if the row (or the permission itself)
|
|
// is removed later. If the permission no longer exists we still emit
|
|
// an audit row with the id so the trail isn't lost.
|
|
const [perm] = await db
|
|
.select({ name: permissionsTable.name })
|
|
.from(permissionsTable)
|
|
.where(eq(permissionsTable.id, permissionId));
|
|
|
|
const deleted = await db.transaction(async (tx) => {
|
|
const previousIds = (
|
|
await tx
|
|
.select({ permissionId: appPermissionsTable.permissionId })
|
|
.from(appPermissionsTable)
|
|
.where(eq(appPermissionsTable.appId, appId))
|
|
).map((r) => r.permissionId);
|
|
const del = await tx
|
|
.delete(appPermissionsTable)
|
|
.where(
|
|
and(
|
|
eq(appPermissionsTable.appId, appId),
|
|
eq(appPermissionsTable.permissionId, permissionId),
|
|
),
|
|
)
|
|
.returning({ appId: appPermissionsTable.appId });
|
|
if (del.length > 0) {
|
|
await recordPermissionAudit(tx, {
|
|
targetKind: "app",
|
|
targetId: app.id,
|
|
changeKind: "app.permissions",
|
|
actorUserId: req.session.userId ?? null,
|
|
previousIds,
|
|
newIds: previousIds.filter((pid) => pid !== permissionId),
|
|
});
|
|
}
|
|
return del;
|
|
});
|
|
|
|
if (deleted.length > 0) {
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.permission.remove",
|
|
targetType: "app",
|
|
targetId: app.id,
|
|
metadata: {
|
|
slug: app.slug,
|
|
nameEn: app.nameEn,
|
|
permissionId,
|
|
permissionName: perm?.name ?? null,
|
|
},
|
|
});
|
|
// #216: dropping a required permission can change app visibility for
|
|
// anyone who held it, so wake them up to refetch /api/apps.
|
|
await emitAppsChangedToPermissionHolders(permissionId);
|
|
}
|
|
|
|
res.sendStatus(204);
|
|
},
|
|
);
|
|
|
|
router.get(
|
|
"/apps/: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 [app] = await db
|
|
.select({ id: appsTable.id })
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, id));
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
const filters = parsePermissionAuditFilters(req, res);
|
|
if (!filters) return;
|
|
const result = await listPermissionAudit({
|
|
targetKind: "app",
|
|
targetId: id,
|
|
limit,
|
|
offset,
|
|
filters,
|
|
});
|
|
res.json(result);
|
|
},
|
|
);
|
|
|
|
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
|
|
const userId = req.session.userId!;
|
|
const params = GetAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
|
|
const [app] = await db
|
|
.select({ id: appsTable.id })
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, params.data.id));
|
|
|
|
if (!app) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
await db.insert(appOpensTable).values({ userId, appId: app.id });
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
|
const params = DeleteAppParams.safeParse(req.params);
|
|
if (!params.success) {
|
|
res.status(400).json({ error: params.error.message });
|
|
return;
|
|
}
|
|
|
|
const appId = params.data.id;
|
|
|
|
const [existing] = await db
|
|
.select()
|
|
.from(appsTable)
|
|
.where(eq(appsTable.id, appId));
|
|
if (!existing) {
|
|
res.status(404).json({ error: "App not found" });
|
|
return;
|
|
}
|
|
|
|
const force = req.query.force === "true" || req.query.force === "1";
|
|
|
|
const [groupRow] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(groupAppsTable)
|
|
.where(eq(groupAppsTable.appId, appId));
|
|
const [restrictionRow] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(appPermissionsTable)
|
|
.where(eq(appPermissionsTable.appId, appId));
|
|
const [openRow] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(appOpensTable)
|
|
.where(eq(appOpensTable.appId, appId));
|
|
|
|
const groupCount = groupRow?.count ?? 0;
|
|
const restrictionCount = restrictionRow?.count ?? 0;
|
|
const openCount = openRow?.count ?? 0;
|
|
const hasDeps = groupCount > 0 || restrictionCount > 0 || openCount > 0;
|
|
|
|
if (hasDeps && !force) {
|
|
res.status(409).json({
|
|
error: "App has dependent records",
|
|
groupCount,
|
|
restrictionCount,
|
|
openCount,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await db.delete(appsTable).where(eq(appsTable.id, appId));
|
|
|
|
await db.insert(auditLogsTable).values({
|
|
actorUserId: req.session.userId ?? null,
|
|
action: "app.delete",
|
|
targetType: "app",
|
|
targetId: appId,
|
|
metadata: {
|
|
appSlug: existing.slug,
|
|
appNameAr: existing.nameAr,
|
|
appNameEn: existing.nameEn,
|
|
force,
|
|
groupCount,
|
|
restrictionCount,
|
|
openCount,
|
|
},
|
|
});
|
|
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
export default router;
|