from fastapi import APIRouter, Depends, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import http_error from app.auth.dependencies import get_current_user from app.auth.security import hash_password, verify_password from app.db.models import Round, RoundParticipant, User from app.db.session import get_session from app.wallet.balance import compute_pending_balance router = APIRouter(prefix="/users", tags=["users"]) _MIN_PASSWORD_LENGTH = 8 class MeResponse(BaseModel): id: int username: str address: str balance_sats: int pending_balance_sats: int has_pending: bool created_at: str @router.get("/me", response_model=MeResponse) async def me( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> MeResponse: pending_balance_sats, has_pending = await compute_pending_balance(session, user) return MeResponse( id=user.id, username=user.username, address=user.address, balance_sats=user.cached_balance_sats, pending_balance_sats=pending_balance_sats, has_pending=has_pending, created_at=user.created_at.isoformat(), ) class ChangePasswordRequest(BaseModel): current_password: str new_password: str @router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT) async def change_password( body: ChangePasswordRequest, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> None: """Self-service password change — requires the current password, unlike the admin-only /admin/users/{id}/reset-password (which is for a user who's actually locked out and can't provide it).""" if not verify_password(body.current_password, user.password_hash): raise http_error( status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect" ) if len(body.new_password) < _MIN_PASSWORD_LENGTH: raise http_error( status.HTTP_400_BAD_REQUEST, "password_too_short", f"new password must be at least {_MIN_PASSWORD_LENGTH} characters", minimum=_MIN_PASSWORD_LENGTH, ) user.password_hash = hash_password(body.new_password) await session.commit() class LastRoundResultResponse(BaseModel): round_id: int | None = None won: bool = False amount_sats: int | None = None @router.get("/me/last-round-result", response_model=LastRoundResultResponse) async def last_round_result( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> LastRoundResultResponse: """The most recent *closed* round this user participated in, with its outcome. Deliberately independent of /rounds/current: that endpoint only exposes winner_user_id while the round is "paying_out", and drops it entirely once the round flips to "closed" (see rounds/service.get_active_round). A client that misses that narrow window (backgrounded tab, missed poll, page loaded late) would otherwise never learn the outcome of a round it bet in. This endpoint reads the durable DB record instead, so the frontend can always catch up regardless of polling timing.""" row = await session.execute( select(Round) .join(RoundParticipant, RoundParticipant.round_id == Round.id) .where(RoundParticipant.user_id == user.id, Round.status == "closed") .order_by(Round.id.desc()) .limit(1) ) round_ = row.scalar_one_or_none() if round_ is None: return LastRoundResultResponse() won = round_.winner_user_id == user.id return LastRoundResultResponse( round_id=round_.id, won=won, amount_sats=round_.winner_amount_sats if won else None, )