"""Invite redemption endpoints. Deliberately *not* nested under ``/organizations/{org_id}``: the person accepting an invite is by definition not yet a member, so ``get_org_context`` would (correctly) refuse them. Here the invite token itself is the authorization. It is stored only as a hash, matched to the signed-in user's email, and single-use — see ``_load_pending_invite``. """ from __future__ import annotations from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.core.database import get_session from app.core.errors import ( ConflictError, NotFoundError, PermissionDeniedError, ValidationError, ) from app.core.security import hash_token from app.domain.audit.models import AuditAction from app.domain.identity.models import ( Invite, Membership, MembershipStatus, Organization, User, ) from app.infrastructure.repositories.identity import ( InviteRepository, MembershipRepository, OrganizationRepository, ) from app.interfaces.http.deps import client_meta, get_current_user router = APIRouter(prefix="/invites", tags=["invites"]) class InvitePreview(BaseModel): organization_id: str organization_name: str email: str role: str expires_at: datetime #: True when the caller is already a member, so the UI can say "open it" #: instead of "join". already_member: bool class AcceptInviteRequest(BaseModel): token: str class AcceptInviteResult(BaseModel): organization_id: str organization_name: str role: str async def _load_pending_invite( token: str, user: User, session: AsyncSession ) -> tuple[Invite, Organization]: """Resolve a raw token into a redeemable invite, or raise. Every rejection is a distinct, user-actionable error rather than a generic "invalid link": the three real failure modes (already used, expired, wrong account) each need a different response from the person holding the link. """ invite = await InviteRepository(session).get_by_token_hash(hash_token(token)) if invite is None: raise NotFoundError("این لینک دعوت معتبر نیست.") if invite.accepted_at is not None: raise ConflictError("این دعوت‌نامه قبلاً پذیرفته شده است.") if invite.is_expired(): raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.") if invite.email.strip().lower() != user.email.strip().lower(): raise PermissionDeniedError( "این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.", details={"invite_email": invite.email}, ) organization = await OrganizationRepository(session).get(invite.organization_id) if organization is None: raise NotFoundError("سازمان این دعوت‌نامه دیگر وجود ندارد.") return invite, organization @router.get("/{token}", response_model=InvitePreview) async def preview_invite( token: str, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> InvitePreview: """Show the invitee what they are about to join, without joining yet.""" invite, organization = await _load_pending_invite(token, user, session) existing = await MembershipRepository(session).get(organization.id, user.id) return InvitePreview( organization_id=organization.id, organization_name=organization.name, email=invite.email, role=invite.role.value, expires_at=invite.expires_at, already_member=existing is not None, ) @router.post("/accept", response_model=AcceptInviteResult, status_code=status.HTTP_200_OK) async def accept_invite( payload: AcceptInviteRequest, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> AcceptInviteResult: """Redeem an invite: create the membership and burn the token. Idempotent by construction — accepting twice is a no-op the second time, so a double-click or a retried request cannot create two memberships. """ invite, organization = await _load_pending_invite(payload.token, user, session) memberships = MembershipRepository(session) if await memberships.get(organization.id, user.id) is None: await memberships.add( Membership( organization_id=organization.id, user_id=user.id, role=invite.role, status=MembershipStatus.ACTIVE, ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.MEMBER_JOIN, organization_id=organization.id, actor_user_id=user.id, target_type="invite", target_id=invite.id, metadata={"email": invite.email, "role": invite.role.value}, ip=ip, user_agent=ua, ) # Burn the token regardless: whether the membership was just created or # already existed, this invite must not be redeemable a second time. await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC)) return AcceptInviteResult( organization_id=organization.id, organization_name=organization.name, role=invite.role.value, )