import { db, appsTable, appPermissionsTable, rolePermissionsTable, rolesTable, userAppOrdersTable, userGroupsTable, groupAppsTable, userHiddenAppsTable, } from "@workspace/db"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { getEffectiveRoleIds } from "../middlewares/auth"; /** * Returns the apps visible to a given user, applying the same RBAC * rules used by GET /apps: * - admins see every app (including inactive) * - non-admins see active apps that are either unrestricted, granted * to one of their roles via app_permissions, or assigned to one of * their groups via group_apps * * Lives in lib/ (not routes/) so the storage authorization helper * (lib/objectAuthz.ts) can reuse the exact same visibility check * without depending on the routes layer. */ export 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; 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) : []; 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), ); } /** * Returns the set of app IDs the given user has personally hidden from * their Home/Dock via PUT /me/apps/{appId}/hidden. */ export async function getHiddenAppIdsForUser( userId: number, ): Promise> { const rows = await db .select({ appId: userHiddenAppsTable.appId }) .from(userHiddenAppsTable) .where(eq(userHiddenAppsTable.userId, userId)); return new Set(rows.map((r) => r.appId)); } /** * Like getVisibleAppsForUser but additionally strips apps the user has * personally hidden. Used by GET /apps which backs Home + Dock so the * hidden toggle in Settings takes effect immediately. */ export async function getVisibleNonHiddenAppsForUser( userId: number, ): Promise { const visible = await getVisibleAppsForUser(userId); const hidden = await getHiddenAppIdsForUser(userId); if (hidden.size === 0) return visible; return visible.filter((a) => !hidden.has(a.id)); }