2026-05-13 10:14:35 +00:00
|
|
|
import {
|
|
|
|
|
db,
|
|
|
|
|
appsTable,
|
|
|
|
|
appPermissionsTable,
|
|
|
|
|
rolePermissionsTable,
|
|
|
|
|
rolesTable,
|
|
|
|
|
userAppOrdersTable,
|
|
|
|
|
userGroupsTable,
|
|
|
|
|
groupAppsTable,
|
2026-05-19 11:06:21 +00:00
|
|
|
userHiddenAppsTable,
|
2026-05-13 10:14:35 +00:00
|
|
|
} 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<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;
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-19 11:06:21 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<Set<number>> {
|
|
|
|
|
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<typeof appsTable.$inferSelect[]> {
|
|
|
|
|
const visible = await getVisibleAppsForUser(userId);
|
|
|
|
|
const hidden = await getHiddenAppIdsForUser(userId);
|
|
|
|
|
if (hidden.size === 0) return visible;
|
|
|
|
|
return visible.filter((a) => !hidden.has(a.id));
|
|
|
|
|
}
|