Files
plm-lottery/app/api/routes/users.py
T

123 lines
4.5 KiB
Python
Raw Normal View History

from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
2026-07-23 08:53:23 +02:00
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
2026-07-23 08:53:23 +02:00
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))
2026-07-23 08:53:23 +02:00
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,
)