feat(admin): replace mock dashboard stats with real /admin/stats endpoint

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 12:20:18 +02:00
parent 7b25f3abfb
commit fbb71f9366
3 changed files with 109 additions and 36 deletions

View file

@ -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<any>, 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) => {