mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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>
212 lines
6.8 KiB
Python
212 lines
6.8 KiB
Python
"""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,
|
|
)
|