Files
wiki-tcg/backend/routers/trades.py
2026-04-01 18:31:33 +02:00

412 lines
15 KiB
Python

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]