swiftops-backend / src /app /services /invitation_service.py
kamau1's picture
fix: Remove project status restrictions for invitations
e6976da
"""
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"
)