Files
plm-lottery/app/api/routes/users.py
T
davide f822911128 Fix frontend/server round-state desync bugs
- Add a request timeout (AbortController) to the frontend's call() helper, so a
  hung server request no longer freezes the entire polling chain silently.
- Add GET /users/me/last-round-result: a durable, DB-backed fallback for the
  round outcome, since /rounds/current drops winner_user_id the instant a
  round flips from "paying_out" to "closed" — a backgrounded tab or a missed
  poll could otherwise mean a player never learns whether they won.
- Refresh the balance display when a win is revealed (live or via the new
  backstop), instead of leaving the pre-payout balance on screen.
- Guard refreshRound() with a session-epoch counter so an in-flight request
  from a previous login can't re-arm the poll loop after logout, which
  previously produced a duplicate "zombie" polling chain.
2026-07-23 08:53:23 +02:00

95 lines
3.3 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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
router = APIRouter(prefix="/users", tags=["users"])
_MIN_PASSWORD_LENGTH = 8
class MeResponse(BaseModel):
id: int
username: str
address: str
balance_sats: int
created_at: str
@router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)) -> MeResponse:
return MeResponse(
id=user.id,
username=user.username,
address=user.address,
balance_sats=user.cached_balance_sats,
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 HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect")
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters")
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,
)