admin-overview.ts91 lines · main
1import { isNull, sql } from 'drizzle-orm';
2
3import { getDb } from '../db/client.js';
4import { projects } from '../db/schema.js';
5import { adminStats } from './admin.js';
6import { listDeploys } from './deploy-history.js';
7import { listIncidents } from './incidents.js';
8import { getHealthSummary, type HealthSummary } from './platform-health.js';
9
10/**
11 * The admin landing overview payload. This is a CONTRACT with
12 * apps/web/(admin)/admin/overview-client.tsx (the `Overview` type). Every
13 * number here is REAL or an honest null:
14 * - billing subscribers/mrr/currency/planMix.churn are null while Mavi Pay
15 * is not connected. planMix (free/pro/team) IS real — a live count of
16 * projects grouped by tier.
17 * - health is getHealthSummary() (checks + host).
18 * - openIncidents counts active (unresolved) incidents.
19 * - recentDeploys is the deploy-history table, last 8 across services.
20 * - counts.projects/users from adminStats().
21 */
22export interface AdminOverview {
23 billing: {
24 subscribers: number | null;
25 mrr: number | null;
26 currency: string | null;
27 planMix: { free: number; pro: number; team: number } | null;
28 churn30d: number | null;
29 };
30 health: HealthSummary;
31 openIncidents: number;
32 recentDeploys: Array<{
33 id: string;
34 service: string;
35 buildSha: string;
36 buildAt: string | null;
37 env: string;
38 bootedAt: string;
39 }>;
40 counts: { projects: number; users: number };
41}
42
43/** Live count of non-deleted projects grouped by tier. */
44async function planMixFromProjects(): Promise<{ free: number; pro: number; team: number }> {
45 const db = getDb();
46 const rows = await db
47 .select({ tier: projects.tier, count: sql<number>`count(*)::int` })
48 .from(projects)
49 .where(isNull(projects.deletedAt))
50 .groupBy(projects.tier);
51 const mix = { free: 0, pro: 0, team: 0 };
52 for (const r of rows) {
53 if (r.tier === 'free' || r.tier === 'pro' || r.tier === 'team') {
54 mix[r.tier] = r.count;
55 }
56 }
57 return mix;
58}
59
60export async function getAdminOverview(): Promise<AdminOverview> {
61 const [planMix, health, activeIncidents, deploys, stats] = await Promise.all([
62 planMixFromProjects(),
63 getHealthSummary(),
64 listIncidents({ activeOnly: true, limit: 200 }),
65 listDeploys({ limit: 8 }),
66 adminStats(),
67 ]);
68
69 return {
70 billing: {
71 // Mavi Pay is not connected — no subscriber/MRR/churn source yet. Honest
72 // nulls rather than fabricated revenue. planMix is the one real signal.
73 subscribers: null,
74 mrr: null,
75 currency: null,
76 planMix,
77 churn30d: null,
78 },
79 health,
80 openIncidents: activeIncidents.length,
81 recentDeploys: deploys.map((d) => ({
82 id: d.id,
83 service: d.service,
84 buildSha: d.buildSha,
85 buildAt: d.buildAt,
86 env: d.env,
87 bootedAt: d.bootedAt.toISOString(),
88 })),
89 counts: { projects: stats.projects, users: stats.users },
90 };
91}