Files
TX/artifacts/api-server/src/routes/stats.ts
T
Riyadh 1bd21a85fe Let admins pick the time range for dashboard trends
Original task: Add a 7d/30d/90d range selector to the admin dashboard
trend cards/chart and have /stats/admin accept a matching range parameter.

Changes:
- OpenAPI (lib/api-spec/openapi.yaml): added optional `range` query
  parameter (enum 7d|30d|90d, default 7d) to GET /stats/admin and
  refactored AdminStats fields to be range-agnostic — added `range`
  and `rangeDays`, renamed `*Last7Days`/`*Prev7Days` to `*InRange`/
  `*PrevRange`. Regenerated api-client-react and api-zod.
- API server (artifacts/api-server/src/routes/stats.ts): parses and
  validates the `range` param, computes range/prev-range windows
  generically, and returns daily series sized to rangeDays.
- Admin UI (artifacts/teaboy-os/src/pages/admin.tsx): adds a segmented
  range selector at the top of the dashboard, passes the selected
  range into the stats query (with proper queryKey), and adapts the
  trend chart for denser ranges (skipped per-bar count text >14 days,
  every-Nth date label, month/day formatting for 30d/90d).
- Locale files (en.json/ar.json): added range/prevRange labels,
  trends/rangeSelector strings, and *Ranged variants of summary keys.
  Old keys kept to avoid stale references.

Verification:
- pnpm typecheck passes across libs and artifacts.
- e2e test (login as admin → toggle 7d/30d/90d → verify chart bar
  counts and aria-pressed state, plus API responses for each range)
  passes.

Notes / deviations:
- Had to (re)create the `app_opens` table in the dev DB and reset the
  seeded admin password hash to run the e2e test; both were preexisting
  environment drift unrelated to this task.
- Followed up with: custom date range, persisting last-used range.
2026-04-20 15:38:28 +00:00

235 lines
8.0 KiB
TypeScript

import { Router, type IRouter } from "express";
import { eq, and, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
appsTable,
appPermissionsTable,
rolesTable,
rolePermissionsTable,
userRolesTable,
servicesTable,
notificationsTable,
usersTable,
messagesTable,
messageReadsTable,
appOpensTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
const router: IRouter = Router();
router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
// 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(*)` })
.from(servicesTable);
const [unreadNotifCount] = await db
.select({ count: sql<number>`count(*)` })
.from(notificationsTable)
.where(
and(
eq(notificationsTable.userId, userId),
eq(notificationsTable.isRead, false),
),
);
const [usersCount] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable);
const [unreadMsgCount] = await db
.select({ count: sql<number>`count(*)` })
.from(messagesTable)
.where(
and(
sql`${messagesTable.id} NOT IN (
SELECT message_id FROM message_reads WHERE user_id = ${userId}
)`,
sql`${messagesTable.senderId} != ${userId}`,
),
);
res.json({
totalApps: Number(appsCount?.count ?? 0),
totalServices: Number(servicesCount?.count ?? 0),
unreadNotifications: Number(unreadNotifCount?.count ?? 0),
unreadMessages: Number(unreadMsgCount?.count ?? 0),
totalUsers: Number(usersCount?.count ?? 0),
});
});
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
const rangeParam = typeof req.query.range === "string" ? req.query.range : "7d";
const allowedRanges = ["7d", "30d", "90d"] as const;
type RangeKey = typeof allowedRanges[number];
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
? (rangeParam as RangeKey)
: "7d";
const rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7;
const now = new Date();
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const rangeStart = new Date(startOfTodayUtc.getTime() - (rangeDays - 1) * 24 * 60 * 60 * 1000);
const prevRangeStart = new Date(startOfTodayUtc.getTime() - (2 * rangeDays - 1) * 24 * 60 * 60 * 1000);
const [newUsersInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`);
const [newUsersPrevRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
.where(
and(
sql`${usersTable.createdAt} >= ${prevRangeStart.toISOString()}`,
sql`${usersTable.createdAt} < ${rangeStart.toISOString()}`,
),
);
const [activeServicesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(eq(servicesTable.isAvailable, true));
const [inactiveServicesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(eq(servicesTable.isAvailable, false));
const buildSeries = (
rows: { day: string; count: number }[],
): { date: string; count: number }[] => {
const map = new Map<string, number>();
for (const r of rows) map.set(r.day, Number(r.count));
const out: { date: string; count: number }[] = [];
for (let i = rangeDays - 1; i >= 0; i--) {
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, count: map.get(key) ?? 0 });
}
return out;
};
const signupRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`)
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
const signupsByDay = buildSeries(signupRows);
const appOpenRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
const appOpensByDay = buildSeries(appOpenRows);
const [appOpensInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`);
const [appOpensPrevRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(
and(
sql`${appOpensTable.createdAt} >= ${prevRangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeStart.toISOString()}`,
),
);
const servicesCreatedRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`)
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
const [servicesCreatedInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
res.json({
range,
rangeDays,
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
activeServices: Number(activeServicesRow?.count ?? 0),
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
signupsByDay,
appOpensByDay,
appOpensInRange: Number(appOpensInRangeRow?.count ?? 0),
appOpensPrevRange: Number(appOpensPrevRangeRow?.count ?? 0),
servicesCreatedByDay,
servicesCreatedInRange: Number(servicesCreatedInRangeRow?.count ?? 0),
});
});
export default router;