🐐
This commit is contained in:
411
backend/routers/trades.py
Normal file
411
backend/routers/trades.py
Normal file
@@ -0,0 +1,411 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user