Add ability to reorder apps on the home screen

Implements drag-and-drop functionality for reordering apps using @dnd-kit, adds a new `userAppOrdersTable` to the database schema to store user-specific app order preferences, and introduces a new API endpoint `/api/me/app-order` for updating these preferences.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e782e35b-00f5-4b9b-8931-63051a25df80
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/PrQkd7G
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-04-20 12:09:14 +00:00
parent 4eb1a18f35
commit abcc32fb66
11 changed files with 750 additions and 46 deletions
+66 -35
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express";
import { eq, asc, inArray } from "drizzle-orm";
import { eq, asc, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
appsTable,
@@ -7,6 +7,7 @@ import {
userRolesTable,
rolePermissionsTable,
rolesTable,
userAppOrdersTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
@@ -15,13 +16,12 @@ import {
GetAppParams,
UpdateAppParams,
DeleteAppParams,
UpdateMyAppOrderBody,
} from "@workspace/api-zod";
const router: IRouter = Router();
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$inferSelect[]> {
const userRoleRows = await db
.select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
.from(userRolesTable)
@@ -30,18 +30,30 @@ router.get("/apps", requireAuth, async (req, res): Promise<void> => {
const isAdmin = userRoleRows.some((r) => r.roleName === "admin");
if (isAdmin) {
const apps = await db
.select()
.from(appsTable)
.where(eq(appsTable.isActive, true))
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
res.json(apps);
return;
}
// 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(
sql`CASE WHEN ${userAppOrdersTable.sortOrder} IS NULL THEN 1 ELSE 0 END`,
asc(userAppOrdersTable.sortOrder),
asc(appsTable.sortOrder),
asc(appsTable.nameEn),
);
const apps = orderedRows.map((r) => r.app);
if (isAdmin) return apps;
const userRoleIds = userRoleRows.map((r) => r.roleId);
const userPermissionIds = userRoleIds.length > 0
? (await db
.select({ permissionId: rolePermissionsTable.permissionId })
@@ -50,29 +62,12 @@ router.get("/apps", requireAuth, async (req, res): Promise<void> => {
).map((r) => r.permissionId)
: [];
const apps = await db
.select()
.from(appsTable)
.where(
eq(appsTable.isActive, true),
)
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
const appsWithPermissions = await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable);
const restrictedAppIds = new Set(appsWithPermissions.map((r) => r.appId));
if (restrictedAppIds.size === 0) {
const unrestricted = await db
.select()
.from(appsTable)
.where(eq(appsTable.isActive, true))
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
res.json(unrestricted);
return;
}
if (restrictedAppIds.size === 0) return apps;
const allowedRestrictedAppIds = userPermissionIds.length > 0
? (await db
@@ -83,12 +78,48 @@ router.get("/apps", requireAuth, async (req, res): Promise<void> => {
: [];
const allowedAppIdSet = new Set(allowedRestrictedAppIds);
const visibleApps = apps.filter(
return apps.filter(
(app) => !restrictedAppIds.has(app.id) || allowedAppIdSet.has(app.id),
);
}
res.json(visibleApps);
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;
}
const { order } = parsed.data;
const visible = await getVisibleAppsForUser(userId);
const visibleIds = new Set(visible.map((a) => a.id));
const filtered = order.filter((id) => visibleIds.has(id));
const seen = new Set<number>();
const dedup = filtered.filter((id) => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
await db.transaction(async (tx) => {
await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
if (dedup.length > 0) {
await tx.insert(userAppOrdersTable).values(
dedup.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
);
}
});
const updated = await getVisibleAppsForUser(userId);
res.json(updated);
});
router.post("/apps", requireAdmin, async (req, res): Promise<void> => {