This commit is contained in:
2026-04-01 18:31:33 +02:00
parent 6e23e32bb0
commit b5c7c5305a
95 changed files with 9609 additions and 2374 deletions

411
backend/routers/trades.py Normal file
View 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]