mantimon-tcg/backend/app/services/user_service.py
Cal Corum 7fcb86ff51 Implement UserRepository pattern with dependency injection
- Add UserRepository and LinkedAccountRepository protocols to protocols.py
- Add UserEntry and LinkedAccountEntry DTOs for service layer decoupling
- Implement PostgresUserRepository and PostgresLinkedAccountRepository
- Refactor UserService to use constructor-injected repositories
- Add get_user_service factory and UserServiceDep to API deps
- Update auth.py and users.py endpoints to use UserServiceDep
- Rewrite tests to use FastAPI dependency overrides (no monkey patching)

This follows the established repository pattern used by DeckService and
CollectionService, enabling future offline fork support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 07:30:16 -06:00

436 lines
14 KiB
Python

"""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)