diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 0ee85b6..9dd2644 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,3 +1,5 @@ +import json + from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel from sqlalchemy import select @@ -5,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.audit.log import write_audit_log from app.config import settings -from app.db.models import User +from app.db.models import AuditLog, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.wallet.hd import derive_user_wif @@ -21,18 +23,40 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None: class RoundConfigResponse(BaseModel): fee_address: str bet_amount_sats: int + round_duration_seconds: int + round_cooldown_seconds: int + min_amount_sats: int + fee_rate_sat_vb: int + rbf_timeout_seconds: int class RoundConfigUpdate(BaseModel): fee_address: str | None = None bet_amount_sats: int | None = None + round_duration_seconds: int | None = None + round_cooldown_seconds: int | None = None + min_amount_sats: int | None = None + fee_rate_sat_vb: int | None = None + rbf_timeout_seconds: int | None = None + + +def _config_response(config) -> RoundConfigResponse: + return RoundConfigResponse( + fee_address=config.fee_address, + bet_amount_sats=config.bet_amount_sats, + round_duration_seconds=config.round_duration_seconds, + round_cooldown_seconds=config.round_cooldown_seconds, + min_amount_sats=config.min_amount_sats, + fee_rate_sat_vb=config.fee_rate_sat_vb, + rbf_timeout_seconds=config.rbf_timeout_seconds, + ) @router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: config = await get_round_config(session) await session.commit() - return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) + return _config_response(config) @router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) @@ -40,12 +64,20 @@ async def update_config( body: RoundConfigUpdate, session: AsyncSession = Depends(get_session) ) -> RoundConfigResponse: config = await get_round_config(session) - if body.fee_address is not None: - config.fee_address = body.fee_address - if body.bet_amount_sats is not None: - config.bet_amount_sats = body.bet_amount_sats + for field in ( + "fee_address", + "bet_amount_sats", + "round_duration_seconds", + "round_cooldown_seconds", + "min_amount_sats", + "fee_rate_sat_vb", + "rbf_timeout_seconds", + ): + value = getattr(body, field) + if value is not None: + setattr(config, field, value) await session.commit() - return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) + return _config_response(config) class AdminUserResponse(BaseModel): @@ -91,3 +123,118 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id) await session.commit() return AdminPrivkeyResponse(address=user.address, wif=wif) + + +class AdminRoundResponse(BaseModel): + id: int + status: str + opened_at: str + closed_at: str | None + draw_block_height: int | None + draw_block_hash: str | None + winner_user_id: int | None + winner_username: str | None + pool_amount_sats: int | None + winner_amount_sats: int | None + fee_amount_sats: int | None + payout_txid: str | None + + +@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) +async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]: + rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all() + winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None} + winners = {} + if winner_ids: + users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all() + winners = {u.id: u.username for u in users} + + return [ + 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, + draw_block_height=r.draw_block_height, + draw_block_hash=r.draw_block_hash, + winner_user_id=r.winner_user_id, + winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None, + pool_amount_sats=r.pool_amount_sats, + winner_amount_sats=r.winner_amount_sats, + fee_amount_sats=r.fee_amount_sats, + payout_txid=r.payout_txid, + ) + for r in rounds + ] + + +class AdminAuditLogResponse(BaseModel): + id: int + event_type: str + payload: dict + user_id: int | None + round_id: int | None + created_at: str + + +@router.get( + "/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)] +) +async def list_audit_log( + session: AsyncSession = Depends(get_session), limit: int = 200 +) -> list[AdminAuditLogResponse]: + entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all() + return [ + AdminAuditLogResponse( + id=e.id, + event_type=e.event_type, + payload=json.loads(e.payload_json), + user_id=e.user_id, + round_id=e.round_id, + created_at=e.created_at.isoformat(), + ) + for e in entries + ] + + +class AdminPendingTransactionResponse(BaseModel): + id: int + kind: str + status: str + round_id: int | None + withdrawal_id: int | None + user_id: int | None + current_txid: str + fee_rate_sat_vb: int + attempt_count: int + broadcast_at: str + replaced_by_txid: str | None + + +@router.get( + "/pending-transactions", + response_model=list[AdminPendingTransactionResponse], + dependencies=[Depends(require_admin)], +) +async def list_pending_transactions( + session: AsyncSession = Depends(get_session), +) -> list[AdminPendingTransactionResponse]: + entries = ( + await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc())) + ).all() + return [ + AdminPendingTransactionResponse( + id=p.id, + kind=p.kind, + status=p.status, + round_id=p.round_id, + withdrawal_id=p.withdrawal_id, + user_id=p.user_id, + 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(), + replaced_by_txid=p.replaced_by_txid, + ) + for p in entries + ]