Files
resolutionflow/backend/app/api/endpoints/invite.py
chihlasm 3c47292eaf feat: add resend capability for platform and account invite codes
Revoke-and-recreate flow for both invite systems with email delivery
via Resend API. Includes account invite email template and audit logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:45:23 -05:00

212 lines
6.6 KiB
Python

from datetime import datetime, timezone, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.core.audit import log_audit
from app.core.email import EmailService
from app.models.user import User
from app.models.invite_code import InviteCode
from app.schemas.invite_code import InviteCodeCreate, InviteCodeResponse, InviteCodeValidation
from app.api.deps import require_admin
router = APIRouter(prefix="/invites", tags=["invites"])
@router.post("", response_model=InviteCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_invite_code(
invite_data: InviteCodeCreate,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Create a new invite code. Admin only."""
invite_code = InviteCode(
created_by_id=current_user.id,
expires_at=invite_data.expires_at,
note=invite_data.note,
email=invite_data.email,
assigned_plan=invite_data.assigned_plan,
trial_duration_days=invite_data.trial_duration_days,
)
db.add(invite_code)
await db.flush()
# Send invite email if email provided
email_sent = False
if invite_data.email:
email_sent = await EmailService.send_invite_email(
to_email=invite_data.email,
code=invite_code.code,
plan=invite_data.assigned_plan,
trial_days=invite_data.trial_duration_days,
)
if email_sent:
invite_code.email_sent_at = datetime.now(timezone.utc)
await log_audit(
db, current_user.id, "invite.create", "invite_code", invite_code.id,
{
"code": invite_code.code,
"plan": invite_data.assigned_plan,
"email": invite_data.email,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(invite_code)
return invite_code
@router.get("", response_model=list[InviteCodeResponse])
async def list_invite_codes(
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""List all invite codes. Admin only."""
result = await db.execute(
select(InviteCode).order_by(InviteCode.created_at.desc())
)
invite_codes = result.scalars().all()
return invite_codes
@router.delete("/{code}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_invite_code(
code: str,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Revoke (delete) an invite code. Admin only."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code)
)
invite_code = result.scalar_one_or_none()
if not invite_code:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite code not found"
)
if invite_code.is_used:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot revoke a used invite code"
)
await db.delete(invite_code)
await db.commit()
@router.post("/{code}/resend", response_model=InviteCodeResponse)
async def resend_invite_code(
code: str,
current_user: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Revoke an existing invite code and create a new one with the same properties, then email it."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code)
)
old_invite = result.scalar_one_or_none()
if not old_invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite code not found"
)
if old_invite.is_used:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot resend a used invite code"
)
if not old_invite.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot resend an invite code without a recipient email"
)
# Recalculate expiration from now if the old one had an expiration
new_expires_at = None
if old_invite.expires_at and old_invite.created_at:
original_duration = old_invite.expires_at - old_invite.created_at
new_expires_at = datetime.now(timezone.utc) + original_duration
elif old_invite.expires_at:
new_expires_at = old_invite.expires_at
# Capture properties before deleting
email = old_invite.email
assigned_plan = old_invite.assigned_plan
trial_duration_days = old_invite.trial_duration_days
note = old_invite.note
old_code = old_invite.code
await db.delete(old_invite)
await db.flush()
# Create new invite with same properties
new_invite = InviteCode(
created_by_id=current_user.id,
expires_at=new_expires_at,
note=note,
email=email,
assigned_plan=assigned_plan,
trial_duration_days=trial_duration_days,
)
db.add(new_invite)
await db.flush()
# Send email
email_sent = await EmailService.send_invite_email(
to_email=email,
code=new_invite.code,
plan=assigned_plan,
trial_days=trial_duration_days,
)
if email_sent:
new_invite.email_sent_at = datetime.now(timezone.utc)
await log_audit(
db, current_user.id, "invite.resend", "invite_code", new_invite.id,
{
"old_code": old_code,
"new_code": new_invite.code,
"email": email,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(new_invite)
return new_invite
@router.get("/validate/{code}", response_model=InviteCodeValidation)
@limiter.limit("5/minute")
async def validate_invite_code(
request: Request,
code: str,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Check if an invite code is valid. Public endpoint for UX."""
result = await db.execute(
select(InviteCode).where(InviteCode.code == code.upper())
)
invite_code = result.scalar_one_or_none()
if not invite_code:
return InviteCodeValidation(valid=False, message="Invalid invite code")
if invite_code.is_used:
return InviteCodeValidation(valid=False, message="Invite code has already been used")
if invite_code.is_expired:
return InviteCodeValidation(valid=False, message="Invite code has expired")
return InviteCodeValidation(valid=True, message="Invite code is valid")