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:
Michael Chihlas
2026-02-11 21:42:58 -05:00
parent a466400c5b
commit 50cb0fc7f0
24 changed files with 2522 additions and 1121 deletions

View File

@@ -1,6 +1,6 @@
import secrets
import string
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
@@ -92,38 +92,39 @@ async def register(
detail="Account invite code has expired"
)
# Validate platform invite code if required (skip if account invite was provided)
# Validate platform invite code (skip if account invite was provided)
invite_code_record = None
if not account_invite_record and settings.REQUIRE_INVITE_CODE:
if not user_data.invite_code:
if not account_invite_record:
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
)
# Look up invite code (case-insensitive)
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
if user_data.invite_code:
# Look up invite code (case-insensitive) — applies plan/trial regardless of REQUIRE_INVITE_CODE
result = await db.execute(
select(InviteCode).where(InviteCode.code == user_data.invite_code.upper())
)
invite_code_record = result.scalar_one_or_none()
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if not invite_code_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
if invite_code_record.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has already been used"
)
if invite_code_record.is_expired:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code has expired"
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
@@ -175,10 +176,24 @@ async def register(
# Now set account owner and create subscription
new_account.owner_id = new_user.id
# Apply plan/trial from invite code if present
sub_plan = "free"
sub_status = "active"
period_start = None
period_end = None
if invite_code_record and invite_code_record.assigned_plan:
sub_plan = invite_code_record.assigned_plan
if invite_code_record.trial_duration_days:
sub_status = "trialing"
period_start = datetime.now(timezone.utc)
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
new_subscription = Subscription(
account_id=new_account.id,
plan="free",
status="active",
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
)
db.add(new_subscription)