Files
plm-lottery/app/api/routes/users.py
T
davideandClaude Opus 5 0cf35147ad Answer user-facing API failures with a machine-readable error code
The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.

Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.

Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.

admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:44:39 +02:00

112 lines
3.8 KiB
Python

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.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 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)
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,
)