Spaces:
Sleeping
Sleeping
| """ | |
| Invitation Service - Core invitation management logic | |
| """ | |
| import os | |
| import logging | |
| from datetime import datetime, timezone, timedelta | |
| from typing import Optional, Dict, Any | |
| from sqlalchemy.orm import Session | |
| from sqlalchemy import or_ | |
| from fastapi import HTTPException, status | |
| from app.models.invitation import UserInvitation | |
| from app.models.user import User | |
| from app.models.client import Client | |
| from app.models.contractor import Contractor | |
| from app.schemas.invitation import InvitationCreate, InvitationAccept | |
| from app.services.token_service import TokenService | |
| from app.services.notification_service import NotificationService | |
| from app.core.supabase_auth import supabase_auth | |
| logger = logging.getLogger(__name__) | |
| class InvitationService: | |
| """Service for managing user invitations""" | |
| def __init__(self): | |
| self.token_service = TokenService() | |
| self.notification_service = NotificationService() | |
| self.invitation_expiry_hours = int(os.getenv('INVITATION_TOKEN_EXPIRY_HOURS', '72')) | |
| async def create_invitation( | |
| self, | |
| invitation_data: InvitationCreate, | |
| invited_by_user: User, | |
| db: Session | |
| ) -> UserInvitation: | |
| """ | |
| Create a new user invitation | |
| Args: | |
| invitation_data: Invitation creation data | |
| invited_by_user: User creating the invitation | |
| db: Database session | |
| Returns: | |
| Created invitation | |
| Raises: | |
| HTTPException: If validation fails | |
| """ | |
| # Validate authorization | |
| self._validate_invitation_authorization( | |
| inviter=invited_by_user, | |
| role=invitation_data.invited_role, | |
| client_id=invitation_data.client_id, | |
| contractor_id=invitation_data.contractor_id | |
| ) | |
| # Check for existing user with this email | |
| existing_user = db.query(User).filter(User.email == invitation_data.email).first() | |
| if existing_user: | |
| # For project invitations, suggest adding user directly to project team | |
| if invitation_data.project_id: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=f"User with email {invitation_data.email} already exists. Add them directly to the project team instead." | |
| ) | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=f"User with email {invitation_data.email} already exists" | |
| ) | |
| # Check for existing pending invitation | |
| existing_invitation = db.query(UserInvitation).filter( | |
| UserInvitation.email == invitation_data.email, | |
| UserInvitation.status == 'pending', | |
| or_( | |
| UserInvitation.client_id == invitation_data.client_id, | |
| UserInvitation.contractor_id == invitation_data.contractor_id | |
| ) | |
| ).first() | |
| if existing_invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="A pending invitation already exists for this user" | |
| ) | |
| # Validate project context if provided | |
| project_name = None | |
| if invitation_data.project_id: | |
| project_name = self._validate_project_context( | |
| project_id=invitation_data.project_id, | |
| project_role_id=invitation_data.project_role_id, | |
| project_region_id=invitation_data.project_region_id, | |
| project_subcontractor_id=invitation_data.project_subcontractor_id, | |
| inviter=invited_by_user, | |
| db=db | |
| ) | |
| # Get organization name for notification | |
| organization_name = self._get_organization_name( | |
| client_id=invitation_data.client_id, | |
| contractor_id=invitation_data.contractor_id, | |
| db=db | |
| ) | |
| # Generate secure token | |
| token = self.token_service.generate_token() | |
| # Calculate expiry | |
| expires_at = datetime.now(timezone.utc) + timedelta(hours=self.invitation_expiry_hours) | |
| # Create invitation record | |
| invitation = UserInvitation( | |
| email=invitation_data.email, | |
| phone=invitation_data.phone, | |
| invited_name=invitation_data.invited_name, | |
| invited_role=invitation_data.invited_role, | |
| client_id=invitation_data.client_id, | |
| contractor_id=invitation_data.contractor_id, | |
| project_id=invitation_data.project_id, | |
| project_role_id=invitation_data.project_role_id, | |
| project_region_id=invitation_data.project_region_id, | |
| project_subcontractor_id=invitation_data.project_subcontractor_id, | |
| token=token, | |
| status='pending', | |
| invitation_method=invitation_data.invitation_method, | |
| invited_by_user_id=invited_by_user.id, | |
| invited_at=datetime.now(timezone.utc), | |
| expires_at=expires_at | |
| ) | |
| db.add(invitation) | |
| db.commit() | |
| db.refresh(invitation) | |
| # Send notification - use provided name or derive from email | |
| name = invitation_data.invited_name or invitation_data.email.split('@')[0].title() | |
| await self._send_invitation_notification( | |
| invitation=invitation, | |
| name=name, | |
| organization_name=organization_name, | |
| project_name=project_name, | |
| db=db | |
| ) | |
| log_msg = f"Invitation created for {invitation_data.email} by {invited_by_user.email}" | |
| if invitation_data.project_id: | |
| log_msg += f" for project {invitation_data.project_id}" | |
| logger.info(log_msg) | |
| return invitation | |
| async def accept_invitation( | |
| self, | |
| acceptance_data: InvitationAccept, | |
| db: Session | |
| ) -> Dict[str, Any]: | |
| """ | |
| Accept invitation and create Supabase Auth user + local profile | |
| Args: | |
| acceptance_data: Invitation acceptance data | |
| db: Database session | |
| Returns: | |
| Dict with access token and user info | |
| Raises: | |
| HTTPException: If validation fails | |
| """ | |
| # Validate token | |
| invitation = self.token_service.validate_token(acceptance_data.token, db) | |
| if not invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Invalid or expired invitation token" | |
| ) | |
| # Check if user already exists | |
| existing_user = db.query(User).filter(User.email == invitation.email).first() | |
| if existing_user: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="User already exists" | |
| ) | |
| try: | |
| # Create Supabase Auth user | |
| full_name = f"{acceptance_data.first_name} {acceptance_data.last_name}" | |
| auth_response = await supabase_auth.sign_up( | |
| email=invitation.email, | |
| password=acceptance_data.password, | |
| user_metadata={ | |
| "first_name": acceptance_data.first_name, | |
| "last_name": acceptance_data.last_name, | |
| "phone": acceptance_data.phone or invitation.phone, | |
| "full_name": full_name | |
| } | |
| ) | |
| auth_user = auth_response["user"] | |
| session = auth_response["session"] | |
| # Create local user profile | |
| new_user = User( | |
| id=auth_user.id, | |
| email=invitation.email, | |
| name=full_name, | |
| phone=acceptance_data.phone or invitation.phone, | |
| is_active=True, | |
| role=invitation.invited_role, | |
| status='active', # User is active after accepting invitation | |
| client_id=invitation.client_id, | |
| contractor_id=invitation.contractor_id | |
| # Note: activated_at removed - tracked in user_invitations.accepted_at | |
| ) | |
| db.add(new_user) | |
| db.flush() # Get user ID for project team assignment | |
| # Auto-add to project team if this is a project invitation | |
| if invitation.project_id: | |
| self._add_user_to_project_team( | |
| invitation=invitation, | |
| user_id=new_user.id, | |
| db=db | |
| ) | |
| # Mark invitation as accepted | |
| self.token_service.mark_accepted(invitation, db) | |
| db.commit() | |
| db.refresh(new_user) | |
| logger.info(f"User created from invitation: {invitation.email}") | |
| return { | |
| "access_token": session.access_token, | |
| "token_type": "bearer", | |
| "user": { | |
| "id": str(new_user.id), | |
| "email": new_user.email, | |
| "first_name": new_user.first_name, | |
| "last_name": new_user.last_name, | |
| "full_name": new_user.full_name, | |
| "role": new_user.role, | |
| "is_active": new_user.is_active | |
| } | |
| } | |
| except Exception as e: | |
| db.rollback() | |
| logger.error(f"Error accepting invitation: {str(e)}") | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail=f"Failed to create user: {str(e)}" | |
| ) | |
| async def resend_invitation( | |
| self, | |
| invitation_id: str, | |
| method: Optional[str], | |
| db: Session | |
| ) -> UserInvitation: | |
| """ | |
| Resend an invitation (regenerates token if expired) | |
| Args: | |
| invitation_id: Invitation ID | |
| method: Delivery method override | |
| db: Database session | |
| Returns: | |
| Updated invitation | |
| Raises: | |
| HTTPException: If invitation not found or invalid | |
| """ | |
| invitation = db.query(UserInvitation).filter( | |
| UserInvitation.id == invitation_id, | |
| UserInvitation.status == 'pending' | |
| ).first() | |
| if not invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="Invitation not found or already processed" | |
| ) | |
| # If expired, regenerate token and extend expiry | |
| if invitation.is_expired: | |
| invitation.token = self.token_service.generate_token() | |
| invitation.expires_at = datetime.now(timezone.utc) + timedelta(hours=self.invitation_expiry_hours) | |
| logger.info(f"Regenerated token for expired invitation: {invitation.email}") | |
| # Get organization name | |
| organization_name = self._get_organization_name( | |
| client_id=invitation.client_id, | |
| contractor_id=invitation.contractor_id, | |
| db=db | |
| ) | |
| # Update method if provided | |
| if method: | |
| invitation.invitation_method = method | |
| # Send notification (with new token if regenerated) - use provided name or derive from email | |
| name = invitation.invited_name or invitation.email.split('@')[0].title() | |
| await self._send_invitation_notification( | |
| invitation=invitation, | |
| name=name, | |
| organization_name=organization_name, | |
| db=db | |
| ) | |
| db.commit() | |
| db.refresh(invitation) | |
| logger.info(f"Invitation resent: {invitation.email}") | |
| return invitation | |
| def cancel_invitation( | |
| self, | |
| invitation_id: str, | |
| db: Session | |
| ) -> UserInvitation: | |
| """ | |
| Cancel a pending invitation | |
| Args: | |
| invitation_id: Invitation ID | |
| db: Database session | |
| Returns: | |
| Cancelled invitation | |
| Raises: | |
| HTTPException: If invitation not found | |
| """ | |
| invitation = db.query(UserInvitation).filter( | |
| UserInvitation.id == invitation_id, | |
| UserInvitation.status == 'pending' | |
| ).first() | |
| if not invitation: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="Invitation not found or already processed" | |
| ) | |
| invitation.status = 'cancelled' | |
| db.commit() | |
| db.refresh(invitation) | |
| logger.info(f"Invitation cancelled: {invitation.email}") | |
| return invitation | |
| def _validate_invitation_authorization( | |
| self, | |
| inviter: User, | |
| role: str, | |
| client_id: Optional[str], | |
| contractor_id: Optional[str] | |
| ) -> None: | |
| """Validate that inviter has permission to create this invitation""" | |
| # Platform admin can invite anyone | |
| if inviter.role == 'platform_admin': | |
| return | |
| # Client admin can only invite to their client | |
| if inviter.role == 'client_admin': | |
| if not client_id or str(inviter.client_id) != str(client_id): | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You can only invite users to your own organization" | |
| ) | |
| return | |
| # Contractor admin can only invite to their contractor | |
| if inviter.role == 'contractor_admin': | |
| if not contractor_id or str(inviter.contractor_id) != str(contractor_id): | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You can only invite users to your own organization" | |
| ) | |
| return | |
| # Project manager, dispatcher, sales_manager can invite to projects | |
| # (project-specific authorization checked in _validate_project_context) | |
| if inviter.role in ['project_manager', 'dispatcher', 'sales_manager']: | |
| # Must be inviting to their contractor's organization | |
| if not contractor_id or str(inviter.contractor_id) != str(contractor_id): | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You can only invite users to your contractor's organization" | |
| ) | |
| return | |
| # Other roles cannot invite | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You do not have permission to invite users" | |
| ) | |
| def _get_organization_name( | |
| self, | |
| client_id: Optional[str], | |
| contractor_id: Optional[str], | |
| db: Session | |
| ) -> str: | |
| """Get organization name for notification""" | |
| if client_id: | |
| client = db.query(Client).filter(Client.id == client_id).first() | |
| return client.name if client else "SwiftOps" | |
| elif contractor_id: | |
| contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first() | |
| return contractor.name if contractor else "SwiftOps" | |
| return "SwiftOps" | |
| async def _send_invitation_notification( | |
| self, | |
| invitation: UserInvitation, | |
| name: str, | |
| organization_name: str, | |
| project_name: Optional[str], | |
| db: Session | |
| ) -> None: | |
| """Send invitation notification and update delivery status""" | |
| results = await self.notification_service.send_invitation( | |
| email=invitation.email, | |
| phone=invitation.phone, | |
| name=name, | |
| organization_name=organization_name, | |
| role=invitation.invited_role, | |
| token=invitation.token, | |
| method=invitation.invitation_method, | |
| expiry_hours=self.invitation_expiry_hours, | |
| project_name=project_name | |
| ) | |
| # Update delivery status | |
| if results['whatsapp_sent']: | |
| invitation.whatsapp_sent = True | |
| invitation.whatsapp_sent_at = datetime.now(timezone.utc) | |
| if results.get('whatsapp_error'): | |
| invitation.whatsapp_error = results['whatsapp_error'] | |
| if results['email_sent']: | |
| invitation.email_sent = True | |
| invitation.email_sent_at = datetime.now(timezone.utc) | |
| if results.get('email_error'): | |
| invitation.email_error = results['email_error'] | |
| db.commit() | |
| def _validate_project_context( | |
| self, | |
| project_id: str, | |
| project_role_id: str, | |
| project_region_id: Optional[str], | |
| project_subcontractor_id: Optional[str], | |
| inviter: User, | |
| db: Session | |
| ) -> str: | |
| """ | |
| Validate project context for invitation | |
| Returns: | |
| Project name for notification | |
| Raises: | |
| HTTPException: If validation fails | |
| """ | |
| from app.models.project import Project, ProjectRole, ProjectRegion, ProjectSubcontractor | |
| # Validate project exists and is in valid status | |
| project = db.query(Project).filter( | |
| Project.id == project_id, | |
| Project.deleted_at == None | |
| ).first() | |
| if not project: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Project {project_id} not found" | |
| ) | |
| # Validate inviter has permission for this project | |
| self._validate_project_invitation_permission(inviter, project) | |
| # Validate project role exists and belongs to this project | |
| project_role = db.query(ProjectRole).filter( | |
| ProjectRole.id == project_role_id, | |
| ProjectRole.project_id == project_id, | |
| ProjectRole.deleted_at == None | |
| ).first() | |
| if not project_role: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Project role {project_role_id} not found or doesn't belong to project {project_id}" | |
| ) | |
| # Validate project region if provided | |
| if project_region_id: | |
| region = db.query(ProjectRegion).filter( | |
| ProjectRegion.id == project_region_id, | |
| ProjectRegion.project_id == project_id, | |
| ProjectRegion.deleted_at == None | |
| ).first() | |
| if not region: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Project region {project_region_id} not found or doesn't belong to project {project_id}" | |
| ) | |
| # Validate project subcontractor if provided | |
| if project_subcontractor_id: | |
| subcontractor = db.query(ProjectSubcontractor).filter( | |
| ProjectSubcontractor.id == project_subcontractor_id, | |
| ProjectSubcontractor.project_id == project_id, | |
| ProjectSubcontractor.deleted_at == None | |
| ).first() | |
| if not subcontractor: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"Project subcontractor {project_subcontractor_id} not found or doesn't belong to project {project_id}" | |
| ) | |
| return project.title | |
| def _validate_project_invitation_permission( | |
| self, | |
| inviter: User, | |
| project: 'Project' | |
| ) -> None: | |
| """ | |
| Validate that inviter can invite to this project | |
| Raises: | |
| HTTPException: If user doesn't have permission | |
| """ | |
| # Platform admin can invite to any project | |
| if inviter.role == 'platform_admin': | |
| return | |
| # Primary manager can invite to their project | |
| if inviter.role == 'project_manager' and str(project.primary_manager_id) == str(inviter.id): | |
| return | |
| # Dispatcher/Sales Manager can invite if they belong to project's contractor | |
| if inviter.role in ['dispatcher', 'sales_manager']: | |
| if str(inviter.contractor_id) == str(project.contractor_id): | |
| return | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="You don't have permission to invite users to this project" | |
| ) | |
| def _add_user_to_project_team( | |
| self, | |
| invitation: UserInvitation, | |
| user_id: str, | |
| db: Session | |
| ) -> None: | |
| """ | |
| Auto-add user to project team after accepting invitation | |
| Args: | |
| invitation: The accepted invitation with project context | |
| user_id: ID of newly created user | |
| db: Database session | |
| """ | |
| from app.models.project import Project | |
| from app.models.project_team import ProjectTeam | |
| # Verify project is still in valid status | |
| project = db.query(Project).filter( | |
| Project.id == invitation.project_id, | |
| Project.deleted_at == None | |
| ).first() | |
| if not project: | |
| logger.warning(f"Project {invitation.project_id} not found when adding user to team") | |
| return | |
| # Create project team entry | |
| team_member = ProjectTeam( | |
| project_id=invitation.project_id, | |
| user_id=user_id, | |
| project_role_id=invitation.project_role_id, | |
| project_region_id=invitation.project_region_id, | |
| project_subcontractor_id=invitation.project_subcontractor_id, | |
| is_lead=False, | |
| is_assigned_slot=True, | |
| assigned_at=datetime.now(timezone.utc) | |
| ) | |
| db.add(team_member) | |
| logger.info( | |
| f"Auto-added user {user_id} to project {invitation.project_id} team " | |
| f"via invitation acceptance" | |
| ) | |