Task #5: Refine home and services screens

- Removed greeting and welcome banner from the home page; trimmed unused
  ar.json/en.json greeting keys.
- Hid the 4-tile stats row for non-admin users (`isAdmin` from user roles)
  and scoped `totalApps` in /api/stats/home to apps the user can actually
  access (mirrors the RBAC join in /api/apps).
- Renamed nav.services translation: "خدماتي" -> "الخدمات",
  "My Services" -> "Services".
- Removed price/free text from the user-facing services page; cards now
  show name, description, availability badge, and (when present) image.
- Admin service editor now exposes an "Image URL" / "رابط الصورة" text
  field with a live preview thumbnail; saved imageUrl renders at the top
  of the corresponding service card (16:10, object-cover, onError hides
  broken images). Uses existing imageUrl column in services schema; no DB
  migration required. Image upload via object storage was deferred — see
  follow-up task.

Verified: typecheck passes for teaboy-os and api-server; api-server
restarted clean; e2e plan validated by testing subagent; architect code
review approved.
This commit is contained in:
Riyadh
2026-04-20 10:49:57 +00:00
parent 87a92591f2
commit ef76e2dc15
6 changed files with 103 additions and 44 deletions
+56 -5
View File
@@ -1,8 +1,12 @@
import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm";
import { eq, and, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
appsTable,
appPermissionsTable,
rolesTable,
rolePermissionsTable,
userRolesTable,
servicesTable,
notificationsTable,
usersTable,
@@ -16,10 +20,57 @@ const router: IRouter = Router();
router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const [appsCount] = await db
.select({ count: sql<number>`count(*)` })
.from(appsTable)
.where(eq(appsTable.isActive, true));
// Determine if the user is an admin and which apps they can access.
const userRoleRows = await db
.select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.where(eq(userRolesTable.userId, userId));
const isAdmin = userRoleRows.some((r) => r.roleName === "admin");
let totalApps = 0;
if (isAdmin) {
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(appsTable)
.where(eq(appsTable.isActive, true));
totalApps = Number(row?.count ?? 0);
} else {
const userRoleIds = userRoleRows.map((r) => r.roleId);
const userPermissionIds = userRoleIds.length > 0
? (await db
.select({ permissionId: rolePermissionsTable.permissionId })
.from(rolePermissionsTable)
.where(inArray(rolePermissionsTable.roleId, userRoleIds))
).map((r) => r.permissionId)
: [];
const restrictedRows = await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable);
const restrictedAppIds = new Set(restrictedRows.map((r) => r.appId));
const allowedRestrictedAppIds = userPermissionIds.length > 0
? new Set(
(await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable)
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
).map((r) => r.appId),
)
: new Set<number>();
const activeApps = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.isActive, true));
totalApps = activeApps.filter(
(a) => !restrictedAppIds.has(a.id) || allowedRestrictedAppIds.has(a.id),
).length;
}
const appsCount = { count: totalApps };
const [servicesCount] = await db
.select({ count: sql<number>`count(*)` })