1bd21a85fe
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.
235 lines
8.0 KiB
TypeScript
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;
|