Skip to content

Commit 9117a56

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: professional platform admin dashboard at /admin
Backend: - New /api/admin/* routes (stats, users, webhooks, plan update) - require_admin dependency checks ADMIN_EMAIL from config - Returns platform stats: MRR, ARR, user counts, email processing - Per-user plan override via PATCH /api/admin/users/{id}/plan Frontend: - Tabbed UI: Overview / Users / Webhooks - Overview: KPI cards (users, MRR, ARR, processed emails), plan breakdown bars, revenue table - Users: searchable table with plan badges, usage bars, inline plan change dropdown - Webhooks: event log with status badges - Non-admins see Access Denied screen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 120c1f4 commit 9117a56

5 files changed

Lines changed: 621 additions & 102 deletions

File tree

backend/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class Settings(BaseSettings):
4848
# Frontend base URL (used for Stripe redirect URLs)
4949
FRONTEND_URL: str = "http://localhost:3000"
5050

51+
# Platform admin
52+
ADMIN_EMAIL: str = "saisridhart@gmail.com"
53+
5154
# JWT / Auth
5255
SECRET_KEY: str
5356
ALGORITHM: str = "HS256"

backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from config import settings
1717
from limiter import limiter
18-
from routes import auth, emails, actions, replies, integrations, settings as settings_routes, billing, contacts, outlook, calendar, teams, webhooks as webhooks_routes, autoassign, crm_integrations
18+
from routes import auth, emails, actions, replies, integrations, settings as settings_routes, billing, contacts, outlook, calendar, teams, webhooks as webhooks_routes, autoassign, crm_integrations, admin as admin_routes
1919
from workers.email_listener import start_email_listener, stop_email_listener
2020

2121
# ---------------------------------------------------------------------------
@@ -164,6 +164,7 @@ async def generic_exception_handler(request: Request, exc: Exception):
164164
app.include_router(webhooks_routes.router, prefix="/api")
165165
app.include_router(autoassign.router, prefix="/api")
166166
app.include_router(crm_integrations.router, prefix="/api")
167+
app.include_router(admin_routes.router, prefix="/api")
167168

168169

169170
# ---------------------------------------------------------------------------

backend/routes/admin.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""
2+
Platform-level admin routes.
3+
All endpoints require the authenticated user to be the platform admin
4+
(email must match ADMIN_EMAIL in config).
5+
"""
6+
import logging
7+
from datetime import datetime, timezone
8+
from typing import Annotated
9+
10+
from fastapi import APIRouter, Depends, HTTPException, status
11+
from pydantic import BaseModel
12+
13+
from config import settings
14+
from database import get_supabase
15+
from middleware.auth import get_current_user
16+
17+
logger = logging.getLogger(__name__)
18+
router = APIRouter(prefix="/admin", tags=["admin"])
19+
20+
PLAN_ORDER = {"free": 0, "pro": 1, "agency": 2}
21+
PLAN_MONTHLY_PRICE = {"free": 0, "pro": 199, "agency": 1499}
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Admin guard
26+
# ---------------------------------------------------------------------------
27+
28+
async def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict:
29+
"""Dependency — raises 403 if caller is not the platform admin."""
30+
email = current_user.get("email", "")
31+
if email != settings.ADMIN_EMAIL:
32+
raise HTTPException(
33+
status_code=status.HTTP_403_FORBIDDEN,
34+
detail="Admin access required.",
35+
)
36+
return current_user
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Models
41+
# ---------------------------------------------------------------------------
42+
43+
class PlanUpdateBody(BaseModel):
44+
plan: str # "free" | "pro" | "agency"
45+
46+
47+
# ---------------------------------------------------------------------------
48+
# Endpoints
49+
# ---------------------------------------------------------------------------
50+
51+
@router.get("/stats")
52+
async def get_platform_stats(_: Annotated[dict, Depends(require_admin)]):
53+
"""High-level platform metrics."""
54+
supabase = get_supabase()
55+
56+
# All user profiles
57+
profiles_res = supabase.table("user_profiles").select("id, plan, subscription_status, created_at").execute()
58+
profiles = profiles_res.data or []
59+
60+
total_users = len(profiles)
61+
paying = [p for p in profiles if p.get("plan") in ("pro", "agency")]
62+
pro_count = sum(1 for p in paying if p.get("plan") == "pro")
63+
agency_count = sum(1 for p in paying if p.get("plan") == "agency")
64+
mrr = pro_count * PLAN_MONTHLY_PRICE["pro"] + agency_count * PLAN_MONTHLY_PRICE["agency"]
65+
66+
# Emails this month
67+
now = datetime.now(timezone.utc)
68+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
69+
emails_res = (
70+
supabase.table("emails")
71+
.select("id", count="exact")
72+
.gte("created_at", month_start)
73+
.execute()
74+
)
75+
emails_this_month = emails_res.count or 0
76+
77+
# Processed emails total
78+
processed_res = (
79+
supabase.table("emails")
80+
.select("id", count="exact")
81+
.eq("processed", True)
82+
.execute()
83+
)
84+
total_processed = processed_res.count or 0
85+
86+
# New users this month
87+
new_users_res = (
88+
supabase.table("user_profiles")
89+
.select("id", count="exact")
90+
.gte("created_at", month_start)
91+
.execute()
92+
)
93+
new_users_this_month = new_users_res.count or 0
94+
95+
return {
96+
"total_users": total_users,
97+
"paying_users": len(paying),
98+
"pro_users": pro_count,
99+
"agency_users": agency_count,
100+
"free_users": total_users - len(paying),
101+
"mrr_inr": mrr,
102+
"emails_this_month": emails_this_month,
103+
"total_processed_emails": total_processed,
104+
"new_users_this_month": new_users_this_month,
105+
}
106+
107+
108+
@router.get("/users")
109+
async def get_all_users(_: Annotated[dict, Depends(require_admin)]):
110+
"""All users with plan, usage and subscription info."""
111+
supabase = get_supabase()
112+
113+
profiles_res = (
114+
supabase.table("user_profiles")
115+
.select("id, email, plan, subscription_status, subscription_id, created_at")
116+
.order("created_at", desc=True)
117+
.execute()
118+
)
119+
profiles = profiles_res.data or []
120+
121+
# Get email counts per user (processed only)
122+
now = datetime.now(timezone.utc)
123+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
124+
125+
enriched = []
126+
for p in profiles:
127+
uid = p.get("id")
128+
# Count AI-processed emails this month
129+
usage_res = (
130+
supabase.table("emails")
131+
.select("id", count="exact")
132+
.eq("user_id", uid)
133+
.eq("processed", True)
134+
.gte("created_at", month_start)
135+
.execute()
136+
)
137+
emails_used = usage_res.count or 0
138+
139+
# Count total emails
140+
total_res = (
141+
supabase.table("emails")
142+
.select("id", count="exact")
143+
.eq("user_id", uid)
144+
.execute()
145+
)
146+
total_emails = total_res.count or 0
147+
148+
enriched.append({
149+
**p,
150+
"emails_used_this_month": emails_used,
151+
"total_emails": total_emails,
152+
})
153+
154+
return {"users": enriched, "total": len(enriched)}
155+
156+
157+
@router.patch("/users/{user_id}/plan")
158+
async def update_user_plan(
159+
user_id: str,
160+
body: PlanUpdateBody,
161+
_: Annotated[dict, Depends(require_admin)],
162+
):
163+
"""Manually override a user's subscription plan."""
164+
if body.plan not in PLAN_ORDER:
165+
raise HTTPException(status_code=400, detail=f"Invalid plan '{body.plan}'. Must be free, pro, or agency.")
166+
167+
supabase = get_supabase()
168+
result = (
169+
supabase.table("user_profiles")
170+
.update({"plan": body.plan})
171+
.eq("id", user_id)
172+
.execute()
173+
)
174+
if not result.data:
175+
raise HTTPException(status_code=404, detail="User not found.")
176+
177+
logger.info("Admin changed plan for user_id=%s to %s", user_id, body.plan)
178+
return {"user_id": user_id, "plan": body.plan, "message": "Plan updated successfully."}
179+
180+
181+
@router.get("/webhooks")
182+
async def get_webhook_logs(_: Annotated[dict, Depends(require_admin)]):
183+
"""Recent Razorpay webhook events (last 50)."""
184+
supabase = get_supabase()
185+
try:
186+
res = (
187+
supabase.table("webhook_logs")
188+
.select("*")
189+
.order("created_at", desc=True)
190+
.limit(50)
191+
.execute()
192+
)
193+
return {"logs": res.data or []}
194+
except Exception:
195+
# Table may not exist yet — return empty
196+
return {"logs": []}

frontend/lib/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,27 @@ export const billingApi = {
382382
},
383383
};
384384

385+
// ─── Platform Admin Endpoints ──────────────────────────────────────────────
386+
387+
export const adminApi = {
388+
getStats: async () => {
389+
const { data } = await api.get('/api/admin/stats');
390+
return data;
391+
},
392+
getUsers: async () => {
393+
const { data } = await api.get('/api/admin/users');
394+
return data;
395+
},
396+
updateUserPlan: async (userId: string, plan: string) => {
397+
const { data } = await api.patch(`/api/admin/users/${userId}/plan`, { plan });
398+
return data;
399+
},
400+
getWebhookLogs: async () => {
401+
const { data } = await api.get('/api/admin/webhooks');
402+
return data;
403+
},
404+
};
405+
385406
// ─── Outlook Endpoints ────────────────────────────────────────────────────────
386407

387408
export const outlookApi = {

0 commit comments

Comments
 (0)