# app/modules/tenancy/services/store_team_service.py """ Store team management service. Handles: - Team member invitations - Invitation acceptance - Role assignment - Permission management """ import logging import secrets from datetime import datetime, timedelta from typing import Any from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.modules.tenancy.services.permission_discovery_service import ( permission_discovery_service, ) def get_preset_permissions(preset_name: str) -> set[str]: """Get permissions for a preset role.""" return permission_discovery_service.get_preset_permissions(preset_name) from app.modules.billing.exceptions import TierLimitExceededException from app.modules.tenancy.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, TeamInvitationAlreadyAcceptedException, TeamMemberAlreadyExistsException, UserNotFoundException, ) from app.modules.tenancy.models import Role, Store, StoreUser, User from middleware.auth import AuthManager logger = logging.getLogger(__name__) class StoreTeamService: """Service for managing store team members.""" def __init__(self): self.auth_manager = AuthManager() def invite_team_member( self, db: Session, store: Store, inviter: User, email: str, role_name: str, custom_permissions: list[str] | None = None, ) -> dict[str, Any]: """ Invite a new team member to a store. Creates: 1. User account (if doesn't exist) 2. Role (if custom permissions provided) 3. StoreUser relationship with invitation token Args: db: Database session store: Store to invite to inviter: User sending the invitation email: Email of person to invite role_name: Role name (manager, staff, support, etc.) custom_permissions: Optional custom permissions (overrides preset) Returns: Dict with invitation details """ try: # Check team size limit from subscription from app.modules.billing.services import subscription_service subscription_service.check_team_limit(db, store.id) # Check if user already exists user = db.query(User).filter(User.email == email).first() if user: # Check if already a member existing_membership = ( db.query(StoreUser) .filter( StoreUser.store_id == store.id, StoreUser.user_id == user.id, ) .first() ) if existing_membership: if existing_membership.is_active: raise TeamMemberAlreadyExistsException( email, store.store_code ) # Reactivate old membership existing_membership.is_active = ( False # Will be activated on acceptance ) existing_membership.invitation_token = ( self._generate_invitation_token() ) existing_membership.invitation_sent_at = datetime.utcnow() existing_membership.invitation_accepted_at = None db.flush() logger.info( f"Re-invited user {email} to store {store.store_code}" ) return { "invitation_token": existing_membership.invitation_token, "email": email, "existing_user": True, } else: # Create new user account (inactive until invitation accepted) username = email.split("@")[0] # Ensure unique username base_username = username counter = 1 while db.query(User).filter(User.username == username).first(): username = f"{base_username}{counter}" counter += 1 # Generate temporary password (user will set real one on activation) temp_password = secrets.token_urlsafe(16) user = User( email=email, username=username, hashed_password=self.auth_manager.hash_password(temp_password), role="store_member", is_active=False, # Will be activated when invitation accepted is_email_verified=False, ) db.add(user) db.flush() # Get user.id logger.info(f"Created new user account for invitation: {email}") # Get or create role role = self._get_or_create_role( db=db, store=store, role_name=role_name, custom_permissions=custom_permissions, ) # Create store membership with invitation invitation_token = self._generate_invitation_token() store_user = StoreUser( store_id=store.id, user_id=user.id, role_id=role.id, invited_by=inviter.id, invitation_token=invitation_token, invitation_sent_at=datetime.utcnow(), is_active=False, # Will be activated on acceptance ) db.add(store_user) db.flush() logger.info( f"Invited {email} to store {store.store_code} " f"as {role_name} by {inviter.username}" ) # TODO: Send invitation email # self._send_invitation_email(email, store, invitation_token) return { "invitation_token": invitation_token, "email": email, "role": role_name, "existing_user": user.is_active, } except (TeamMemberAlreadyExistsException, TierLimitExceededException): raise except SQLAlchemyError as e: logger.error(f"Error inviting team member: {str(e)}") raise def accept_invitation( self, db: Session, invitation_token: str, password: str, first_name: str | None = None, last_name: str | None = None, ) -> dict[str, Any]: """ Accept a team invitation and activate account. Args: db: Database session invitation_token: Invitation token from email password: New password to set first_name: Optional first name last_name: Optional last name Returns: Dict with user and store info """ try: # Find invitation store_user = ( db.query(StoreUser) .filter( StoreUser.invitation_token == invitation_token, ) .first() ) if not store_user: raise InvalidInvitationTokenException() # Check if already accepted if store_user.invitation_accepted_at is not None: raise TeamInvitationAlreadyAcceptedException() # Check token expiration (7 days) if store_user.invitation_sent_at: expiry_date = store_user.invitation_sent_at + timedelta(days=7) if datetime.utcnow() > expiry_date: raise InvalidInvitationTokenException("Invitation has expired") user = store_user.user store = store_user.store # Update user user.hashed_password = self.auth_manager.hash_password(password) user.is_active = True user.is_email_verified = True if first_name: user.first_name = first_name if last_name: user.last_name = last_name # Activate membership store_user.is_active = True store_user.invitation_accepted_at = datetime.utcnow() store_user.invitation_token = None # Clear token db.flush() logger.info( f"User {user.email} accepted invitation to store {store.store_code}" ) return { "user": user, "store": store, "role": store_user.role.name if store_user.role else "member", } except ( InvalidInvitationTokenException, TeamInvitationAlreadyAcceptedException, ): raise except SQLAlchemyError as e: logger.error(f"Error accepting invitation: {str(e)}") raise def remove_team_member( self, db: Session, store: Store, user_id: int, ) -> bool: """ Remove a team member from a store. Cannot remove owner. Args: db: Database session store: Store to remove from user_id: User ID to remove Returns: True if removed """ try: store_user = ( db.query(StoreUser) .filter( StoreUser.store_id == store.id, StoreUser.user_id == user_id, ) .first() ) if not store_user: raise UserNotFoundException(str(user_id)) # Cannot remove owner if store_user.is_owner: raise CannotRemoveOwnerException(user_id, store.id) # Soft delete - just deactivate store_user.is_active = False logger.info(f"Removed user {user_id} from store {store.store_code}") return True except (UserNotFoundException, CannotRemoveOwnerException): raise except SQLAlchemyError as e: logger.error(f"Error removing team member: {str(e)}") raise def update_member_role( self, db: Session, store: Store, user_id: int, new_role_name: str, custom_permissions: list[str] | None = None, ) -> StoreUser: """ Update a team member's role. Args: db: Database session store: Store user_id: User ID new_role_name: New role name custom_permissions: Optional custom permissions Returns: Updated StoreUser """ try: store_user = ( db.query(StoreUser) .filter( StoreUser.store_id == store.id, StoreUser.user_id == user_id, ) .first() ) if not store_user: raise UserNotFoundException(str(user_id)) # Cannot change owner's role if store_user.is_owner: raise CannotRemoveOwnerException(user_id, store.id) # Get or create new role new_role = self._get_or_create_role( db=db, store=store, role_name=new_role_name, custom_permissions=custom_permissions, ) store_user.role_id = new_role.id db.flush() logger.info( f"Updated role for user {user_id} in store {store.store_code} " f"to {new_role_name}" ) return store_user except (UserNotFoundException, CannotRemoveOwnerException): raise except SQLAlchemyError as e: logger.error(f"Error updating member role: {str(e)}") raise def get_team_members( self, db: Session, store: Store, include_inactive: bool = False, ) -> list[dict[str, Any]]: """ Get all team members for a store. Args: db: Database session store: Store include_inactive: Include inactive members Returns: List of team member info """ query = db.query(StoreUser).filter( StoreUser.store_id == store.id, ) if not include_inactive: query = query.filter(StoreUser.is_active == True) store_users = query.all() members = [] for vu in store_users: members.append( { "id": vu.user.id, "email": vu.user.email, "username": vu.user.username, "first_name": vu.user.first_name, "last_name": vu.user.last_name, "full_name": vu.user.full_name, "role_name": vu.role.name if vu.role else "owner", "role_id": vu.role.id if vu.role else None, "permissions": vu.get_all_permissions(), "is_active": vu.is_active, "is_owner": vu.is_owner, "invitation_pending": vu.is_invitation_pending, "invited_at": vu.invitation_sent_at, "accepted_at": vu.invitation_accepted_at, "joined_at": vu.invitation_accepted_at or vu.created_at or vu.user.created_at, } ) return members def get_store_roles(self, db: Session, store_id: int) -> list[dict[str, Any]]: """ Get all roles for a store. Creates default preset roles if none exist. Args: db: Database session store_id: Store ID Returns: List of role info dicts """ roles = db.query(Role).filter(Role.store_id == store_id).all() # Create default roles if none exist if not roles: default_role_names = ["manager", "staff", "support", "viewer", "marketing"] for role_name in default_role_names: permissions = list(get_preset_permissions(role_name)) role = Role( store_id=store_id, name=role_name, permissions=permissions, ) db.add(role) # noqa: PERF006 db.flush() # Flush to get IDs without committing (endpoint commits) roles = db.query(Role).filter(Role.store_id == store_id).all() return [ { "id": role.id, "name": role.name, "permissions": role.permissions or [], "store_id": role.store_id, "created_at": role.created_at, "updated_at": role.updated_at, } for role in roles ] # Private helper methods def _generate_invitation_token(self) -> str: """Generate a secure invitation token.""" return secrets.token_urlsafe(32) def _get_or_create_role( self, db: Session, store: Store, role_name: str, custom_permissions: list[str] | None = None, ) -> Role: """Get existing role or create new one with preset/custom permissions.""" # Try to find existing role with same name role = ( db.query(Role) .filter( Role.store_id == store.id, Role.name == role_name, ) .first() ) if role and custom_permissions is None: # Use existing role return role # Determine permissions if custom_permissions: permissions = custom_permissions else: # Get preset permissions permissions = list(get_preset_permissions(role_name)) if role: # Update existing role with new permissions role.permissions = permissions else: # Create new role role = Role( store_id=store.id, name=role_name, permissions=permissions, ) db.add(role) db.flush() return role def _send_invitation_email(self, email: str, store: Store, token: str): """Send invitation email (TODO: implement).""" # TODO: Implement email sending # Should include: # - Link to accept invitation: /store/invitation/accept?token={token} # - Store name # - Inviter name # - Expiry date # Create service instance store_team_service = StoreTeamService()