"""Business logic för inbjudningsflödet.""" import secrets from datetime import datetime, timedelta from typing import Optional from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from fastapi import HTTPException, status from app.models import Invitation, User, UserRole, UserStatus, Tenant from app.schemas import InvitationCreate, AcceptInvitationRequest from app.config import get_settings from app.security import get_password_hash, can_manage from app.services.user_service import UserService settings = get_settings() class InvitationService: def __init__(self, db: AsyncSession): self.db = db self.user_service = UserService(db) async def get_by_token(self, token: str) -> Optional[Invitation]: result = await self.db.execute(select(Invitation).where(Invitation.token == token)) return result.scalar_one_or_none() async def list_invitations( self, tenant_id: Optional[UUID] = None, pending_only: bool = True, skip: int = 0, limit: int = 100, ) -> tuple[list[Invitation], int]: query = select(Invitation) count_query = select(func.count()).select_from(Invitation) if tenant_id: query = query.where(Invitation.tenant_id == tenant_id) count_query = count_query.where(Invitation.tenant_id == tenant_id) if pending_only: query = query.where(Invitation.accepted_at.is_(None)) count_query = count_query.where(Invitation.accepted_at.is_(None)) total_result = await self.db.execute(count_query) total = total_result.scalar_one() result = await self.db.execute(query.offset(skip).limit(limit).order_by(Invitation.created_at.desc())) return result.scalars().all(), total async def create_invitation(self, data: InvitationCreate, invited_by: User) -> Invitation: # Validera att inviteraren kan ge denna roll if not invited_by.is_superuser and not can_manage(invited_by.role, data.role): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot invite with role equal or higher than your own", ) # Kolla om användaren redan finns existing_user = await self.user_service.get_by_email(data.email) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="User with this email already exists", ) # Kolla om det redan finns en aktiv inbjudan existing_invite = await self.db.execute( select(Invitation).where( Invitation.email == data.email.lower().strip(), Invitation.accepted_at.is_(None), Invitation.expires_at > datetime.utcnow(), ) ) if existing_invite.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Active invitation already exists for this email", ) if data.tenant_id: tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id)) if tenant.scalar_one_or_none() is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant not found", ) token = secrets.token_urlsafe(48) invitation = Invitation( email=data.email.lower().strip(), token=token, role=data.role, tenant_id=data.tenant_id, invited_by_id=invited_by.id, expires_at=datetime.utcnow() + timedelta(hours=settings.INVITATION_TOKEN_EXPIRE_HOURS), ) self.db.add(invitation) await self.db.commit() await self.db.refresh(invitation) return invitation async def accept_invitation(self, data: AcceptInvitationRequest) -> User: invitation = await self.get_by_token(data.token) if invitation is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found", ) if invitation.accepted_at is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invitation already accepted", ) if invitation.expires_at < datetime.utcnow(): raise HTTPException( status_code=status.HTTP_410_GONE, detail="Invitation has expired", ) # Skapa användaren user = User( email=invitation.email, first_name=data.first_name.strip(), last_name=data.last_name.strip(), role=invitation.role, tenant_id=invitation.tenant_id, status=UserStatus.ACTIVE, hashed_password=get_password_hash(data.password), ) self.db.add(user) invitation.accepted_at = datetime.utcnow() await self.db.commit() await self.db.refresh(user) return user async def revoke_invitation(self, invitation_id: UUID, actor: User) -> None: invitation = await self.db.execute(select(Invitation).where(Invitation.id == invitation_id)) inv = invitation.scalar_one_or_none() if inv is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found") if not actor.is_superuser and inv.invited_by_id != actor.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Can only revoke invitations you sent", ) await self.db.delete(inv) await self.db.commit() def get_invitation_link(self, token: str) -> str: return f"{settings.FRONTEND_SET_PASSWORD_URL}?token={token}"