2026-04-20 09:20:50 +00:00
|
|
|
import { Router, type IRouter } from "express";
|
2026-04-22 08:54:08 +00:00
|
|
|
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
2026-04-20 09:20:50 +00:00
|
|
|
import { db } from "@workspace/db";
|
2026-04-20 09:42:37 +00:00
|
|
|
import {
|
|
|
|
|
appsTable,
|
|
|
|
|
appPermissionsTable,
|
|
|
|
|
userRolesTable,
|
|
|
|
|
rolePermissionsTable,
|
|
|
|
|
rolesTable,
|
2026-04-20 12:09:14 +00:00
|
|
|
userAppOrdersTable,
|
2026-04-20 15:16:19 +00:00
|
|
|
appOpensTable,
|
2026-04-22 08:28:31 +00:00
|
|
|
userGroupsTable,
|
|
|
|
|
groupAppsTable,
|
2026-04-20 09:42:37 +00:00
|
|
|
} from "@workspace/db";
|
2026-04-22 08:47:35 +00:00
|
|
|
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
2026-04-20 09:20:50 +00:00
|
|
|
import {
|
|
|
|
|
CreateAppBody,
|
|
|
|
|
UpdateAppBody,
|
|
|
|
|
GetAppParams,
|
|
|
|
|
UpdateAppParams,
|
|
|
|
|
DeleteAppParams,
|
2026-04-20 12:09:14 +00:00
|
|
|
UpdateMyAppOrderBody,
|
2026-04-20 09:20:50 +00:00
|
|
|
} from "@workspace/api-zod";
|
|
|
|
|
|
|
|
|
|
const router: IRouter = Router();
|
|
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
|
2026-04-22 08:47:35 +00:00
|
|
|
const effectiveRoleIds = await getEffectiveRoleIds(userId);
|
2026-04-20 09:42:37 +00:00
|
|
|
|
2026-04-22 08:47:35 +00:00
|
|
|
const adminRoleRows = effectiveRoleIds.length > 0
|
|
|
|
|
? await db
|
|
|
|
|
.select({ id: rolesTable.id })
|
|
|
|
|
.from(rolesTable)
|
2026-04-22 08:54:08 +00:00
|
|
|
.where(and(inArray(rolesTable.id, effectiveRoleIds), eq(rolesTable.name, "admin")))
|
2026-04-22 08:47:35 +00:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const isAdmin = adminRoleRows.length > 0;
|
2026-04-20 09:42:37 +00:00
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
// Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
|
|
|
|
|
const orderedRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
app: appsTable,
|
|
|
|
|
userSort: userAppOrdersTable.sortOrder,
|
|
|
|
|
})
|
|
|
|
|
.from(appsTable)
|
|
|
|
|
.leftJoin(
|
|
|
|
|
userAppOrdersTable,
|
|
|
|
|
sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
|
|
|
|
|
)
|
|
|
|
|
.where(eq(appsTable.isActive, true))
|
|
|
|
|
.orderBy(
|
2026-04-20 12:12:06 +00:00
|
|
|
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
|
2026-04-20 12:09:14 +00:00
|
|
|
asc(appsTable.nameEn),
|
|
|
|
|
);
|
2026-04-20 09:42:37 +00:00
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
const apps = orderedRows.map((r) => r.app);
|
|
|
|
|
|
|
|
|
|
if (isAdmin) return apps;
|
2026-04-20 09:42:37 +00:00
|
|
|
|
2026-04-22 08:47:35 +00:00
|
|
|
const userRoleIds = effectiveRoleIds;
|
2026-04-20 09:42:37 +00:00
|
|
|
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));
|
|
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
if (restrictedAppIds.size === 0) return apps;
|
2026-04-20 09:42:37 +00:00
|
|
|
|
|
|
|
|
const allowedRestrictedAppIds = userPermissionIds.length > 0
|
|
|
|
|
? (await db
|
|
|
|
|
.select({ appId: appPermissionsTable.appId })
|
|
|
|
|
.from(appPermissionsTable)
|
|
|
|
|
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
|
|
|
|
|
).map((r) => r.appId)
|
|
|
|
|
: [];
|
|
|
|
|
|
2026-04-22 08:28:31 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2026-04-20 09:42:37 +00:00
|
|
|
const allowedAppIdSet = new Set(allowedRestrictedAppIds);
|
2026-04-20 12:09:14 +00:00
|
|
|
return apps.filter(
|
2026-04-22 08:28:31 +00:00
|
|
|
(app) =>
|
|
|
|
|
!restrictedAppIds.has(app.id) ||
|
|
|
|
|
allowedAppIdSet.has(app.id) ||
|
|
|
|
|
groupGrantedSet.has(app.id),
|
2026-04-20 09:42:37 +00:00
|
|
|
);
|
2026-04-20 12:09:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
|
|
|
|
|
const userId = req.session.userId!;
|
|
|
|
|
const visible = await getVisibleAppsForUser(userId);
|
|
|
|
|
res.json(visible);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-20 09:42:37 +00:00
|
|
|
|
2026-04-20 12:09:14 +00:00
|
|
|
const { order } = parsed.data;
|
|
|
|
|
|
|
|
|
|
const seen = new Set<number>();
|
2026-04-20 12:12:06 +00:00
|
|
|
for (const id of order) {
|
|
|
|
|
if (seen.has(id)) {
|
|
|
|
|
res.status(400).json({ error: `Duplicate app id in order: ${id}` });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-20 12:09:14 +00:00
|
|
|
seen.add(id);
|
2026-04-20 12:12:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-20 12:09:14 +00:00
|
|
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
|
2026-04-20 12:12:06 +00:00
|
|
|
if (order.length > 0) {
|
2026-04-20 12:09:14 +00:00
|
|
|
await tx.insert(userAppOrdersTable).values(
|
2026-04-20 12:12:06 +00:00
|
|
|
order.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
|
2026-04-20 12:09:14 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updated = await getVisibleAppsForUser(userId);
|
|
|
|
|
res.json(updated);
|
2026-04-20 09:20:50 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [app] = await db.insert(appsTable).values(parsed.data).returning();
|
|
|
|
|
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 [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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(app);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:19 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-20 09:20:50 +00:00
|
|
|
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 [app] = await db
|
|
|
|
|
.delete(appsTable)
|
|
|
|
|
.where(eq(appsTable.id, params.data.id))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!app) {
|
|
|
|
|
res.status(404).json({ error: "App not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.sendStatus(204);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|