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.api.timeutil import isoformat_utc from app.auth.dependencies import get_current_user from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, 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"]) 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=isoformat_utc(user.created_at), ) class ChangePasswordRequest(BaseModel): current_password: str new_password: str class ChangePasswordResponse(BaseModel): access_token: str @router.post("/me/change-password", response_model=ChangePasswordResponse) async def change_password( body: ChangePasswordRequest, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> ChangePasswordResponse: """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) # B-34: bumping token_version invalidates every token issued before this # point — including this very request's own bearer token, and any an # attacker who knew the old password might be holding. A fresh token is # handed back so *this* session keeps working without forcing a re-login; # every other open session (this user's other devices, or an attacker's) # gets "session_expired" on its next request. user.token_version += 1 await session.commit() return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version)) 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, )