diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 9e8f533..32c568c 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,6 +1,6 @@ """Exchange routes for Bitcoin trading.""" -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -22,7 +22,12 @@ from models import ( User, ) from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price -from schemas import ExchangeRequest, ExchangeResponse +from schemas import ( + AdminExchangeResponse, + ExchangeRequest, + ExchangeResponse, + ExchangeUserContact, +) from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, @@ -468,5 +473,260 @@ async def cancel_my_trade( return _to_exchange_response(exchange, current_user.email) +# ============================================================================= +# Admin Exchanges Endpoints +# ============================================================================= + +admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"]) + + +def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse: + """Convert an Exchange model to AdminExchangeResponse with user contact.""" + user = exchange.user + return AdminExchangeResponse( + id=exchange.id, + user_id=exchange.user_id, + user_email=user.email, + user_contact=ExchangeUserContact( + email=user.email, + contact_email=user.contact_email, + telegram=user.telegram, + signal=user.signal, + nostr_npub=user.nostr_npub, + ), + slot_start=exchange.slot_start, + slot_end=exchange.slot_end, + direction=exchange.direction.value, + eur_amount=exchange.eur_amount, + sats_amount=exchange.sats_amount, + market_price_eur=exchange.market_price_eur, + agreed_price_eur=exchange.agreed_price_eur, + premium_percentage=exchange.premium_percentage, + status=exchange.status.value, + created_at=exchange.created_at, + cancelled_at=exchange.cancelled_at, + completed_at=exchange.completed_at, + ) + + +@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse]) +async def get_upcoming_trades( + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), +) -> list[AdminExchangeResponse]: + """Get all upcoming booked trades, sorted by slot time ascending.""" + now = datetime.now(UTC) + result = await db.execute( + select(Exchange) + .options(joinedload(Exchange.user)) + .where( + and_( + Exchange.slot_start > now, + Exchange.status == ExchangeStatus.BOOKED, + ) + ) + .order_by(Exchange.slot_start.asc()) + ) + exchanges = result.scalars().all() + + return [_to_admin_exchange_response(ex) for ex in exchanges] + + +@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse]) +async def get_past_trades( + status: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + user_search: str | None = None, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), +) -> list[AdminExchangeResponse]: + """ + Get past trades with optional filters. + + Filters: + - status: Filter by exchange status + - start_date, end_date: Filter by slot_start date range + - user_search: Search by user email (partial match) + """ + + now = datetime.now(UTC) + + # Start with base query for past trades (slot_start <= now OR not booked) + query = ( + select(Exchange) + .options(joinedload(Exchange.user)) + .where( + (Exchange.slot_start <= now) | (Exchange.status != ExchangeStatus.BOOKED) + ) + ) + + # Apply status filter + if status: + try: + status_enum = ExchangeStatus(status) + query = query.where(Exchange.status == status_enum) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid status: {status}", + ) from None + + # Apply date range filter + if start_date: + start_dt = datetime.combine(start_date, time.min, tzinfo=UTC) + query = query.where(Exchange.slot_start >= start_dt) + if end_date: + end_dt = datetime.combine(end_date, time.max, tzinfo=UTC) + query = query.where(Exchange.slot_start <= end_dt) + + # Apply user search filter (join with User table) + if user_search: + query = query.join(Exchange.user).where(User.email.ilike(f"%{user_search}%")) + + # Order by most recent first + query = query.order_by(Exchange.slot_start.desc()) + + result = await db.execute(query) + exchanges = result.scalars().all() + + return [_to_admin_exchange_response(ex) for ex in exchanges] + + +@admin_trades_router.post( + "/{exchange_id}/complete", response_model=AdminExchangeResponse +) +async def complete_trade( + exchange_id: int, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends( + require_permission(Permission.CANCEL_ANY_APPOINTMENT) + ), +) -> AdminExchangeResponse: + """Mark a trade as completed. Only possible after slot time has passed.""" + + result = await db.execute( + select(Exchange) + .options(joinedload(Exchange.user)) + .where(Exchange.id == exchange_id) + ) + exchange = result.scalar_one_or_none() + + if not exchange: + raise HTTPException( + status_code=404, + detail=f"Trade {exchange_id} not found", + ) + + # Check slot has passed + if exchange.slot_start > datetime.now(UTC): + raise HTTPException( + status_code=400, + detail="Cannot complete: trade slot has not yet started", + ) + + # Check status is BOOKED + if exchange.status != ExchangeStatus.BOOKED: + raise HTTPException( + status_code=400, + detail=f"Cannot complete: status is '{exchange.status.value}'", + ) + + exchange.status = ExchangeStatus.COMPLETED + exchange.completed_at = datetime.now(UTC) + + await db.commit() + await db.refresh(exchange) + + return _to_admin_exchange_response(exchange) + + +@admin_trades_router.post( + "/{exchange_id}/no-show", response_model=AdminExchangeResponse +) +async def mark_no_show( + exchange_id: int, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends( + require_permission(Permission.CANCEL_ANY_APPOINTMENT) + ), +) -> AdminExchangeResponse: + """Mark a trade as no-show. Only possible after slot time has passed.""" + + result = await db.execute( + select(Exchange) + .options(joinedload(Exchange.user)) + .where(Exchange.id == exchange_id) + ) + exchange = result.scalar_one_or_none() + + if not exchange: + raise HTTPException( + status_code=404, + detail=f"Trade {exchange_id} not found", + ) + + # Check slot has passed + if exchange.slot_start > datetime.now(UTC): + raise HTTPException( + status_code=400, + detail="Cannot mark as no-show: trade slot has not yet started", + ) + + # Check status is BOOKED + if exchange.status != ExchangeStatus.BOOKED: + raise HTTPException( + status_code=400, + detail=f"Cannot mark as no-show: status is '{exchange.status.value}'", + ) + + exchange.status = ExchangeStatus.NO_SHOW + exchange.completed_at = datetime.now(UTC) + + await db.commit() + await db.refresh(exchange) + + return _to_admin_exchange_response(exchange) + + +@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse) +async def admin_cancel_trade( + exchange_id: int, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends( + require_permission(Permission.CANCEL_ANY_APPOINTMENT) + ), +) -> AdminExchangeResponse: + """Cancel any trade (admin only).""" + + result = await db.execute( + select(Exchange) + .options(joinedload(Exchange.user)) + .where(Exchange.id == exchange_id) + ) + exchange = result.scalar_one_or_none() + + if not exchange: + raise HTTPException( + status_code=404, + detail=f"Trade {exchange_id} not found", + ) + + # Check status is BOOKED + if exchange.status != ExchangeStatus.BOOKED: + raise HTTPException( + status_code=400, + detail=f"Cannot cancel: status is '{exchange.status.value}'", + ) + + exchange.status = ExchangeStatus.CANCELLED_BY_ADMIN + exchange.cancelled_at = datetime.now(UTC) + + await db.commit() + await db.refresh(exchange) + + return _to_admin_exchange_response(exchange) + + # All routers from this module for easy registration -routers = [router, trades_router] +routers = [router, trades_router, admin_trades_router]