feat: admin invite codes with plan assignment + user detail page
- Migration 030: add email, assigned_plan, trial_duration_days, email_sent_at
to invite_codes with CHECK constraints
- Resend email integration (graceful degradation when API key not set)
- Invite codes now support plan assignment (free/pro/team) and trial duration (1-90 days)
- Registration applies invite code plan/trial to new subscription
- Auto-downgrade expired trials on authenticated access
- Enriched GET /admin/users/{id} with account, subscription, sessions, audit logs
- New endpoints: PUT /admin/users/{id}/subscription/plan and extend-trial
- Frontend: enhanced invite codes page with email, plan, trial fields
- Frontend: new user detail page at /admin/users/:userId
- Fixed API path drift: /invite-codes -> /invites
- 11 new backend tests, 416 total passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.session import Session
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.invite_code import InviteCode
|
||||
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
|
||||
from app.schemas.admin import MoveUserAccount
|
||||
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
|
||||
from app.schemas.user_detail import (
|
||||
UserDetailResponse, AccountSummary, SubscriptionSummary,
|
||||
SessionSummary, AuditLogSummary, InviteCodeUsedSummary,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
@@ -42,13 +53,13 @@ async def list_users(
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserResponse)
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Get user details (super admin only)."""
|
||||
"""Get enriched user details (super admin only)."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -58,7 +69,104 @@ async def get_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return user
|
||||
# Account + subscription
|
||||
account_summary = None
|
||||
subscription_summary = None
|
||||
if user.account_id:
|
||||
acc_result = await db.execute(select(Account).where(Account.id == user.account_id))
|
||||
account = acc_result.scalar_one_or_none()
|
||||
if account:
|
||||
account_summary = AccountSummary(
|
||||
id=account.id, name=account.name,
|
||||
display_code=getattr(account, "display_code", None),
|
||||
)
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == user.account_id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
if subscription:
|
||||
subscription_summary = SubscriptionSummary(
|
||||
id=subscription.id, plan=subscription.plan, status=subscription.status,
|
||||
current_period_start=subscription.current_period_start,
|
||||
current_period_end=subscription.current_period_end,
|
||||
)
|
||||
|
||||
# Recent sessions (latest 10 + total)
|
||||
total_sessions_result = await db.execute(
|
||||
select(func.count()).select_from(Session).where(Session.user_id == user_id)
|
||||
)
|
||||
total_sessions = total_sessions_result.scalar() or 0
|
||||
|
||||
sessions_result = await db.execute(
|
||||
select(Session).options(selectinload(Session.tree))
|
||||
.where(Session.user_id == user_id)
|
||||
.order_by(Session.started_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
sessions = sessions_result.scalars().all()
|
||||
recent_sessions = [
|
||||
SessionSummary(
|
||||
id=s.id,
|
||||
tree_name=s.tree.name if s.tree else None,
|
||||
started_at=s.started_at,
|
||||
completed_at=s.completed_at,
|
||||
outcome=s.outcome,
|
||||
)
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
# Recent audit logs (latest 10 + total)
|
||||
total_audits_result = await db.execute(
|
||||
select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id)
|
||||
)
|
||||
total_audit_logs = total_audits_result.scalar() or 0
|
||||
|
||||
audits_result = await db.execute(
|
||||
select(AuditLog).where(AuditLog.user_id == user_id)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
audits = audits_result.scalars().all()
|
||||
recent_audit_logs = [
|
||||
AuditLogSummary(
|
||||
id=a.id, action=a.action, resource_type=a.resource_type,
|
||||
resource_id=str(a.resource_id) if a.resource_id else None,
|
||||
created_at=a.created_at, details=a.details,
|
||||
)
|
||||
for a in audits
|
||||
]
|
||||
|
||||
# Invite code used
|
||||
invite_code_used = None
|
||||
if user.invite_code_id:
|
||||
ic_result = await db.execute(
|
||||
select(InviteCode).where(InviteCode.id == user.invite_code_id)
|
||||
)
|
||||
ic = ic_result.scalar_one_or_none()
|
||||
if ic:
|
||||
creator_email = None
|
||||
if ic.created_by_id:
|
||||
creator_result = await db.execute(
|
||||
select(User.email).where(User.id == ic.created_by_id)
|
||||
)
|
||||
creator_email = creator_result.scalar_one_or_none()
|
||||
invite_code_used = InviteCodeUsedSummary(
|
||||
code=ic.code, assigned_plan=ic.assigned_plan,
|
||||
trial_duration_days=ic.trial_duration_days,
|
||||
created_by_email=creator_email,
|
||||
)
|
||||
|
||||
return UserDetailResponse(
|
||||
id=user.id, email=user.email, full_name=user.name,
|
||||
role=user.role, is_active=user.is_active,
|
||||
is_super_admin=user.is_super_admin,
|
||||
is_team_admin=getattr(user, "is_team_admin", False),
|
||||
created_at=user.created_at,
|
||||
account=account_summary, subscription=subscription_summary,
|
||||
invite_code_used=invite_code_used,
|
||||
recent_sessions=recent_sessions, total_sessions=total_sessions,
|
||||
recent_audit_logs=recent_audit_logs, total_audit_logs=total_audit_logs,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role", response_model=UserResponse)
|
||||
@@ -198,3 +306,69 @@ async def move_user_account(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User, Subscription]:
|
||||
"""Helper to load user and their subscription."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if not user.account_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no account")
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == user.account_id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found")
|
||||
return user, subscription
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/plan")
|
||||
async def update_user_plan(
|
||||
user_id: UUID,
|
||||
data: SubscriptionPlanUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change a user's subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
user, subscription = await _get_user_subscription(user_id, db)
|
||||
old_plan = subscription.plan
|
||||
subscription.plan = data.plan
|
||||
await log_audit(db, current_user.id, "subscription.plan_change", "subscription", subscription.id,
|
||||
{"old_plan": old_plan, "new_plan": data.plan, "user_id": str(user_id)})
|
||||
await db.commit()
|
||||
return {"plan": subscription.plan, "status": subscription.status}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/subscription/extend-trial")
|
||||
async def extend_user_trial(
|
||||
user_id: UUID,
|
||||
data: ExtendTrialRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Extend or start a trial for a user's subscription (super admin only)."""
|
||||
if data.days < 1 or data.days > 90:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Days must be 1-90")
|
||||
user, subscription = await _get_user_subscription(user_id, db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription.status == "trialing" and subscription.current_period_end:
|
||||
# Extend existing trial
|
||||
new_end = subscription.current_period_end + timedelta(days=data.days)
|
||||
else:
|
||||
# Start new trial
|
||||
subscription.status = "trialing"
|
||||
subscription.current_period_start = now
|
||||
new_end = now + timedelta(days=data.days)
|
||||
|
||||
subscription.current_period_end = new_end
|
||||
await log_audit(db, current_user.id, "subscription.extend_trial", "subscription", subscription.id,
|
||||
{"days": data.days, "new_end": new_end.isoformat(), "user_id": str(user_id)})
|
||||
await db.commit()
|
||||
return {"plan": subscription.plan, "status": subscription.status,
|
||||
"current_period_end": subscription.current_period_end}
|
||||
|
||||
Reference in New Issue
Block a user