Files
plm-lottery/app/api/routes/users.py
T
davideandClaude Sonnet 5 6a857f0e07 Show pending-inclusive balance and per-player round outcome reliably
Balance display: place_bet/request_withdrawal spend whole UTXOs and mark
them spent at broadcast time, well before confirmation, so the confirmed-only
balance could drop by far more than the amount actually moving. Add
compute_pending_balance() (app/wallet/balance.py) to fold the unconfirmed
change from in-flight bet/withdrawal PendingTransactions back in; GET
/users/me now returns pending_balance_sats + has_pending, and the frontend
shows it colored green (settled) or amber (still pending) instead of the
confirmed-only figure.

Round outcome display: the win/lose reveal and the "pagamento al vincitore
in corso" status were fighting over the same UI slot, and the reveal broke
across a page refresh. Now:
- The round-status box (generic phase progress) and the personal win/lose
  box are independent and can both be visible at once.
- The win/lose box only renders for users who actually played in that round
  (new user_played field on GET /rounds/current, via a new optional-auth
  dependency so the endpoint stays usable logged-out).
- The reveal delay is anchored to the round's server-provided closes_at
  instead of a client-side "first seen" timestamp, so repeated reloads can't
  reset it, and the revealed result is persisted in localStorage so it
  survives a refresh even after the round has fully closed.
- GET /users/me/last-round-result is a durable DB-backed backstop for
  players who miss the live window entirely (backgrounded tab, offline).

Also hardens the frontend polling loop: call() now times out instead of
hanging forever, and a session-epoch counter stops an in-flight request from
a previous login from resurrecting a duplicate poll loop after logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:09:12 +02:00

104 lines
3.6 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
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 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,
)