- 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>
436 lines
14 KiB
Python
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)
|