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, auditLogsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth"; import { CreateAppBody, UpdateAppBody, GetAppParams, UpdateAppParams, DeleteAppParams, UpdateMyAppOrderBody, } from "@workspace/api-zod"; const router: IRouter = Router(); async function getVisibleAppsForUser(userId: number): Promise { 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 => { const userId = req.session.userId!; const visible = await getVisibleAppsForUser(userId); res.json(visible); }); router.get("/admin/apps", requireAdmin, async (_req, res): Promise => { 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`count(*)::int`, }) .from(groupAppsTable) .where(inArray(groupAppsTable.appId, appIds)) .groupBy(groupAppsTable.appId); const restrictionRows = await db .select({ appId: appPermissionsTable.appId, count: sql`count(*)::int`, }) .from(appPermissionsTable) .where(inArray(appPermissionsTable.appId, appIds)) .groupBy(appPermissionsTable.appId); const openRows = await db .select({ appId: appOpensTable.appId, count: sql`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 => { 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(); 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 => { const parsed = CreateAppBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const [app] = await db.insert(appsTable).values(parsed.data).returning(); 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, }, }); res.status(201).json(app); }); router.get("/apps/:id", requireAuth, async (req, res): Promise => { 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 => { 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 = {}; for (const [key, value] of Object.entries(parsed.data)) { const prevValue = (previous as Record)[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.post("/apps/:id/open", requireAuth, async (req, res): Promise => { 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 => { 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`count(*)::int` }) .from(groupAppsTable) .where(eq(groupAppsTable.appId, appId)); const [restrictionRow] = await db .select({ count: sql`count(*)::int` }) .from(appPermissionsTable) .where(eq(appPermissionsTable.appId, appId)); const [openRow] = await db .select({ count: sql`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: { slug: existing.slug, nameAr: existing.nameAr, nameEn: existing.nameEn, force, groupCount, restrictionCount, openCount, }, }); res.sendStatus(204); }); export default router;