Spaces:
Sleeping
Sleeping
| """ | |
| User Invitation Management Endpoints | |
| """ | |
| from fastapi import APIRouter, Depends, HTTPException, status, Query | |
| from sqlalchemy.orm import Session | |
| from typing import List, Optional | |
| from uuid import UUID | |
| from app.api.deps import get_db, get_current_active_user | |
| from app.models.user import User | |
| from app.models.invitation import UserInvitation | |
| from app.models.client import Client | |
| from app.models.contractor import Contractor | |
| from app.schemas.invitation import ( | |
| InvitationCreate, | |
| InvitationResponse, | |
| InvitationPublicResponse, | |
| InvitationValidate, | |
| InvitationAccept, | |
| InvitationResend, | |
| InvitationListResponse | |
| ) | |
| from app.services.invitation_service import InvitationService | |
| from app.schemas.auth import TokenResponse | |
| from app.core.permissions import require_permission | |
| import logging | |
| import math | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter(prefix="/invitations", tags=["Invitations"]) | |
| # Initialize invitation service | |
| invitation_service = InvitationService() | |
| async def create_invitation( | |
| invitation_data: InvitationCreate, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_active_user) | |
| ): | |
| """ | |
| Create a new user invitation | |
| **Authorization:** | |
| - `platform_admin` - Can invite to any organization | |
| - `client_admin` - Can invite to their client only | |
| - `contractor_admin` - Can invite to their contractor only | |
| **Notification:** | |
| - Default: WhatsApp (saves email credits) | |
| - Fallback: Email if WhatsApp fails | |
| - Options: 'whatsapp', 'email', 'both' | |
| """ | |
| invitation = await invitation_service.create_invitation( | |
| invitation_data=invitation_data, | |
| invited_by_user=current_user, | |
| db=db | |
| ) | |
| # Enrich response with organization name | |
| response = InvitationResponse.model_validate(invitation) | |
| return response | |
| async def list_invitations( | |
| skip: int = Query(0, ge=0, description="Number of records to skip"), | |
| limit: int = Query(50, ge=1, le=100, description="Maximum records to return"), | |
| status: Optional[str] = Query(None, description="Filter by status"), | |
| client_id: Optional[UUID] = Query(None, description="Filter by client"), | |
| contractor_id: Optional[UUID] = Query(None, description="Filter by contractor"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_active_user) | |
| ): | |
| """ | |
| List invitations with pagination and filters | |
| **Authorization:** | |
| - `platform_admin` - Can view all invitations | |
| - `client_admin` - Can view their client's invitations only | |
| - `contractor_admin` - Can view their contractor's invitations only | |
| - Other users - Can view invitations they created | |
| """ | |
| query = db.query(UserInvitation) | |
| # Apply authorization filters | |
| if current_user.role == 'platform_admin': | |
| # Platform admin sees all | |
| pass | |
| elif current_user.role == 'client_admin': | |
| # Client admin sees only their client's invitations | |
| query = query.filter(UserInvitation.client_id == current_user.client_id) | |
| elif current_user.role == 'contractor_admin': | |
| # Contractor admin sees only their contractor's invitations | |
| query = query.filter(UserInvitation.contractor_id == current_user.contractor_id) | |
| else: | |
| # Other users see only invitations they created | |
| query = query.filter(UserInvitation.invited_by_user_id == current_user.id) | |
| # Apply filters | |
| if status: | |
| query = query.filter(UserInvitation.status == status) | |
| if client_id: | |
| query = query.filter(UserInvitation.client_id == client_id) | |
| if contractor_id: | |
| query = query.filter(UserInvitation.contractor_id == contractor_id) | |
| # Get total count | |
| total = query.count() | |
| # Apply pagination | |
| invitations = query.order_by( | |
| UserInvitation.created_at.desc() | |
| ).offset(skip).limit(limit).all() | |
| # Calculate pagination info | |
| total_pages = math.ceil(total / limit) if limit > 0 else 0 | |
| current_page = (skip // limit) + 1 if limit > 0 else 1 | |
| return InvitationListResponse( | |
| invitations=invitations, | |
| total=total, | |
| page=current_page, | |
| page_size=limit, | |
| total_pages=total_pages | |
| ) | |
| async def get_invitation( | |
| invitation_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_active_user) | |
| ): | |
| """ | |
| Get a specific invitation by ID | |
| **Authorization:** | |
| - `platform_admin` - Can view any invitation | |
| - `client_admin` - Can view their client's invitations | |
| - `contractor_admin` - Can view their contractor's invitations | |
| - Other users - Can view invitations they created | |
| """ | |
| invitation = db.query(UserInvitation).filter( | |
| UserInvitation.id == invitation_id | |
| ).first() | |
| if not invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="Invitation not found" | |
| ) | |
| # Check authorization | |
| if current_user.role == 'platform_admin': | |
| pass # Can view any | |
| elif current_user.role == 'client_admin': | |
| if invitation.client_id != current_user.client_id: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Access denied" | |
| ) | |
| elif current_user.role == 'contractor_admin': | |
| if invitation.contractor_id != current_user.contractor_id: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Access denied" | |
| ) | |
| else: | |
| if invitation.invited_by_user_id != current_user.id: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Access denied" | |
| ) | |
| return invitation | |
| async def resend_invitation( | |
| invitation_id: UUID, | |
| resend_data: InvitationResend, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_active_user) | |
| ): | |
| """ | |
| Resend an invitation notification | |
| **Behavior:** | |
| - If invitation is not expired: Resends same token | |
| - If invitation is expired: Generates new token and extends expiry by 72 hours | |
| **Authorization:** | |
| - `platform_admin` - Can resend any invitation | |
| - Org admins - Can resend their org's invitations | |
| - Other users - Can resend invitations they created | |
| """ | |
| invitation = await invitation_service.resend_invitation( | |
| invitation_id=str(invitation_id), | |
| method=resend_data.invitation_method, | |
| db=db | |
| ) | |
| return invitation | |
| async def cancel_invitation( | |
| invitation_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_active_user) | |
| ): | |
| """ | |
| Cancel a pending invitation | |
| **Authorization:** | |
| - `platform_admin` - Can cancel any invitation | |
| - Org admins - Can cancel their org's invitations | |
| - Other users - Can cancel invitations they created | |
| """ | |
| invitation_service.cancel_invitation( | |
| invitation_id=str(invitation_id), | |
| db=db | |
| ) | |
| return None | |
| # ============================================ | |
| # PUBLIC ENDPOINTS (No authentication required) | |
| # ============================================ | |
| async def validate_invitation_token( | |
| validation_data: InvitationValidate, | |
| db: Session = Depends(get_db) | |
| ): | |
| """ | |
| Validate an invitation token (PUBLIC endpoint) | |
| Used by frontend to check if invitation is valid before showing registration form. | |
| No authentication required. | |
| """ | |
| from app.services.token_service import TokenService | |
| token_service = TokenService() | |
| invitation = token_service.validate_token(validation_data.token, db) | |
| if not invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Invalid or expired invitation token" | |
| ) | |
| # Get organization name | |
| organization_name = None | |
| organization_type = None | |
| if invitation.client_id: | |
| client = db.query(Client).filter(Client.id == invitation.client_id).first() | |
| organization_name = client.name if client else None | |
| organization_type = 'client' | |
| elif invitation.contractor_id: | |
| contractor = db.query(Contractor).filter(Contractor.id == invitation.contractor_id).first() | |
| organization_name = contractor.name if contractor else None | |
| organization_type = 'contractor' | |
| else: | |
| organization_name = 'SwiftOps Platform' | |
| organization_type = 'platform' | |
| # Split invited_name into suggested first and last names for form pre-filling | |
| suggested_first_name = None | |
| suggested_last_name = None | |
| if invitation.invited_name: | |
| # Smart name splitting | |
| name_parts = invitation.invited_name.strip().split() | |
| if len(name_parts) == 1: | |
| # Single name - use as first name | |
| suggested_first_name = name_parts[0] | |
| elif len(name_parts) == 2: | |
| # Two parts - first and last | |
| suggested_first_name = name_parts[0] | |
| suggested_last_name = name_parts[1] | |
| else: | |
| # Multiple parts - first word is first name, rest is last name | |
| # e.g., "John Michael Doe" → first="John", last="Michael Doe" | |
| suggested_first_name = name_parts[0] | |
| suggested_last_name = " ".join(name_parts[1:]) | |
| # Get project details if this is a project invitation | |
| project_name = None | |
| project_role_name = None | |
| region_name = None | |
| if invitation.project_id: | |
| from app.models.project import Project, ProjectRole, ProjectRegion | |
| # Get project name | |
| project = db.query(Project).filter(Project.id == invitation.project_id).first() | |
| project_name = project.title if project else None | |
| # Get project role name | |
| if invitation.project_role_id: | |
| project_role = db.query(ProjectRole).filter(ProjectRole.id == invitation.project_role_id).first() | |
| project_role_name = project_role.role_name if project_role else None | |
| # Get region name | |
| if invitation.project_region_id: | |
| region = db.query(ProjectRegion).filter(ProjectRegion.id == invitation.project_region_id).first() | |
| region_name = region.region_name if region else None | |
| return InvitationPublicResponse( | |
| id=invitation.id, | |
| email=invitation.email, | |
| phone=invitation.phone, | |
| invited_role=invitation.invited_role, | |
| status=invitation.status, | |
| expires_at=invitation.expires_at, | |
| organization_name=organization_name, | |
| organization_type=organization_type, | |
| is_expired=invitation.is_expired, | |
| is_valid=invitation.is_pending, | |
| suggested_first_name=suggested_first_name, | |
| suggested_last_name=suggested_last_name, | |
| project_id=invitation.project_id, | |
| project_name=project_name, | |
| project_role_name=project_role_name, | |
| region_name=region_name | |
| ) | |
| async def accept_invitation( | |
| acceptance_data: InvitationAccept, | |
| db: Session = Depends(get_db) | |
| ): | |
| """ | |
| Accept an invitation and create user account (PUBLIC endpoint) | |
| This endpoint: | |
| 1. Validates the invitation token | |
| 2. Creates Supabase Auth user | |
| 3. Creates local user profile | |
| 4. Marks invitation as accepted | |
| 5. Returns authentication token | |
| No authentication required (user doesn't exist yet). | |
| """ | |
| result = await invitation_service.accept_invitation( | |
| acceptance_data=acceptance_data, | |
| db=db | |
| ) | |
| return result | |