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>
112 lines
3.8 KiB
Python
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,
|
|
)
|