Stamp UTC on naive API timestamps before serializing (B-35)

SQLite/aiosqlite returns DateTime columns as naive even though every
value is written in UTC, so a bare .isoformat() dropped the offset and
the frontend's new Date() parsed it as local time. Add a shared
isoformat_utc() helper and use it at every call site that was missing
the fix already applied ad hoc in rounds.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 12:20:22 +02:00
co-authored by Claude Sonnet 5
parent 739fc9fed2
commit bb8b71278a
7 changed files with 70 additions and 30 deletions
+6 -5
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
@@ -163,7 +164,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin
username=u.username,
address=u.address,
balance_sats=u.cached_balance_sats,
created_at=u.created_at.isoformat(),
created_at=isoformat_utc(u.created_at),
)
for u in users
]
@@ -253,8 +254,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int =
AdminRoundResponse(
id=r.id,
status=r.status,
opened_at=r.opened_at.isoformat(),
closed_at=r.closed_at.isoformat() if r.closed_at else None,
opened_at=isoformat_utc(r.opened_at),
closed_at=isoformat_utc(r.closed_at),
draw_block_height=r.draw_block_height,
draw_block_hash=r.draw_block_hash,
winner_user_id=r.winner_user_id,
@@ -291,7 +292,7 @@ async def list_audit_log(
payload=json.loads(e.payload_json),
user_id=e.user_id,
round_id=e.round_id,
created_at=e.created_at.isoformat(),
created_at=isoformat_utc(e.created_at),
)
for e in entries
]
@@ -333,7 +334,7 @@ async def list_pending_transactions(
current_txid=p.current_txid,
fee_rate_sat_vb=p.fee_rate_sat_vb,
attempt_count=p.attempt_count,
broadcast_at=p.broadcast_at.isoformat(),
broadcast_at=isoformat_utc(p.broadcast_at),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
+2 -1
View File
@@ -4,6 +4,7 @@ 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
from app.db.models import Round, RoundParticipant, User
@@ -36,7 +37,7 @@ async def me(
balance_sats=user.cached_balance_sats,
pending_balance_sats=pending_balance_sats,
has_pending=has_pending,
created_at=user.created_at.isoformat(),
created_at=isoformat_utc(user.created_at),
)
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime, timezone
def isoformat_utc(dt: datetime | None) -> str | None:
"""Serialize a datetime for API responses, stamping it UTC first.
Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite
round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset
and JavaScript's `new Date()` on the frontend parses the result as local time
instead of UTC (B-35). All stored values are UTC in practice, so a naive value
can be safely stamped rather than converted.
"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()