"""User API router for Mantimon TCG. This module provides endpoints for user profile management: - Get current user profile - Update profile (display name, avatar) - List linked OAuth accounts - Session management - Starter deck selection (one-time for new players) All endpoints require authentication. Example: # Get current user GET /api/users/me Authorization: Bearer # Update profile PATCH /api/users/me {"display_name": "NewName"} # Select starter deck POST /api/users/me/starter-deck {"starter_type": "grass"} """ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from app.api.deps import CurrentUser, DeckServiceDep, UserServiceDep from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse from app.schemas.user import UserResponse, UserUpdate from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError from app.services.profanity_service import validate_display_name from app.services.token_store import token_store from app.services.user_service import AccountLinkingError router = APIRouter(prefix="/users", tags=["users"]) class LinkedAccountResponse(BaseModel): """Response for a linked OAuth account.""" provider: str = Field(..., description="OAuth provider name") email: str | None = Field(None, description="Email from this provider") linked_at: str = Field(..., description="When account was linked (ISO format)") class SessionsResponse(BaseModel): """Response for active sessions count.""" active_sessions: int = Field(..., description="Number of active sessions") @router.get("/me", response_model=UserResponse) async def get_current_user_profile( user: CurrentUser, ) -> UserResponse: """Get the current user's profile. Returns: User profile information. """ return UserResponse.model_validate(user) @router.patch("/me", response_model=UserResponse) async def update_current_user_profile( user: CurrentUser, user_service: UserServiceDep, update_data: UserUpdate, ) -> UserResponse: """Update the current user's profile. Only provided fields are updated. Args: update_data: Fields to update (display_name, avatar_url). Returns: Updated user profile. Raises: HTTPException: 400 if display_name contains profanity. """ # Validate display name for profanity if update_data.display_name is not None: is_valid, error = validate_display_name(update_data.display_name) if not is_valid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=error, ) updated_user = await user_service.update(user.id, update_data) return UserResponse.model_validate(updated_user) @router.get("/me/linked-accounts", response_model=list[LinkedAccountResponse]) async def get_linked_accounts( user: CurrentUser, ) -> list[LinkedAccountResponse]: """Get all OAuth accounts linked to the current user. Returns the primary OAuth provider plus any additional linked accounts. Returns: List of linked OAuth accounts. """ accounts = [] # Add primary OAuth account accounts.append( LinkedAccountResponse( provider=user.oauth_provider, email=user.email, linked_at=user.created_at.isoformat(), ) ) # Add additional linked accounts for linked in user.linked_accounts: accounts.append( LinkedAccountResponse( provider=linked.provider, email=linked.email, linked_at=linked.linked_at.isoformat(), ) ) return accounts @router.get("/me/sessions", response_model=SessionsResponse) async def get_active_sessions( user: CurrentUser, ) -> SessionsResponse: """Get the number of active sessions for the current user. Each session corresponds to a valid refresh token. Returns: Number of active sessions. """ from uuid import UUID user_id = UUID(user.id) if isinstance(user.id, str) else user.id count = await token_store.get_active_session_count(user_id) return SessionsResponse(active_sessions=count) @router.delete("/me/link/{provider}", status_code=status.HTTP_204_NO_CONTENT) async def unlink_oauth_account( user: CurrentUser, user_service: UserServiceDep, provider: str, ) -> None: """Unlink an OAuth provider from the current user's account. Cannot unlink the primary OAuth provider (the one used to create the account). Args: provider: OAuth provider name to unlink ('google' or 'discord'). Raises: HTTPException: 400 if trying to unlink primary provider. HTTPException: 404 if provider is not linked. """ provider = provider.lower() if provider not in ("google", "discord"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown provider: {provider}", ) try: unlinked = await user_service.unlink_oauth_account(user.id, user.oauth_provider, provider) if not unlinked: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"{provider.title()} is not linked to your account", ) except AccountLinkingError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None # ============================================================================= # Starter Deck Endpoints # ============================================================================= @router.get("/me/starter-status", response_model=StarterStatusResponse) async def get_starter_status( user: CurrentUser, deck_service: DeckServiceDep, ) -> StarterStatusResponse: """Check if user has already selected a starter deck. New users need to select a starter deck before playing. This endpoint checks if they've already made that selection. Returns: StarterStatusResponse with has_starter flag and starter_type. """ has_starter, starter_type = await deck_service.has_starter_deck(user.id) return StarterStatusResponse( has_starter=has_starter, starter_type=starter_type, ) @router.post("/me/starter-deck", response_model=DeckResponse, status_code=status.HTTP_201_CREATED) async def select_starter_deck( request: StarterDeckSelectRequest, user: CurrentUser, deck_service: DeckServiceDep, ) -> DeckResponse: """Select and create a starter deck for a new user. This is a one-time operation that atomically: 1. Validates the starter type 2. Creates the starter deck (protected by unique constraint) 3. Grants all starter deck cards to the user's collection Race conditions are handled by a database unique constraint that prevents multiple starter decks per user. Available starter types: grass, fire, water, psychic, lightning Args: request: Starter deck selection with type and optional config. Returns: The created starter deck. Raises: 400: If starter already selected or invalid starter_type. """ try: deck = await deck_service.select_and_grant_starter_deck( user_id=user.id, starter_type=request.starter_type, deck_config=request.deck_config, max_decks=user.max_decks, ) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None except StarterAlreadySelectedError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None except DeckLimitExceededError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None return DeckResponse( id=deck.id, name=deck.name, description=deck.description, cards=deck.cards, energy_cards=deck.energy_cards, is_valid=deck.is_valid, validation_errors=deck.validation_errors, is_starter=deck.is_starter, starter_type=deck.starter_type, created_at=deck.created_at, updated_at=deck.updated_at, )