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