From fbb71f9366ce3c7b3208e3b0fd50f330cff9b4aa Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 12:20:18 +0200 Subject: [PATCH] feat(admin): replace mock dashboard stats with real /admin/stats endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /admin route in the unified Mana web app was rendering hardcoded mock data (42 users, 156 successful logins, 3 failed) for every admin who opened it. The previous code had a TODO comment to wire up a real endpoint and the backend half had been waiting for the frontend half ever since the consolidation landed. Backend (mana-auth): Add GET /api/v1/admin/stats — admin-only, returns the seven counts the dashboard needs in a single response. Each count is its own Drizzle query against auth.users / auth.sessions / auth.login_ attempts; they run in parallel via Promise.all so total latency is dominated by the round-trip to Postgres, not the per-query work. Stats: - totalUsers → users where deleted_at IS NULL - newUsers7d → users created in the last 7 days - newUsers30d → users created in the last 30 days - activeSessions → sessions where expires_at > now() AND not revoked - uniqueUsers24h → distinct user_id from sessions with last_activity in the last 24h (and not revoked) - loginSuccess7d → login_attempts where successful=true, last 7d - loginFailed7d → login_attempts where successful=false, last 7d Plus a generatedAt ISO timestamp so the client can show staleness if it ever caches the response. Frontend (apps/mana/apps/web): - Add adminService.getStats() in the existing admin API service (sits next to getUsers / getUserData / deleteUserData; uses the same authenticated base-client and ApiResult envelope). - Replace the onMount mock-data block in admin/+page.svelte with a single adminService.getStats() call. Drop the local Stats interface in favor of the AdminStats type exported from the service. - Guard the Success Rate calculation against division by zero on fresh deployments — when there have been no login attempts in the last 7 days, render '—%' instead of NaN%. Verification: - mana-auth type-check unchanged (baseline errors only) - mana-auth runtime tests still 19/19 passing - svelte-check on the two changed web files: zero errors Closes item #12 in docs/REFACTORING_AUDIT_2026_04.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/api/services/admin.ts | 24 ++++++ .../web/src/routes/(app)/admin/+page.svelte | 47 ++++-------- services/mana-auth/src/routes/admin.ts | 74 ++++++++++++++++++- 3 files changed, 109 insertions(+), 36 deletions(-) diff --git a/apps/mana/apps/web/src/lib/api/services/admin.ts b/apps/mana/apps/web/src/lib/api/services/admin.ts index f4ebb4cd3..98808fbdf 100644 --- a/apps/mana/apps/web/src/lib/api/services/admin.ts +++ b/apps/mana/apps/web/src/lib/api/services/admin.ts @@ -146,10 +146,34 @@ export interface DeleteUserDataResponse { totalDeleted: number; } +/** + * Admin dashboard stats — aggregate counts from auth.users, + * auth.sessions, auth.login_attempts. Backed by GET /admin/stats. + */ +export interface AdminStats { + totalUsers: number; + newUsers7d: number; + newUsers30d: number; + activeSessions: number; + uniqueUsers24h: number; + loginSuccess7d: number; + loginFailed7d: number; + /** ISO timestamp of when the snapshot was generated server-side */ + generatedAt: string; +} + /** * Admin service for user data management */ export const adminService = { + /** + * Aggregate dashboard stats. Backed by a single mana-auth endpoint + * that runs seven counts against the auth schema in parallel. + */ + async getStats(): Promise> { + return getClient().get('/admin/stats'); + }, + /** * Get list of users with pagination and search */ diff --git a/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte b/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte index 930cb259d..1716c898a 100644 --- a/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte @@ -2,18 +2,9 @@ import { onMount } from 'svelte'; import StatCard from '$lib/components/admin/StatCard.svelte'; import QuickLinks from '$lib/components/admin/QuickLinks.svelte'; + import { adminService, type AdminStats } from '$lib/api/services/admin'; - interface Stats { - totalUsers: number; - newUsers7d: number; - newUsers30d: number; - activeSessions: number; - uniqueUsers24h: number; - loginSuccess7d: number; - loginFailed7d: number; - } - - let stats = $state(null); + let stats = $state(null); let loading = $state(true); let error = $state(null); @@ -45,27 +36,13 @@ ]; onMount(async () => { - try { - // TODO: Replace with actual API call to fetch admin stats - // const response = await fetch('/api/admin/stats'); - // stats = await response.json(); - - // Mock data for now - await new Promise((resolve) => setTimeout(resolve, 500)); - stats = { - totalUsers: 42, - newUsers7d: 8, - newUsers30d: 23, - activeSessions: 15, - uniqueUsers24h: 12, - loginSuccess7d: 156, - loginFailed7d: 3, - }; - } catch (e) { - error = 'Failed to load stats'; - } finally { - loading = false; + const result = await adminService.getStats(); + if (result.error) { + error = result.error; + } else { + stats = result.data; } + loading = false; }); let userGrowthPercent = $derived( @@ -131,9 +108,11 @@
Success Rate - {Math.round( - (stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100 - )}% + {stats.loginSuccess7d + stats.loginFailed7d > 0 + ? Math.round( + (stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100 + ) + : '—'}%
diff --git a/services/mana-auth/src/routes/admin.ts b/services/mana-auth/src/routes/admin.ts index 3d34afbf9..2af1bf835 100644 --- a/services/mana-auth/src/routes/admin.ts +++ b/services/mana-auth/src/routes/admin.ts @@ -5,10 +5,11 @@ */ import { Hono } from 'hono'; -import { eq } from 'drizzle-orm'; +import { and, count, countDistinct, eq, gte, isNull } from 'drizzle-orm'; import type { AuthUser } from '../middleware/jwt-auth'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { users } from '../db/schema/auth'; +import { users, sessions } from '../db/schema/auth'; +import { loginAttempts } from '../db/schema/login-attempts'; import type { UserDataService } from '../services/user-data'; const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const; @@ -26,6 +27,75 @@ export function createAdminRoutes(db: PostgresJsDatabase, userDataService: await next(); }); + // ─── Aggregate stats for the admin dashboard ────────────── + // + // Replaces hardcoded mock data in apps/mana/apps/web/src/routes/ + // (app)/admin/+page.svelte. All seven values come from auth.users, + // auth.sessions and auth.login_attempts — no other service is + // involved, which keeps this endpoint a pure single-DB read. + + app.get('/stats', async (c) => { + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // One query per stat — Postgres handles them in parallel via + // the connection pool when wrapped in Promise.all. Each one is + // a single indexed count, so total latency is dominated by + // round-trip not query work. + const [ + [{ value: totalUsers }], + [{ value: newUsers7d }], + [{ value: newUsers30d }], + [{ value: activeSessions }], + [{ value: uniqueUsers24h }], + [{ value: loginSuccess7d }], + [{ value: loginFailed7d }], + ] = await Promise.all([ + db.select({ value: count() }).from(users).where(isNull(users.deletedAt)), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, sevenDaysAgo))), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, thirtyDaysAgo))), + db + .select({ value: count() }) + .from(sessions) + .where(and(gte(sessions.expiresAt, now), isNull(sessions.revokedAt))), + db + .select({ value: countDistinct(sessions.userId) }) + .from(sessions) + .where(and(isNull(sessions.revokedAt), gte(sessions.lastActivityAt, twentyFourHoursAgo))), + db + .select({ value: count() }) + .from(loginAttempts) + .where( + and(eq(loginAttempts.successful, true), gte(loginAttempts.attemptedAt, sevenDaysAgo)) + ), + db + .select({ value: count() }) + .from(loginAttempts) + .where( + and(eq(loginAttempts.successful, false), gte(loginAttempts.attemptedAt, sevenDaysAgo)) + ), + ]); + + return c.json({ + totalUsers, + newUsers7d, + newUsers30d, + activeSessions, + uniqueUsers24h, + loginSuccess7d, + loginFailed7d, + generatedAt: now.toISOString(), + }); + }); + // ─── List users with pagination and search ──────────────── app.get('/users', async (c) => {