|
| 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": []} |
0 commit comments