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>
This commit is contained in:
@@ -9,6 +9,8 @@ from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
|
||||
from app.core.audit import log_audit
|
||||
from app.core.email import EmailService
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.subscription import Subscription
|
||||
@@ -222,6 +224,88 @@ async def create_invite(
|
||||
return invite
|
||||
|
||||
|
||||
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
|
||||
async def resend_invite(
|
||||
invite_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Revoke an existing account invite and create a new one, then email it."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite).where(
|
||||
AccountInvite.id == invite_id,
|
||||
AccountInvite.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
old_invite = result.scalar_one_or_none()
|
||||
|
||||
if not old_invite:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invite not found"
|
||||
)
|
||||
|
||||
if old_invite.is_used:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot resend a used invite"
|
||||
)
|
||||
|
||||
# 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
|
||||
role = old_invite.role
|
||||
|
||||
await db.delete(old_invite)
|
||||
await db.flush()
|
||||
|
||||
# Create new invite
|
||||
code = secrets.token_urlsafe(16)
|
||||
new_invite = AccountInvite(
|
||||
account_id=current_user.account_id,
|
||||
invited_by_id=current_user.id,
|
||||
email=email,
|
||||
code=code,
|
||||
role=role,
|
||||
expires_at=new_expires_at,
|
||||
)
|
||||
db.add(new_invite)
|
||||
await db.flush()
|
||||
|
||||
# Get account name for email
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one()
|
||||
|
||||
email_sent = await EmailService.send_account_invite_email(
|
||||
to_email=email,
|
||||
code=code,
|
||||
account_name=account.name,
|
||||
role=role,
|
||||
)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account_invite.resend", "account_invite", new_invite.id,
|
||||
{
|
||||
"email": email,
|
||||
"role": role,
|
||||
"email_sent": email_sent,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(new_invite)
|
||||
|
||||
return new_invite
|
||||
|
||||
|
||||
@router.get("/me/invites", response_model=list[AccountInviteResponse])
|
||||
async def list_invites(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -101,6 +101,91 @@ async def revoke_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(
|
||||
|
||||
@@ -51,6 +51,49 @@ class EmailService:
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def send_account_invite_email(
|
||||
to_email: str,
|
||||
code: str,
|
||||
account_name: str,
|
||||
role: str,
|
||||
signup_url: str = "https://resolutionflow.com/register",
|
||||
) -> bool:
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
role_label = role.capitalize()
|
||||
subject = f"You've been invited to join {account_name} on ResolutionFlow"
|
||||
|
||||
html = _render_account_invite_html(
|
||||
code=code,
|
||||
account_name=account_name,
|
||||
role_label=role_label,
|
||||
signup_url=signup_url,
|
||||
)
|
||||
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
}
|
||||
)
|
||||
logger.info("Account invite email sent to %s", to_email)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send account invite email to %s", to_email)
|
||||
return False
|
||||
|
||||
|
||||
def _render_invite_html(
|
||||
code: str,
|
||||
plan_label: str,
|
||||
@@ -103,3 +146,46 @@ def _render_invite_html(
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_account_invite_html(
|
||||
code: str,
|
||||
account_name: str,
|
||||
role_label: str,
|
||||
signup_url: str,
|
||||
) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
|
||||
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
|
||||
You've been invited to join <strong style="color:#fff;">{account_name}</strong> as an <strong style="color:#fff;">{role_label}</strong>.
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;text-align:center;">
|
||||
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
|
||||
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Invite Code</p>
|
||||
<p style="margin:0;color:#fff;font-size:28px;font-weight:700;letter-spacing:4px;">{code}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;text-align:center;">
|
||||
<a href="{signup_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
|
||||
Create Your Account
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
|
||||
Enter the code above during registration, or click the button to get started.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
Reference in New Issue
Block a user