import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from pydantic import BaseModel from sqlalchemy.orm import Session from services import notification_manager from core.auth import decode_access_token from core.database import get_db from core.dependencies import get_current_user, get_user_id_from_request, limiter from core.models import Card as CardModel from core.models import Notification as NotificationModel from core.models import TradeProposal as TradeProposalModel from core.models import User as UserModel from routers.notifications import _serialize_notification from services.trade_manager import ( TradeQueueEntry, active_trades, handle_trade_action, handle_trade_disconnect, serialize_trade, trade_queue, trade_queue_lock, try_trade_match, ) from services.trade_manager import transfer_cards router = APIRouter() def _fetch_cards_for_ids(id_strings: list, db: Session) -> list: """Fetch CardModel rows for a JSONB list of UUID strings, preserving nothing if list is empty.""" if not id_strings: return [] uuids = [uuid.UUID(cid) for cid in id_strings] return db.query(CardModel).filter(CardModel.id.in_(uuids)).all() def _serialize_proposal(p: TradeProposalModel, current_user_id: uuid.UUID, card_map: dict) -> dict: offered_cards = [card_map[cid] for cid in p.offered_card_ids if cid in card_map] requested_cards = [card_map[cid] for cid in p.requested_card_ids if cid in card_map] def card_summary(c: CardModel) -> dict: return { "id": str(c.id), "name": c.name, "card_rarity": c.card_rarity, "card_type": c.card_type, "image_link": c.image_link, "cost": c.cost, "text": c.text, "attack": c.attack, "defense": c.defense, "generated_at": c.generated_at.isoformat() if c.generated_at else None, } return { "id": str(p.id), "status": p.status, "direction": "outgoing" if p.proposer_id == current_user_id else "incoming", "proposer_username": p.proposer.username, "recipient_username": p.recipient.username, "offered_cards": [card_summary(c) for c in offered_cards], "requested_cards": [card_summary(c) for c in requested_cards], "created_at": p.created_at.isoformat(), "expires_at": p.expires_at.isoformat(), } # ── WebSocket trade matchmaking ─────────────────────────────────────────────── @router.websocket("/ws/trade/queue") async def trade_queue_endpoint(websocket: WebSocket, db: Session = Depends(get_db)): await websocket.accept() token = await websocket.receive_text() user_id = decode_access_token(token) if not user_id: await websocket.close(code=1008) return user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first() if not user: await websocket.close(code=1008) return if not user.email_verified: await websocket.send_json({"type": "error", "message": "You must verify your email before trading."}) await websocket.close(code=1008) return entry = TradeQueueEntry(user_id=user_id, username=user.username, websocket=websocket) async with trade_queue_lock: trade_queue.append(entry) await websocket.send_json({"type": "queued"}) await try_trade_match() try: while True: await websocket.receive_text() except WebSocketDisconnect: async with trade_queue_lock: trade_queue[:] = [e for e in trade_queue if e.user_id != user_id] @router.websocket("/ws/trade/{trade_id}") async def trade_endpoint(websocket: WebSocket, trade_id: str, db: Session = Depends(get_db)): await websocket.accept() token = await websocket.receive_text() user_id = decode_access_token(token) if not user_id: await websocket.close(code=1008) return session = active_trades.get(trade_id) if not session or user_id not in session.offers: await websocket.close(code=1008) return session.connections[user_id] = websocket await websocket.send_json({ "type": "state", "state": serialize_trade(session, user_id), }) try: while True: data = await websocket.receive_json() await handle_trade_action(trade_id, user_id, data, db) except WebSocketDisconnect: session.connections.pop(user_id, None) import asyncio asyncio.create_task(handle_trade_disconnect(trade_id, user_id)) # ── Trade proposals ─────────────────────────────────────────────────────────── class CreateTradeProposalRequest(BaseModel): recipient_username: str offered_card_ids: list[str] requested_card_ids: list[str] @router.post("/trade-proposals") @limiter.limit("10/minute", key_func=get_user_id_from_request) async def create_trade_proposal( request: Request, req: CreateTradeProposalRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): # Parse UUIDs early so we give a clear error if malformed try: offered_uuids = [uuid.UUID(cid) for cid in req.offered_card_ids] requested_uuids = [uuid.UUID(cid) for cid in req.requested_card_ids] except ValueError: raise HTTPException(status_code=400, detail="Invalid card IDs") recipient = db.query(UserModel).filter(UserModel.username == req.recipient_username).first() if not recipient: raise HTTPException(status_code=404, detail="User not found") if recipient.id == user.id: raise HTTPException(status_code=400, detail="Cannot propose a trade with yourself") if not offered_uuids and not requested_uuids: raise HTTPException(status_code=400, detail="At least one side must include cards") # Verify proposer owns all offered cards if offered_uuids: owned_count = db.query(CardModel).filter( CardModel.id.in_(offered_uuids), CardModel.user_id == user.id, ).count() if owned_count != len(offered_uuids): raise HTTPException(status_code=400, detail="Some offered cards are not in your collection") # Verify all requested cards belong to recipient and are marked WTT if requested_uuids: wtt_count = db.query(CardModel).filter( CardModel.id.in_(requested_uuids), CardModel.user_id == recipient.id, CardModel.willing_to_trade == True, ).count() if wtt_count != len(requested_uuids): raise HTTPException(status_code=400, detail="Some requested cards are not available for trade") # One pending proposal per direction between two users prevents spam duplicate = db.query(TradeProposalModel).filter( TradeProposalModel.proposer_id == user.id, TradeProposalModel.recipient_id == recipient.id, TradeProposalModel.status == "pending", ).first() if duplicate: raise HTTPException(status_code=400, detail="You already have a pending proposal with this user") now = datetime.now() proposal = TradeProposalModel( proposer_id=user.id, recipient_id=recipient.id, offered_card_ids=[str(cid) for cid in offered_uuids], requested_card_ids=[str(cid) for cid in requested_uuids], expires_at=now + timedelta(hours=72), ) db.add(proposal) db.flush() # get proposal.id before notification notif = NotificationModel( user_id=recipient.id, type="trade_offer", payload={ "proposal_id": str(proposal.id), "from_username": user.username, "offered_count": len(offered_uuids), "requested_count": len(requested_uuids), }, expires_at=proposal.expires_at, ) db.add(notif) db.commit() await notification_manager.send_notification(str(recipient.id), _serialize_notification(notif)) return {"proposal_id": str(proposal.id)} @router.get("/trade-proposals/{proposal_id}") def get_trade_proposal( proposal_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): try: pid = uuid.UUID(proposal_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid proposal ID") proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == pid).first() if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") if proposal.proposer_id != user.id and proposal.recipient_id != user.id: raise HTTPException(status_code=403, detail="Not authorized") # Lazy-expire before returning so the UI always sees accurate status if proposal.status == "pending" and datetime.now() > proposal.expires_at: proposal.status = "expired" db.commit() all_ids = set(proposal.offered_card_ids + proposal.requested_card_ids) card_map = {str(c.id): c for c in _fetch_cards_for_ids(list(all_ids), db)} return _serialize_proposal(proposal, user.id, card_map) @router.post("/trade-proposals/{proposal_id}/accept") async def accept_trade_proposal( proposal_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).with_for_update().first() if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") if proposal.recipient_id != user.id: raise HTTPException(status_code=403, detail="Only the recipient can accept a proposal") if proposal.status != "pending": raise HTTPException(status_code=400, detail=f"Proposal is already {proposal.status}") now = datetime.now() if now > proposal.expires_at: proposal.status = "expired" db.commit() raise HTTPException(status_code=400, detail="This trade proposal has expired") offered_uuids = [uuid.UUID(cid) for cid in proposal.offered_card_ids] requested_uuids = [uuid.UUID(cid) for cid in proposal.requested_card_ids] # Re-verify proposer still owns all offered cards at accept time if offered_uuids: owned_count = db.query(CardModel).filter( CardModel.id.in_(offered_uuids), CardModel.user_id == proposal.proposer_id, ).count() if owned_count != len(offered_uuids): proposal.status = "expired" db.commit() raise HTTPException(status_code=400, detail="The proposer no longer owns all offered cards") # Re-verify all requested cards still belong to recipient and are still WTT if requested_uuids: wtt_count = db.query(CardModel).filter( CardModel.id.in_(requested_uuids), CardModel.user_id == user.id, CardModel.willing_to_trade == True, ).count() if wtt_count != len(requested_uuids): raise HTTPException(status_code=400, detail="Some requested cards are no longer available for trade") # Execute both sides of the transfer atomically transfer_cards(proposal.proposer_id, user.id, offered_uuids, db, now) transfer_cards(user.id, proposal.proposer_id, requested_uuids, db, now) proposal.status = "accepted" # Clean up the trade_offer notification from the recipient's bell deleted_notif = db.query(NotificationModel).filter( NotificationModel.user_id == proposal.recipient_id, NotificationModel.type == "trade_offer", NotificationModel.payload["proposal_id"].astext == proposal_id, ).first() deleted_notif_id = str(deleted_notif.id) if deleted_notif else None if deleted_notif: db.delete(deleted_notif) # Notify the proposer that their offer was accepted response_notif = NotificationModel( user_id=proposal.proposer_id, type="trade_response", payload={ "proposal_id": proposal_id, "status": "accepted", "from_username": user.username, }, ) db.add(response_notif) # Withdraw any other pending proposals that involve cards that just changed hands. # Both sides are now non-tradeable: offered cards left the proposer, requested cards left the recipient. transferred_strs = {str(c) for c in offered_uuids + requested_uuids} if transferred_strs: for p in db.query(TradeProposalModel).filter( TradeProposalModel.status == "pending", TradeProposalModel.id != proposal.id, ( (TradeProposalModel.proposer_id == proposal.proposer_id) | (TradeProposalModel.proposer_id == proposal.recipient_id) | (TradeProposalModel.recipient_id == proposal.proposer_id) | (TradeProposalModel.recipient_id == proposal.recipient_id) ), ).all(): if set(p.offered_card_ids) & transferred_strs or set(p.requested_card_ids) & transferred_strs: p.status = "withdrawn" db.commit() if deleted_notif_id: await notification_manager.send_delete(str(proposal.recipient_id), deleted_notif_id) await notification_manager.send_notification(str(proposal.proposer_id), _serialize_notification(response_notif)) return {"ok": True} @router.post("/trade-proposals/{proposal_id}/decline") async def decline_trade_proposal( proposal_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): proposal = db.query(TradeProposalModel).filter(TradeProposalModel.id == uuid.UUID(proposal_id)).first() if not proposal: raise HTTPException(status_code=404, detail="Proposal not found") if proposal.proposer_id != user.id and proposal.recipient_id != user.id: raise HTTPException(status_code=403, detail="Not authorized") if proposal.status != "pending": raise HTTPException(status_code=400, detail=f"Proposal is already {proposal.status}") is_withdrawal = proposal.proposer_id == user.id proposal.status = "withdrawn" if is_withdrawal else "declined" # Clean up the trade_offer notification from the recipient's bell deleted_notif = db.query(NotificationModel).filter( NotificationModel.user_id == proposal.recipient_id, NotificationModel.type == "trade_offer", NotificationModel.payload["proposal_id"].astext == proposal_id, ).first() deleted_notif_id = str(deleted_notif.id) if deleted_notif else None if deleted_notif: db.delete(deleted_notif) # Notify the proposer if the recipient declined (not a withdrawal) response_notif = None if not is_withdrawal: response_notif = NotificationModel( user_id=proposal.proposer_id, type="trade_response", payload={ "proposal_id": proposal_id, "status": "declined", "from_username": user.username, }, ) db.add(response_notif) db.commit() if deleted_notif_id: await notification_manager.send_delete(str(proposal.recipient_id), deleted_notif_id) if response_notif: await notification_manager.send_notification(str(proposal.proposer_id), _serialize_notification(response_notif)) return {"ok": True} @router.get("/trade-proposals") def get_trade_proposals(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): # Lazy-expire any pending proposals that have passed their deadline now = datetime.now() db.query(TradeProposalModel).filter( TradeProposalModel.status == "pending", TradeProposalModel.expires_at < now, (TradeProposalModel.proposer_id == user.id) | (TradeProposalModel.recipient_id == user.id), ).update({"status": "expired"}) db.commit() proposals = db.query(TradeProposalModel).filter( (TradeProposalModel.proposer_id == user.id) | (TradeProposalModel.recipient_id == user.id) ).order_by(TradeProposalModel.created_at.desc()).all() # Batch-fetch all cards referenced across all proposals in one query all_ids = {cid for p in proposals for cid in p.offered_card_ids + p.requested_card_ids} card_map = {str(c.id): c for c in _fetch_cards_for_ids(list(all_ids), db)} return [_serialize_proposal(p, user.id, card_map) for p in proposals]