"""User service for Mantimon TCG. This module provides business logic for user account operations, including OAuth-based user creation, account linking, and premium status management. The service layer contains business logic while repositories handle pure data access. This separation enables testing and different storage backends. Example: from app.services.user_service import UserService from app.repositories.postgres import PostgresUserRepository, PostgresLinkedAccountRepository # Create service with injected repositories user_repo = PostgresUserRepository(db) linked_repo = PostgresLinkedAccountRepository(db) service = UserService(user_repo, linked_repo) # Use service user = await service.get_by_id(user_id) user, created = await service.get_or_create_from_oauth(oauth_info) """ from datetime import datetime from uuid import UUID from app.repositories.protocols import ( LinkedAccountEntry, LinkedAccountRepository, UserEntry, UserRepository, ) from app.schemas.user import OAuthUserInfo, UserUpdate class AccountLinkingError(Exception): """Error during account linking operation.""" pass class UserService: """Service for user account operations. Provides business logic for user CRUD, OAuth-based creation, account linking, and premium subscription management. Attributes: _user_repo: Repository for user data access. _linked_repo: Repository for linked account data access. """ def __init__( self, user_repository: UserRepository, linked_account_repository: LinkedAccountRepository, ) -> None: """Initialize with repository dependencies. Args: user_repository: Repository for user data access. linked_account_repository: Repository for linked account data access. """ self._user_repo = user_repository self._linked_repo = linked_account_repository async def get_by_id(self, user_id: UUID) -> UserEntry | None: """Get a user by their ID. Args: user_id: The user's UUID. Returns: UserEntry if found, None otherwise. Example: user = await service.get_by_id(user_id) if user: print(f"Found user: {user.display_name}") """ return await self._user_repo.get_by_id(user_id) async def get_by_email(self, email: str) -> UserEntry | None: """Get a user by their email address. Args: email: The user's email address. Returns: UserEntry if found, None otherwise. Example: user = await service.get_by_email("player@example.com") """ return await self._user_repo.get_by_email(email) async def get_by_oauth(self, provider: str, oauth_id: str) -> UserEntry | None: """Get a user by their OAuth provider and ID. Args: provider: OAuth provider name (google, discord). oauth_id: Unique ID from the OAuth provider. Returns: UserEntry if found, None otherwise. Example: user = await service.get_by_oauth("google", "123456789") """ return await self._user_repo.get_by_oauth(provider, oauth_id) async def create( self, email: str, display_name: str, oauth_provider: str, oauth_id: str, avatar_url: str | None = None, ) -> UserEntry: """Create a new user. Args: email: User's email address. display_name: Public display name. oauth_provider: OAuth provider name. oauth_id: Unique ID from the OAuth provider. avatar_url: Optional avatar URL. Returns: The created UserEntry. Example: user = await service.create( email="player@example.com", display_name="Player1", oauth_provider="google", oauth_id="123456789" ) """ return await self._user_repo.create( email=email, display_name=display_name, oauth_provider=oauth_provider, oauth_id=oauth_id, avatar_url=avatar_url, ) async def create_from_oauth(self, oauth_info: OAuthUserInfo) -> UserEntry: """Create a new user from OAuth provider info. Convenience method that extracts fields from OAuthUserInfo. Args: oauth_info: Normalized OAuth user information. Returns: The created UserEntry. Example: oauth_info = OAuthUserInfo( provider="google", oauth_id="123456789", email="player@example.com", name="Player One", avatar_url="https://..." ) user = await service.create_from_oauth(oauth_info) """ return await self.create( email=oauth_info.email, display_name=oauth_info.name, oauth_provider=oauth_info.provider, oauth_id=oauth_info.oauth_id, avatar_url=oauth_info.avatar_url, ) async def get_or_create_from_oauth( self, oauth_info: OAuthUserInfo, ) -> tuple[UserEntry, bool]: """Get existing user or create new one from OAuth info. First checks for existing user by OAuth provider+ID, then by email (for account linking), and finally creates a new user if not found. Args: oauth_info: Normalized OAuth user information. Returns: Tuple of (UserEntry, created) where created is True if new user. Example: user, created = await service.get_or_create_from_oauth(oauth_info) if created: print("Welcome, new user!") else: print("Welcome back!") """ # First, check by OAuth provider + ID (exact match) user = await self._user_repo.get_by_oauth(oauth_info.provider, oauth_info.oauth_id) if user: return user, False # Check by email for potential account linking # If user exists with same email but different OAuth, update their OAuth user = await self._user_repo.get_by_email(oauth_info.email) if user: # Update OAuth credentials for existing user # This links the new OAuth provider to the existing account updated_user = await self._user_repo.update( user_id=user.id, oauth_provider=oauth_info.provider, oauth_id=oauth_info.oauth_id, avatar_url=oauth_info.avatar_url if not user.avatar_url else None, ) return updated_user or user, False # Create new user user = await self.create_from_oauth(oauth_info) return user, True async def update( self, user_id: UUID, update_data: UserUpdate, ) -> UserEntry | None: """Update user profile fields. Only updates fields that are explicitly provided. Uses UNSET pattern for avatar_url to distinguish "not provided" from "set to None". Args: user_id: The user's UUID. update_data: Fields to update. Returns: The updated UserEntry, or None if not found. Example: update_data = UserUpdate(display_name="New Name") user = await service.update(user_id, update_data) """ from app.repositories.protocols import UNSET # Use UNSET for avatar_url unless explicitly provided avatar_url = ( update_data.avatar_url if "avatar_url" in update_data.model_fields_set else UNSET ) return await self._user_repo.update( user_id=user_id, display_name=update_data.display_name, avatar_url=avatar_url, ) async def update_last_login(self, user_id: UUID) -> UserEntry | None: """Update the user's last login timestamp. Args: user_id: The user's UUID. Returns: The updated UserEntry, or None if not found. Example: user = await service.update_last_login(user_id) """ return await self._user_repo.update_last_login(user_id) async def update_premium( self, user_id: UUID, premium_until: datetime | None, ) -> UserEntry | None: """Update user's premium subscription status. Args: user_id: The user's UUID. premium_until: When premium expires, or None to remove premium. Returns: The updated UserEntry, or None if not found. Example: # Grant 30 days of premium expires = datetime.now(UTC) + timedelta(days=30) user = await service.update_premium(user_id, expires) # Remove premium user = await service.update_premium(user_id, None) """ is_premium = premium_until is not None return await self._user_repo.update_premium( user_id=user_id, is_premium=is_premium, premium_until=premium_until, ) async def delete(self, user_id: UUID) -> bool: """Delete a user account. This will cascade delete all related data (decks, collection, etc.) based on the database constraints. Args: user_id: The user's UUID. Returns: True if deleted, False if not found. Example: success = await service.delete(user_id) """ return await self._user_repo.delete(user_id) # ========================================================================= # Linked Account Operations # ========================================================================= async def get_linked_accounts(self, user_id: UUID) -> list[LinkedAccountEntry]: """Get all linked OAuth accounts for a user. Args: user_id: The user's UUID. Returns: List of LinkedAccountEntry. """ return await self._linked_repo.get_by_user(user_id) async def get_linked_account( self, provider: str, oauth_id: str, ) -> LinkedAccountEntry | None: """Get a linked account by provider and OAuth ID. Args: provider: OAuth provider name (google, discord). oauth_id: Unique ID from the OAuth provider. Returns: LinkedAccountEntry if found, None otherwise. """ return await self._linked_repo.get_by_provider(provider, oauth_id) async def link_oauth_account( self, user_id: UUID, user_oauth_provider: str, oauth_info: OAuthUserInfo, ) -> LinkedAccountEntry: """Link an additional OAuth provider to a user account. Args: user_id: The user's UUID. user_oauth_provider: The user's primary OAuth provider. oauth_info: OAuth information from the provider. Returns: The created LinkedAccountEntry. Raises: AccountLinkingError: If provider is already linked to this or another user. Example: linked = await service.link_oauth_account(user.id, user.oauth_provider, discord_info) """ # Check if this provider+oauth_id is already linked to any user existing = await self._linked_repo.get_by_provider(oauth_info.provider, oauth_info.oauth_id) if existing: if existing.user_id == user_id: raise AccountLinkingError( f"{oauth_info.provider.title()} account is already linked to your account" ) raise AccountLinkingError( f"This {oauth_info.provider.title()} account is already linked to another user" ) # Check if this is the user's primary OAuth provider if user_oauth_provider == oauth_info.provider: raise AccountLinkingError( f"{oauth_info.provider.title()} is your primary login provider" ) # Check if user already has this provider linked linked_accounts = await self._linked_repo.get_by_user(user_id) for linked in linked_accounts: if linked.provider == oauth_info.provider: raise AccountLinkingError( f"You already have a {oauth_info.provider.title()} account linked" ) # Create the linked account return await self._linked_repo.create( user_id=user_id, provider=oauth_info.provider, oauth_id=oauth_info.oauth_id, email=oauth_info.email, display_name=oauth_info.name, avatar_url=oauth_info.avatar_url, ) async def unlink_oauth_account( self, user_id: UUID, user_oauth_provider: str, provider: str, ) -> bool: """Unlink an OAuth provider from a user account. Cannot unlink the primary OAuth provider. Args: user_id: The user's UUID. user_oauth_provider: The user's primary OAuth provider. provider: OAuth provider name to unlink. Returns: True if unlinked, False if provider wasn't linked. Raises: AccountLinkingError: If trying to unlink the primary provider. Example: success = await service.unlink_oauth_account(user.id, user.oauth_provider, "discord") """ # Cannot unlink primary provider if user_oauth_provider == provider: raise AccountLinkingError( f"Cannot unlink {provider.title()} - it is your primary login provider" ) return await self._linked_repo.delete(user_id, provider)