mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Epic 5 Complete: Billing, Subscriptions, and Admin Features
Resolve all 5 deferred items from Epic 5 adversarial code review: - Migration 124: Add CASCADE to subscriptionstatus enum drop (prevent orphaned references) - Stripe rate limiting: In-memory per-user limiter (20 calls/60s) on verify-checkout-session - Subscription request cooldown: 24h cooldown before resubmitting rejected requests - Token reset date: Initialize on first subscription activation - Checkout URL validation: Confirmed HTTPS-only (Stripe always returns HTTPS) Implement Story 5.4 (Usage Tracking & Rate Limit Enforcement): - Page quota pre-check at HTTP upload layer - Extend UserRead schema with token quota fields - Frontend 402 error handling in document upload - Quota indicator in dashboard sidebar Story 5.5 (Admin Seed & Approval Flow): - Seed admin user migration with default credentials warning - Subscription approval/rejection routes with admin guard - 24h rejection cooldown enforcement Story 5.6 (Admin-Only Model Config): - Global model config visible across all search spaces - Per-search-space model configs with user access control - Superuser CRUD for global configs Additional fixes from code review: - PageLimitService: PAST_DUE subscriptions enforce free-tier limits - TokenQuotaService: PAST_DUE subscriptions enforce free-tier limits - Config routes: Fixed user_id.is_(None) filter on mutation endpoints - Stripe webhook: Added guard against silent plan downgrade on unrecognized price_id All changes formatted with Ruff (Python) and Biome (TypeScript). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
20c4f128bb
commit
4eb6ed18d6
41 changed files with 1771 additions and 318 deletions
212
surfsense_backend/app/routes/admin_routes.py
Normal file
212
surfsense_backend/app/routes/admin_routes.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Admin routes — superuser-only operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
SubscriptionRequest,
|
||||
SubscriptionRequestStatus,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.users import current_superuser
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SubscriptionRequestItem(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
user_email: str
|
||||
plan_id: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
approved_at: datetime | None = None
|
||||
approved_by: uuid.UUID | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List pending subscription requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/subscription-requests",
|
||||
response_model=list[SubscriptionRequestItem],
|
||||
)
|
||||
async def list_subscription_requests(
|
||||
admin: User = Depends(current_superuser),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
) -> list[SubscriptionRequestItem]:
|
||||
"""Return all pending subscription requests."""
|
||||
result = await db_session.execute(
|
||||
select(SubscriptionRequest)
|
||||
.where(SubscriptionRequest.status == SubscriptionRequestStatus.PENDING)
|
||||
.order_by(SubscriptionRequest.created_at.asc())
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
# Collect user IDs and batch-load to avoid N+1
|
||||
user_ids = [req.user_id for req in requests]
|
||||
email_map: dict[uuid.UUID, str] = {}
|
||||
if user_ids:
|
||||
user_rows = await db_session.execute(select(User).where(User.id.in_(user_ids)))
|
||||
for u in user_rows.scalars():
|
||||
email_map[u.id] = u.email
|
||||
|
||||
items: list[SubscriptionRequestItem] = [
|
||||
SubscriptionRequestItem(
|
||||
id=req.id,
|
||||
user_id=req.user_id,
|
||||
user_email=email_map.get(req.user_id, "<deleted>"),
|
||||
plan_id=req.plan_id,
|
||||
status=req.status.value,
|
||||
created_at=req.created_at,
|
||||
approved_at=req.approved_at,
|
||||
approved_by=req.approved_by,
|
||||
)
|
||||
for req in requests
|
||||
]
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approve a subscription request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription-requests/{request_id}/approve",
|
||||
response_model=SubscriptionRequestItem,
|
||||
)
|
||||
async def approve_subscription_request(
|
||||
request_id: uuid.UUID,
|
||||
admin: User = Depends(current_superuser),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
) -> SubscriptionRequestItem:
|
||||
"""Approve a pending subscription request and activate the user's subscription."""
|
||||
result = await db_session.execute(
|
||||
select(SubscriptionRequest)
|
||||
.where(SubscriptionRequest.id == request_id)
|
||||
.with_for_update()
|
||||
)
|
||||
req = result.scalar_one_or_none()
|
||||
if req is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Subscription request not found.",
|
||||
)
|
||||
if req.status != SubscriptionRequestStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Request is already {req.status.value}.",
|
||||
)
|
||||
|
||||
user_result = await db_session.execute(
|
||||
select(User).where(User.id == req.user_id).with_for_update()
|
||||
)
|
||||
req_user = user_result.scalar_one_or_none()
|
||||
if req_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found."
|
||||
)
|
||||
|
||||
# Activate subscription
|
||||
plan_limits = config.PLAN_LIMITS.get(req.plan_id, config.PLAN_LIMITS["free"])
|
||||
req_user.subscription_status = SubscriptionStatus.ACTIVE
|
||||
req_user.plan_id = req.plan_id
|
||||
req_user.monthly_token_limit = plan_limits["monthly_token_limit"]
|
||||
req_user.pages_limit = max(req_user.pages_used or 0, plan_limits["pages_limit"])
|
||||
req_user.tokens_used_this_month = 0
|
||||
req_user.token_reset_date = datetime.now(UTC).date()
|
||||
|
||||
# Mark request approved
|
||||
now = datetime.now(UTC)
|
||||
req.status = SubscriptionRequestStatus.APPROVED
|
||||
req.approved_at = now
|
||||
req.approved_by = admin.id
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(req)
|
||||
|
||||
user_result2 = await db_session.execute(select(User).where(User.id == req.user_id))
|
||||
req_user2 = user_result2.scalar_one_or_none()
|
||||
email = req_user2.email if req_user2 else "<deleted>"
|
||||
|
||||
return SubscriptionRequestItem(
|
||||
id=req.id,
|
||||
user_id=req.user_id,
|
||||
user_email=email,
|
||||
plan_id=req.plan_id,
|
||||
status=req.status.value,
|
||||
created_at=req.created_at,
|
||||
approved_at=req.approved_at,
|
||||
approved_by=req.approved_by,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reject a subscription request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription-requests/{request_id}/reject",
|
||||
response_model=SubscriptionRequestItem,
|
||||
)
|
||||
async def reject_subscription_request(
|
||||
request_id: uuid.UUID,
|
||||
admin: User = Depends(current_superuser),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
) -> SubscriptionRequestItem:
|
||||
"""Reject a pending subscription request."""
|
||||
result = await db_session.execute(
|
||||
select(SubscriptionRequest).where(SubscriptionRequest.id == request_id)
|
||||
)
|
||||
req = result.scalar_one_or_none()
|
||||
if req is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Subscription request not found.",
|
||||
)
|
||||
if req.status != SubscriptionRequestStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Request is already {req.status.value}.",
|
||||
)
|
||||
|
||||
req.status = SubscriptionRequestStatus.REJECTED
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(req)
|
||||
|
||||
user_result = await db_session.execute(select(User).where(User.id == req.user_id))
|
||||
req_user = user_result.scalar_one_or_none()
|
||||
email = req_user.email if req_user else "<deleted>"
|
||||
|
||||
return SubscriptionRequestItem(
|
||||
id=req.id,
|
||||
user_id=req.user_id,
|
||||
user_email=email,
|
||||
plan_id=req.plan_id,
|
||||
status=req.status.value,
|
||||
created_at=req.created_at,
|
||||
approved_at=req.approved_at,
|
||||
approved_by=req.approved_by,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue