Files
TX/artifacts/api-server/src/routes/apps.ts
T

519 lines
14 KiB
TypeScript
Raw Normal View History

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,
permissionsTable,
} 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<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;
}
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<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", 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 })
.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 })
.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.
await db
.insert(appPermissionsTable)
.values({ appId: app.id, permissionId })
.onConflictDoNothing();
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 })
.from(appsTable)
.where(eq(appsTable.id, appId));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
await db
.delete(appPermissionsTable)
.where(
and(
eq(appPermissionsTable.appId, appId),
eq(appPermissionsTable.permissionId, permissionId),
),
);
res.sendStatus(204);
},
);
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: {
slug: existing.slug,
nameAr: existing.nameAr,
nameEn: existing.nameEn,
force,
groupCount,
restrictionCount,
openCount,
},
});
res.sendStatus(204);
});
export default router;