🐐
This commit is contained in:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
224
backend/routers/auth.py
Normal file
224
backend/routers/auth.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.auth import (
|
||||
create_access_token, create_refresh_token,
|
||||
decode_refresh_token, hash_password, verify_password,
|
||||
)
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, limiter
|
||||
from services.email_utils import send_password_reset_email, send_verification_email
|
||||
from core.models import User as UserModel
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
try:
|
||||
from disposable_email_domains import blocklist as _disposable_blocklist
|
||||
except ImportError:
|
||||
_disposable_blocklist: set[str] = set()
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
class ResetPasswordWithTokenRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
def validate_register(username: str, email: str, password: str) -> str | None:
|
||||
if not username.strip():
|
||||
return "Username is required"
|
||||
if len(username) < 2:
|
||||
return "Username must be at least 2 characters"
|
||||
if len(username) > 16:
|
||||
return "Username must be 16 characters or fewer"
|
||||
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", email):
|
||||
return "Please enter a valid email"
|
||||
domain = email.split("@")[-1].lower()
|
||||
if domain in _disposable_blocklist:
|
||||
return "Disposable email addresses are not allowed"
|
||||
if len(password) < 8:
|
||||
return "Password must be at least 8 characters"
|
||||
if len(password) > 256:
|
||||
return "Password must be 256 characters or fewer"
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
@limiter.limit("5/minute")
|
||||
def register(request: Request, req: RegisterRequest, db: Session = Depends(get_db)):
|
||||
err = validate_register(req.username, req.email, req.password)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
if db.query(UserModel).filter(UserModel.username.ilike(req.username)).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
if db.query(UserModel).filter(UserModel.email == req.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
user = UserModel(
|
||||
id=uuid.uuid4(),
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
password_hash=hash_password(req.password),
|
||||
email_verified=False,
|
||||
email_verification_token=verification_token,
|
||||
email_verification_token_expires_at=datetime.now() + timedelta(hours=24),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
try:
|
||||
send_verification_email(req.email, req.username, verification_token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send verification email: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Account created but we couldn't send the verification email. Please use 'Resend verification' to try again."
|
||||
)
|
||||
return {"message": "Account created. Please check your email to verify your account."}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("10/minute")
|
||||
def login(request: Request, form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.username.ilike(form.username)).first()
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Invalid username or password")
|
||||
user.last_active_at = datetime.now()
|
||||
db.commit()
|
||||
return {
|
||||
"access_token": create_access_token(str(user.id)),
|
||||
"refresh_token": create_refresh_token(str(user.id)),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/reset-password")
|
||||
@limiter.limit("5/minute")
|
||||
def reset_password(request: Request, req: ResetPasswordRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not verify_password(req.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(req.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if len(req.new_password) > 256:
|
||||
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
|
||||
if req.current_password == req.new_password:
|
||||
raise HTTPException(status_code=400, detail="New password must be different from current password")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
db.commit()
|
||||
return {"message": "Password updated"}
|
||||
|
||||
|
||||
@router.post("/auth/forgot-password")
|
||||
@limiter.limit("5/minute")
|
||||
def forgot_password(request: Request, req: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.email == req.email).first()
|
||||
# Always return success even if email not found. Prevents user enumeration
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.reset_token = token
|
||||
user.reset_token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
db.commit()
|
||||
try:
|
||||
send_password_reset_email(user.email, user.username, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reset email: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to send the password reset email. Please try again later."
|
||||
)
|
||||
return {"message": "If that email is registered you will receive a reset link shortly"}
|
||||
|
||||
|
||||
@router.post("/auth/reset-password-with-token")
|
||||
@limiter.limit("5/minute")
|
||||
def reset_password_with_token(request: Request, req: ResetPasswordWithTokenRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.reset_token == req.token).first()
|
||||
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset link")
|
||||
if len(req.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if len(req.new_password) > 256:
|
||||
raise HTTPException(status_code=400, detail="Password must be 256 characters or fewer")
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_expires_at = None
|
||||
db.commit()
|
||||
return {"message": "Password updated"}
|
||||
|
||||
|
||||
@router.get("/auth/verify-email")
|
||||
@limiter.limit("10/minute")
|
||||
def verify_email(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.email_verification_token == token).first()
|
||||
if not user or not user.email_verification_token_expires_at or user.email_verification_token_expires_at < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired verification link")
|
||||
user.email_verified = True
|
||||
user.email_verification_token = None
|
||||
user.email_verification_token_expires_at = None
|
||||
db.commit()
|
||||
return {"message": "Email verified"}
|
||||
|
||||
|
||||
@router.post("/auth/resend-verification")
|
||||
@limiter.limit("5/minute")
|
||||
def resend_verification(request: Request, req: ResendVerificationRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.email == req.email).first()
|
||||
# Always return success to prevent user enumeration
|
||||
if user and not user.email_verified:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.email_verification_token = token
|
||||
user.email_verification_token_expires_at = datetime.now() + timedelta(hours=24)
|
||||
db.commit()
|
||||
try:
|
||||
send_verification_email(user.email, user.username, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resend verification email: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to send the verification email. Please try again later."
|
||||
)
|
||||
return {"message": "If that email is registered and unverified, you will receive a new verification link shortly"}
|
||||
|
||||
|
||||
@router.post("/auth/refresh")
|
||||
@limiter.limit("20/minute")
|
||||
def refresh(request: Request, req: RefreshRequest, db: Session = Depends(get_db)):
|
||||
user_id = decode_refresh_token(req.refresh_token)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
user.last_active_at = datetime.now()
|
||||
db.commit()
|
||||
return {
|
||||
"access_token": create_access_token(str(user.id)),
|
||||
"refresh_token": create_refresh_token(str(user.id)),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
229
backend/routers/cards.py
Normal file
229
backend/routers/cards.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import asc, case, desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from game.card import _get_specific_card_async
|
||||
from core.database import get_db
|
||||
from services.database_functions import check_boosters, fill_card_pool, BOOSTER_MAX
|
||||
from core.dependencies import get_current_user, limiter
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import DeckCard as DeckCardModel
|
||||
from core.models import User as UserModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/boosters")
|
||||
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
count, countdown = check_boosters(user, db)
|
||||
return {"count": count, "countdown": countdown, "email_verified": user.email_verified}
|
||||
|
||||
|
||||
@router.get("/cards")
|
||||
def get_cards(
|
||||
skip: int = 0,
|
||||
limit: int = 40,
|
||||
search: str = "",
|
||||
rarities: list[str] = Query(default=[]),
|
||||
types: list[str] = Query(default=[]),
|
||||
cost_min: int = 1,
|
||||
cost_max: int = 10,
|
||||
favorites_only: bool = False,
|
||||
wtt_only: bool = False,
|
||||
sort_by: str = "name",
|
||||
sort_dir: str = "asc",
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(CardModel).filter(CardModel.user_id == user.id)
|
||||
|
||||
if search:
|
||||
q = q.filter(CardModel.name.ilike(f"%{search}%"))
|
||||
if rarities:
|
||||
q = q.filter(CardModel.card_rarity.in_(rarities))
|
||||
if types:
|
||||
q = q.filter(CardModel.card_type.in_(types))
|
||||
q = q.filter(CardModel.cost >= cost_min, CardModel.cost <= cost_max)
|
||||
if favorites_only:
|
||||
q = q.filter(CardModel.is_favorite == True)
|
||||
if wtt_only:
|
||||
q = q.filter(CardModel.willing_to_trade == True)
|
||||
|
||||
total = q.count()
|
||||
|
||||
# case() for rarity ordering matches frontend RARITY_ORDER constant
|
||||
rarity_order_expr = case(
|
||||
(CardModel.card_rarity == 'common', 0),
|
||||
(CardModel.card_rarity == 'uncommon', 1),
|
||||
(CardModel.card_rarity == 'rare', 2),
|
||||
(CardModel.card_rarity == 'super_rare', 3),
|
||||
(CardModel.card_rarity == 'epic', 4),
|
||||
(CardModel.card_rarity == 'legendary', 5),
|
||||
else_=0
|
||||
)
|
||||
# coalesce mirrors frontend: received_at ?? generated_at
|
||||
date_received_expr = func.coalesce(CardModel.received_at, CardModel.generated_at)
|
||||
|
||||
sort_map = {
|
||||
"name": CardModel.name,
|
||||
"cost": CardModel.cost,
|
||||
"attack": CardModel.attack,
|
||||
"defense": CardModel.defense,
|
||||
"rarity": rarity_order_expr,
|
||||
"date_generated": CardModel.generated_at,
|
||||
"date_received": date_received_expr,
|
||||
}
|
||||
sort_col = sort_map.get(sort_by, CardModel.name)
|
||||
order_fn = desc if sort_dir == "desc" else asc
|
||||
# Secondary sort by name keeps pages stable when primary values are tied
|
||||
q = q.order_by(order_fn(sort_col), asc(CardModel.name))
|
||||
|
||||
cards = q.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"cards": [
|
||||
{c.name: getattr(card, c.name) for c in card.__table__.columns}
|
||||
for card in cards
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cards/in-decks")
|
||||
def get_cards_in_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck_ids = [d.id for d in db.query(DeckModel).filter(DeckModel.user_id == user.id, DeckModel.deleted == False).all()]
|
||||
if not deck_ids:
|
||||
return []
|
||||
card_ids = db.query(DeckCardModel.card_id).filter(DeckCardModel.deck_id.in_(deck_ids)).distinct().all()
|
||||
return [str(row.card_id) for row in card_ids]
|
||||
|
||||
|
||||
@router.post("/open_pack")
|
||||
@limiter.limit("10/minute")
|
||||
async def open_pack(request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not user.email_verified:
|
||||
raise HTTPException(status_code=403, detail="You must verify your email before opening packs")
|
||||
|
||||
check_boosters(user, db)
|
||||
|
||||
if user.boosters == 0:
|
||||
raise HTTPException(status_code=400, detail="No booster packs available")
|
||||
|
||||
cards = (
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == None, CardModel.ai_used == False)
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(cards) < 5:
|
||||
asyncio.create_task(fill_card_pool())
|
||||
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
|
||||
|
||||
now = datetime.now()
|
||||
for card in cards:
|
||||
card.user_id = user.id
|
||||
card.received_at = now
|
||||
|
||||
was_full = user.boosters == BOOSTER_MAX
|
||||
user.boosters -= 1
|
||||
if was_full:
|
||||
user.boosters_countdown = datetime.now()
|
||||
|
||||
db.commit()
|
||||
|
||||
asyncio.create_task(fill_card_pool())
|
||||
|
||||
return [
|
||||
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type}
|
||||
for card in cards
|
||||
]
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/report")
|
||||
def report_card(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
card.reported = True
|
||||
db.commit()
|
||||
return {"message": "Card reported"}
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/refresh")
|
||||
@limiter.limit("5/hour")
|
||||
async def refresh_card(request: Request, card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
|
||||
if user.last_refresh_at and datetime.now() - user.last_refresh_at < timedelta(minutes=10):
|
||||
remaining = (user.last_refresh_at + timedelta(minutes=10)) - datetime.now()
|
||||
minutes = int(remaining.total_seconds() // 60)
|
||||
seconds = int(remaining.total_seconds() % 60)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"You can refresh again in {minutes}m {seconds}s"
|
||||
)
|
||||
|
||||
new_card = await _get_specific_card_async(card.name)
|
||||
if not new_card:
|
||||
raise HTTPException(status_code=502, detail="Failed to regenerate card from Wikipedia")
|
||||
|
||||
card.image_link = new_card.image_link
|
||||
card.card_rarity = new_card.card_rarity.name
|
||||
card.card_type = new_card.card_type.name
|
||||
card.text = new_card.text
|
||||
card.attack = new_card.attack
|
||||
card.defense = new_card.defense
|
||||
card.cost = new_card.cost
|
||||
card.reported = False
|
||||
card.generated_at = datetime.now()
|
||||
card.received_at = datetime.now()
|
||||
|
||||
user.last_refresh_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
**{c.name: getattr(card, c.name) for c in card.__table__.columns},
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/favorite")
|
||||
def toggle_favorite(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
card.is_favorite = not card.is_favorite
|
||||
db.commit()
|
||||
return {"is_favorite": card.is_favorite}
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/willing-to-trade")
|
||||
def toggle_willing_to_trade(card_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_id),
|
||||
CardModel.user_id == user.id
|
||||
).first()
|
||||
if not card:
|
||||
raise HTTPException(status_code=404, detail="Card not found")
|
||||
card.willing_to_trade = not card.willing_to_trade
|
||||
db.commit()
|
||||
return {"willing_to_trade": card.willing_to_trade}
|
||||
97
backend/routers/decks.py
Normal file
97
backend/routers/decks.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from game.card import compute_deck_type
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import DeckCard as DeckCardModel
|
||||
from core.models import User as UserModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DeckUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=64)
|
||||
card_ids: Optional[List[str]] = None
|
||||
|
||||
|
||||
@router.get("/decks")
|
||||
def get_decks(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
decks = db.query(DeckModel).options(
|
||||
selectinload(DeckModel.deck_cards).selectinload(DeckCardModel.card)
|
||||
).filter(
|
||||
DeckModel.user_id == user.id,
|
||||
DeckModel.deleted == False
|
||||
).order_by(DeckModel.created_at).all()
|
||||
result = []
|
||||
for deck in decks:
|
||||
cards = [dc.card for dc in deck.deck_cards]
|
||||
result.append({
|
||||
"id": str(deck.id),
|
||||
"name": deck.name,
|
||||
"card_count": len(cards),
|
||||
"total_cost": sum(card.cost for card in cards),
|
||||
"times_played": deck.times_played,
|
||||
"wins": deck.wins,
|
||||
"losses": deck.losses,
|
||||
"deck_type": compute_deck_type(cards),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/decks")
|
||||
def create_deck(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
count = db.query(DeckModel).filter(DeckModel.user_id == user.id).count()
|
||||
deck = DeckModel(id=uuid.uuid4(), user_id=user.id, name=f"Deck #{count + 1}")
|
||||
db.add(deck)
|
||||
db.commit()
|
||||
return {"id": str(deck.id), "name": deck.name, "card_count": 0}
|
||||
|
||||
|
||||
@router.patch("/decks/{deck_id}")
|
||||
def update_deck(deck_id: str, body: DeckUpdate, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
if body.name is not None:
|
||||
deck.name = body.name
|
||||
if body.card_ids is not None:
|
||||
db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).delete()
|
||||
for card_id in body.card_ids:
|
||||
db.add(DeckCardModel(deck_id=deck.id, card_id=uuid.UUID(card_id)))
|
||||
if deck.times_played > 0:
|
||||
deck.wins = 0
|
||||
deck.losses = 0
|
||||
deck.times_played = 0
|
||||
db.commit()
|
||||
return {"id": str(deck.id), "name": deck.name}
|
||||
|
||||
|
||||
@router.delete("/decks/{deck_id}")
|
||||
def delete_deck(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
if deck.times_played > 0:
|
||||
deck.deleted = True
|
||||
else:
|
||||
db.delete(deck)
|
||||
db.commit()
|
||||
return {"message": "Deleted"}
|
||||
|
||||
|
||||
@router.get("/decks/{deck_id}/cards")
|
||||
def get_deck_cards(deck_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == user.id).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
deck_cards = db.query(DeckCardModel).options(
|
||||
selectinload(DeckCardModel.card)
|
||||
).filter(DeckCardModel.deck_id == deck.id).all()
|
||||
return [{"id": str(dc.card_id), "cost": dc.card.cost} for dc in deck_cards]
|
||||
134
backend/routers/friends.py
Normal file
134
backend/routers/friends.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from services import notification_manager
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, get_user_id_from_request, limiter
|
||||
from core.models import Friendship as FriendshipModel
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import User as UserModel
|
||||
from routers.notifications import _serialize_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/users/{username}/friend-request")
|
||||
@limiter.limit("10/minute", key_func=get_user_id_from_request)
|
||||
async def send_friend_request(request: Request, username: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
addressee = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not addressee:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if addressee.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot send friend request to yourself")
|
||||
|
||||
# Check for any existing friendship in either direction
|
||||
existing = db.query(FriendshipModel).filter(
|
||||
((FriendshipModel.requester_id == user.id) & (FriendshipModel.addressee_id == addressee.id)) |
|
||||
((FriendshipModel.requester_id == addressee.id) & (FriendshipModel.addressee_id == user.id)),
|
||||
).first()
|
||||
if existing and existing.status != "declined":
|
||||
raise HTTPException(status_code=400, detail="Friend request already exists or already friends")
|
||||
# Clear stale declined row so the unique constraint allows re-requesting
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
db.flush()
|
||||
|
||||
friendship = FriendshipModel(requester_id=user.id, addressee_id=addressee.id, status="pending")
|
||||
db.add(friendship)
|
||||
db.flush() # get friendship.id before notification
|
||||
|
||||
notif = NotificationModel(
|
||||
user_id=addressee.id,
|
||||
type="friend_request",
|
||||
payload={"friendship_id": str(friendship.id), "from_username": user.username},
|
||||
)
|
||||
db.add(notif)
|
||||
db.commit()
|
||||
|
||||
await notification_manager.send_notification(str(addressee.id), _serialize_notification(notif))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/friendships/{friendship_id}/accept")
|
||||
def accept_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.addressee_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
if friendship.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Friendship is not pending")
|
||||
friendship.status = "accepted"
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/friendships/{friendship_id}/decline")
|
||||
def decline_friend_request(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.addressee_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
if friendship.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Friendship is not pending")
|
||||
friendship.status = "declined"
|
||||
# Clean up the associated notification so it disappears from the bell
|
||||
db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user.id,
|
||||
NotificationModel.type == "friend_request",
|
||||
NotificationModel.payload["friendship_id"].astext == friendship_id,
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/friends")
|
||||
def get_friends(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendships = db.query(FriendshipModel).options(
|
||||
joinedload(FriendshipModel.requester),
|
||||
joinedload(FriendshipModel.addressee),
|
||||
).filter(
|
||||
(FriendshipModel.requester_id == user.id) | (FriendshipModel.addressee_id == user.id),
|
||||
FriendshipModel.status == "accepted",
|
||||
).all()
|
||||
result = []
|
||||
for f in friendships:
|
||||
other = f.addressee if f.requester_id == user.id else f.requester
|
||||
result.append({"id": str(other.id), "username": other.username, "friendship_id": str(f.id)})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/friendship-status/{username}")
|
||||
def get_friendship_status(username: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""Returns the friendship status between the current user and the given username."""
|
||||
other = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not other:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
friendship = db.query(FriendshipModel).filter(
|
||||
((FriendshipModel.requester_id == user.id) & (FriendshipModel.addressee_id == other.id)) |
|
||||
((FriendshipModel.requester_id == other.id) & (FriendshipModel.addressee_id == user.id)),
|
||||
FriendshipModel.status != "declined",
|
||||
).first()
|
||||
if not friendship:
|
||||
return {"status": "none"}
|
||||
if friendship.status == "accepted":
|
||||
return {"status": "friends", "friendship_id": str(friendship.id)}
|
||||
# pending: distinguish sent vs received
|
||||
if friendship.requester_id == user.id:
|
||||
return {"status": "pending_sent", "friendship_id": str(friendship.id)}
|
||||
return {"status": "pending_received", "friendship_id": str(friendship.id)}
|
||||
|
||||
|
||||
@router.delete("/friendships/{friendship_id}")
|
||||
def remove_friend(friendship_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
friendship = db.query(FriendshipModel).filter(FriendshipModel.id == uuid.UUID(friendship_id)).first()
|
||||
if not friendship:
|
||||
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||
if friendship.requester_id != user.id and friendship.addressee_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
db.delete(friendship)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
404
backend/routers/games.py
Normal file
404
backend/routers/games.py
Normal file
@@ -0,0 +1,404 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from services import notification_manager
|
||||
from core.auth import decode_access_token
|
||||
from core.database import get_db
|
||||
from services.database_functions import fill_card_pool
|
||||
from core.dependencies import get_current_user, get_user_id_from_request, limiter
|
||||
from game.manager import (
|
||||
QueueEntry, active_games, connections, create_challenge_game, create_solo_game,
|
||||
handle_action, handle_disconnect, handle_timeout_claim, load_deck_cards,
|
||||
queue, queue_lock, serialize_state, try_match,
|
||||
)
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import DeckCard as DeckCardModel
|
||||
from core.models import GameChallenge as GameChallengeModel
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import User as UserModel
|
||||
from routers.notifications import _serialize_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> dict:
|
||||
deck = c.challenger_deck
|
||||
return {
|
||||
"id": str(c.id),
|
||||
"status": c.status,
|
||||
"direction": "outgoing" if c.challenger_id == current_user_id else "incoming",
|
||||
"challenger_username": c.challenger.username,
|
||||
"challenged_username": c.challenged.username,
|
||||
"deck_name": deck.name if deck else "Unknown Deck",
|
||||
"deck_id": str(c.challenger_deck_id),
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"expires_at": c.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── WebSocket game matchmaking ────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/queue")
|
||||
async def queue_endpoint(websocket: WebSocket, deck_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
|
||||
|
||||
deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == uuid.UUID(deck_id),
|
||||
DeckModel.user_id == uuid.UUID(user_id)
|
||||
).first()
|
||||
|
||||
if not deck:
|
||||
await websocket.send_json({"type": "error", "message": "Deck not found"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
|
||||
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
|
||||
if total_cost == 0 or total_cost > 50:
|
||||
await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
entry = QueueEntry(user_id=user_id, deck_id=deck_id, websocket=websocket)
|
||||
|
||||
async with queue_lock:
|
||||
queue.append(entry)
|
||||
|
||||
await websocket.send_json({"type": "queued"})
|
||||
await try_match(db)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Keeping socket alive
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
async with queue_lock:
|
||||
queue[:] = [e for e in queue if e.user_id != user_id]
|
||||
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_endpoint(websocket: WebSocket, game_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
|
||||
|
||||
if game_id not in active_games:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
if user_id not in active_games[game_id].players:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
# Register this connection (handles reconnects)
|
||||
connections[game_id][user_id] = websocket
|
||||
|
||||
# Send current state immediately on connect
|
||||
await websocket.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_state(active_games[game_id], user_id),
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
await handle_action(game_id, user_id, data, db)
|
||||
except WebSocketDisconnect:
|
||||
if game_id in connections:
|
||||
connections[game_id].pop(user_id, None)
|
||||
asyncio.create_task(handle_disconnect(game_id, user_id))
|
||||
|
||||
|
||||
# ── Game challenges ───────────────────────────────────────────────────────────
|
||||
|
||||
class CreateGameChallengeRequest(BaseModel):
|
||||
deck_id: str
|
||||
|
||||
class AcceptGameChallengeRequest(BaseModel):
|
||||
deck_id: str
|
||||
|
||||
|
||||
@router.post("/users/{username}/challenge")
|
||||
@limiter.limit("10/minute", key_func=get_user_id_from_request)
|
||||
async def create_game_challenge(
|
||||
request: Request,
|
||||
username: str,
|
||||
req: CreateGameChallengeRequest,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
target = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if target.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot challenge yourself")
|
||||
|
||||
try:
|
||||
deck_id = uuid.UUID(req.deck_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid deck_id")
|
||||
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
|
||||
existing = db.query(GameChallengeModel).filter(
|
||||
GameChallengeModel.status == "pending",
|
||||
(
|
||||
((GameChallengeModel.challenger_id == user.id) & (GameChallengeModel.challenged_id == target.id)) |
|
||||
((GameChallengeModel.challenger_id == target.id) & (GameChallengeModel.challenged_id == user.id))
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A pending challenge already exists between you two")
|
||||
|
||||
now = datetime.now()
|
||||
challenge = GameChallengeModel(
|
||||
challenger_id=user.id,
|
||||
challenged_id=target.id,
|
||||
challenger_deck_id=deck_id,
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
)
|
||||
db.add(challenge)
|
||||
db.flush()
|
||||
|
||||
notif = NotificationModel(
|
||||
user_id=target.id,
|
||||
type="game_challenge",
|
||||
expires_at=challenge.expires_at,
|
||||
payload={
|
||||
"challenge_id": str(challenge.id),
|
||||
"from_username": user.username,
|
||||
"deck_name": deck.name,
|
||||
},
|
||||
)
|
||||
db.add(notif)
|
||||
db.commit()
|
||||
|
||||
await notification_manager.send_notification(str(target.id), _serialize_notification(notif))
|
||||
return {"challenge_id": str(challenge.id)}
|
||||
|
||||
|
||||
@router.post("/challenges/{challenge_id}/accept")
|
||||
async def accept_game_challenge(
|
||||
challenge_id: str,
|
||||
req: AcceptGameChallengeRequest,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
cid = uuid.UUID(challenge_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid challenge_id")
|
||||
|
||||
challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).with_for_update().first()
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||
if challenge.challenged_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
now = datetime.now()
|
||||
if challenge.status == "pending" and now > challenge.expires_at:
|
||||
challenge.status = "expired"
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="Challenge has expired")
|
||||
if challenge.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}")
|
||||
|
||||
try:
|
||||
deck_id = uuid.UUID(req.deck_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid deck_id")
|
||||
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
|
||||
# Verify challenger's deck still exists — it could have been deleted since the challenge was sent
|
||||
challenger_deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == challenge.challenger_deck_id,
|
||||
DeckModel.deleted == False,
|
||||
).first()
|
||||
if not challenger_deck:
|
||||
raise HTTPException(status_code=400, detail="The challenger's deck no longer exists")
|
||||
|
||||
try:
|
||||
game_id = create_challenge_game(
|
||||
str(challenge.challenger_id), str(challenge.challenger_deck_id),
|
||||
str(challenge.challenged_id), str(deck_id),
|
||||
db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
challenge.status = "accepted"
|
||||
|
||||
# Delete the original challenge notification from the challenged player's bell
|
||||
old_notif = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user.id,
|
||||
NotificationModel.type == "game_challenge",
|
||||
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
|
||||
).first()
|
||||
deleted_notif_id = str(old_notif.id) if old_notif else None
|
||||
if old_notif:
|
||||
db.delete(old_notif)
|
||||
|
||||
# Notify the challenger that their challenge was accepted
|
||||
response_notif = NotificationModel(
|
||||
user_id=challenge.challenger_id,
|
||||
type="game_challenge",
|
||||
payload={
|
||||
"challenge_id": str(challenge.id),
|
||||
"status": "accepted",
|
||||
"game_id": game_id,
|
||||
"from_username": user.username,
|
||||
},
|
||||
)
|
||||
db.add(response_notif)
|
||||
db.commit()
|
||||
|
||||
if deleted_notif_id:
|
||||
await notification_manager.send_delete(str(user.id), deleted_notif_id)
|
||||
await notification_manager.send_notification(str(challenge.challenger_id), _serialize_notification(response_notif))
|
||||
|
||||
return {"game_id": game_id}
|
||||
|
||||
|
||||
@router.post("/challenges/{challenge_id}/decline")
|
||||
async def decline_game_challenge(
|
||||
challenge_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
cid = uuid.UUID(challenge_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid challenge_id")
|
||||
|
||||
challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).first()
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||
if challenge.challenger_id != user.id and challenge.challenged_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
now = datetime.now()
|
||||
if challenge.status == "pending" and now > challenge.expires_at:
|
||||
challenge.status = "expired"
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="Challenge has already expired")
|
||||
if challenge.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}")
|
||||
|
||||
is_withdrawal = challenge.challenger_id == user.id
|
||||
challenge.status = "withdrawn" if is_withdrawal else "declined"
|
||||
|
||||
# Remove the notification from the other party's bell
|
||||
if is_withdrawal:
|
||||
# Challenger withdrawing: remove challenge notif from challenged player's bell
|
||||
notif = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == challenge.challenged_id,
|
||||
NotificationModel.type == "game_challenge",
|
||||
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
|
||||
).first()
|
||||
recipient_id = str(challenge.challenged_id)
|
||||
else:
|
||||
# Challenged player declining: remove challenge notif from their own bell
|
||||
notif = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user.id,
|
||||
NotificationModel.type == "game_challenge",
|
||||
NotificationModel.payload["challenge_id"].astext == str(challenge.id),
|
||||
).first()
|
||||
recipient_id = str(user.id)
|
||||
|
||||
deleted_notif_id = str(notif.id) if notif else None
|
||||
if notif:
|
||||
db.delete(notif)
|
||||
db.commit()
|
||||
|
||||
if deleted_notif_id:
|
||||
await notification_manager.send_delete(recipient_id, deleted_notif_id)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/challenges")
|
||||
def get_challenges(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
now = datetime.now()
|
||||
# Lazy-expire pending challenges past deadline
|
||||
db.query(GameChallengeModel).filter(
|
||||
GameChallengeModel.status == "pending",
|
||||
GameChallengeModel.expires_at < now,
|
||||
(GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id),
|
||||
).update({"status": "expired"})
|
||||
db.commit()
|
||||
|
||||
challenges = db.query(GameChallengeModel).options(
|
||||
joinedload(GameChallengeModel.challenger_deck)
|
||||
).filter(
|
||||
(GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id)
|
||||
).order_by(GameChallengeModel.created_at.desc()).all()
|
||||
|
||||
return [_serialize_challenge(c, user.id) for c in challenges]
|
||||
|
||||
|
||||
@router.post("/game/{game_id}/claim-timeout-win")
|
||||
async def claim_timeout_win(game_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
err = await handle_timeout_claim(game_id, str(user.id), db)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
return {"message": "Win claimed"}
|
||||
|
||||
|
||||
@router.post("/game/solo")
|
||||
async def start_solo_game(deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if difficulty < 1 or difficulty > 10:
|
||||
raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10")
|
||||
|
||||
deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == uuid.UUID(deck_id),
|
||||
DeckModel.user_id == user.id
|
||||
).first()
|
||||
if not deck:
|
||||
raise HTTPException(status_code=404, detail="Deck not found")
|
||||
|
||||
card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()]
|
||||
total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0
|
||||
if total_cost == 0 or total_cost > 50:
|
||||
raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50")
|
||||
|
||||
player_cards = load_deck_cards(deck_id, str(user.id), db)
|
||||
if player_cards is None:
|
||||
raise HTTPException(status_code=503, detail="Couldn't load deck")
|
||||
|
||||
ai_cards = db.query(CardModel).filter(
|
||||
CardModel.user_id == None,
|
||||
).order_by(func.random()).limit(500).all()
|
||||
|
||||
if len(ai_cards) == 0:
|
||||
raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck")
|
||||
|
||||
for card in ai_cards:
|
||||
card.ai_used = True
|
||||
db.commit()
|
||||
|
||||
game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty)
|
||||
asyncio.create_task(fill_card_pool())
|
||||
|
||||
return {"game_id": game_id}
|
||||
17
backend/routers/health.py
Normal file
17
backend/routers/health.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from core.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
# Validates that the DB is reachable, not just that the process is up
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/teapot")
|
||||
def teapot():
|
||||
return JSONResponse(status_code=418, content={"message": "I'm a teapot"})
|
||||
115
backend/routers/notifications.py
Normal file
115
backend/routers/notifications.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
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
|
||||
from core.models import Notification as NotificationModel
|
||||
from core.models import User as UserModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_notification(n: NotificationModel) -> dict:
|
||||
return {
|
||||
"id": str(n.id),
|
||||
"type": n.type,
|
||||
"payload": n.payload,
|
||||
"read": n.read,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"expires_at": n.expires_at.isoformat() if n.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/notifications")
|
||||
async def notifications_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
|
||||
|
||||
notification_manager.register(user_id, websocket)
|
||||
|
||||
# Flush all unread (non-expired) notifications on connect
|
||||
now = datetime.now()
|
||||
pending = (
|
||||
db.query(NotificationModel)
|
||||
.filter(
|
||||
NotificationModel.user_id == uuid.UUID(user_id),
|
||||
NotificationModel.read == False,
|
||||
(NotificationModel.expires_at == None) | (NotificationModel.expires_at > now),
|
||||
)
|
||||
.order_by(NotificationModel.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "flush",
|
||||
"notifications": [_serialize_notification(n) for n in pending],
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # keep connection alive; server only pushes
|
||||
except WebSocketDisconnect:
|
||||
notification_manager.unregister(user_id)
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
def get_notifications(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
now = datetime.now()
|
||||
notifications = (
|
||||
db.query(NotificationModel)
|
||||
.filter(
|
||||
NotificationModel.user_id == user.id,
|
||||
(NotificationModel.expires_at == None) | (NotificationModel.expires_at > now),
|
||||
)
|
||||
.order_by(NotificationModel.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_serialize_notification(n) for n in notifications]
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read")
|
||||
def mark_notification_read(
|
||||
notification_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
n = db.query(NotificationModel).filter(
|
||||
NotificationModel.id == uuid.UUID(notification_id),
|
||||
NotificationModel.user_id == user.id,
|
||||
).first()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
n.read = True
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/notifications/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: str,
|
||||
user: UserModel = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
n = db.query(NotificationModel).filter(
|
||||
NotificationModel.id == uuid.UUID(notification_id),
|
||||
NotificationModel.user_id == user.id,
|
||||
).first()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
db.delete(n)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
147
backend/routers/profile.py
Normal file
147
backend/routers/profile.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user
|
||||
from core.models import Card as CardModel
|
||||
from core.models import Deck as DeckModel
|
||||
from core.models import User as UserModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_card_public(card: CardModel) -> dict:
|
||||
"""Card fields safe to expose on public profiles (no user_id)."""
|
||||
return {
|
||||
"id": str(card.id),
|
||||
"name": card.name,
|
||||
"image_link": card.image_link,
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type,
|
||||
"text": card.text,
|
||||
"attack": card.attack,
|
||||
"defense": card.defense,
|
||||
"cost": card.cost,
|
||||
"is_favorite": card.is_favorite,
|
||||
"willing_to_trade": card.willing_to_trade,
|
||||
}
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
trade_wishlist: str
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
def get_profile(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
total_games = user.wins + user.losses
|
||||
|
||||
most_played_deck = (
|
||||
db.query(DeckModel)
|
||||
.filter(DeckModel.user_id == user.id, DeckModel.times_played > 0)
|
||||
.order_by(DeckModel.times_played.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
most_played_card = (
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == user.id, CardModel.times_played > 0)
|
||||
.order_by(CardModel.times_played.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
return {
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"created_at": user.created_at,
|
||||
"wins": user.wins,
|
||||
"losses": user.losses,
|
||||
"shards": user.shards,
|
||||
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
|
||||
"trade_wishlist": user.trade_wishlist or "",
|
||||
"most_played_deck": {
|
||||
"name": most_played_deck.name,
|
||||
"times_played": most_played_deck.times_played,
|
||||
} if most_played_deck else None,
|
||||
"most_played_card": {
|
||||
"name": most_played_card.name,
|
||||
"times_played": most_played_card.times_played,
|
||||
"card_type": most_played_card.card_type,
|
||||
"card_rarity": most_played_card.card_rarity,
|
||||
"image_link": most_played_card.image_link,
|
||||
} if most_played_card else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile/refresh-status")
|
||||
def refresh_status(user: UserModel = Depends(get_current_user)):
|
||||
if not user.last_refresh_at:
|
||||
return {"can_refresh": True, "next_refresh_at": None}
|
||||
next_refresh = user.last_refresh_at + timedelta(minutes=10)
|
||||
can_refresh = datetime.now() >= next_refresh
|
||||
return {
|
||||
"can_refresh": can_refresh,
|
||||
"next_refresh_at": next_refresh.isoformat() if not can_refresh else None,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/profile")
|
||||
def update_profile(req: UpdateProfileRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
user.trade_wishlist = req.trade_wishlist
|
||||
db.commit()
|
||||
return {"trade_wishlist": user.trade_wishlist}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def search_users(q: str, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Require auth to prevent scraping
|
||||
if len(q) < 2:
|
||||
return []
|
||||
results = (
|
||||
db.query(UserModel)
|
||||
.filter(UserModel.username.ilike(f"%{q}%"))
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"username": u.username,
|
||||
"wins": u.wins,
|
||||
"losses": u.losses,
|
||||
"win_rate": round(u.wins / (u.wins + u.losses) * 100) if (u.wins + u.losses) > 0 else 0,
|
||||
}
|
||||
for u in results
|
||||
]
|
||||
|
||||
|
||||
@router.get("/users/{username}")
|
||||
def get_public_profile(username: str, db: Session = Depends(get_db)):
|
||||
user = db.query(UserModel).filter(UserModel.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
total_games = user.wins + user.losses
|
||||
favorite_cards = (
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == user.id, CardModel.is_favorite == True)
|
||||
.order_by(CardModel.received_at.desc())
|
||||
.all()
|
||||
)
|
||||
wtt_cards = (
|
||||
db.query(CardModel)
|
||||
.filter(CardModel.user_id == user.id, CardModel.willing_to_trade == True)
|
||||
.order_by(CardModel.received_at.desc())
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"username": user.username,
|
||||
"wins": user.wins,
|
||||
"losses": user.losses,
|
||||
"win_rate": round((user.wins / total_games) * 100) if total_games > 0 else None,
|
||||
"trade_wishlist": user.trade_wishlist or "",
|
||||
"last_active_at": user.last_active_at.isoformat() if user.last_active_at else None,
|
||||
"favorite_cards": [_serialize_card_public(c) for c in favorite_cards],
|
||||
"wtt_cards": [_serialize_card_public(c) for c in wtt_cards],
|
||||
}
|
||||
189
backend/routers/store.py
Normal file
189
backend/routers/store.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import stripe
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from game.card import _get_specific_card_async
|
||||
from core.config import FRONTEND_URL, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET
|
||||
from core.database import get_db
|
||||
from core.dependencies import get_current_user, limiter
|
||||
from core.models import Card as CardModel
|
||||
from core.models import ProcessedWebhookEvent
|
||||
from core.models import User as UserModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Shard packages sold for real money.
|
||||
# price_oere is in Danish øre (1 DKK = 100 øre). Stripe minimum is 250 øre.
|
||||
SHARD_PACKAGES = {
|
||||
"s1": {"base": 100, "bonus": 0, "shards": 100, "price_oere": 1000, "price_label": "10 DKK"},
|
||||
"s2": {"base": 250, "bonus": 50, "shards": 300, "price_oere": 2500, "price_label": "25 DKK"},
|
||||
"s3": {"base": 500, "bonus": 200, "shards": 700, "price_oere": 5000, "price_label": "50 DKK"},
|
||||
"s4": {"base": 1000, "bonus": 600, "shards": 1600, "price_oere": 10000, "price_label": "100 DKK"},
|
||||
"s5": {"base": 2500, "bonus": 2000, "shards": 4500, "price_oere": 25000, "price_label": "250 DKK"},
|
||||
"s6": {"base": 5000, "bonus": 5000, "shards": 10000, "price_oere": 50000, "price_label": "500 DKK"},
|
||||
}
|
||||
|
||||
STORE_PACKAGES = {
|
||||
1: 15,
|
||||
5: 65,
|
||||
10: 120,
|
||||
25: 260,
|
||||
}
|
||||
|
||||
SPECIFIC_CARD_COST = 1000
|
||||
|
||||
|
||||
class ShatterRequest(BaseModel):
|
||||
card_ids: list[str]
|
||||
|
||||
class StripeCheckoutRequest(BaseModel):
|
||||
package_id: str
|
||||
|
||||
class StoreBuyRequest(BaseModel):
|
||||
quantity: int
|
||||
|
||||
class BuySpecificCardRequest(BaseModel):
|
||||
wiki_title: str
|
||||
|
||||
|
||||
@router.post("/shards/shatter")
|
||||
def shatter_cards(req: ShatterRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not req.card_ids:
|
||||
raise HTTPException(status_code=400, detail="No cards selected")
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in req.card_ids]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid card IDs")
|
||||
|
||||
cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(parsed_ids),
|
||||
CardModel.user_id == user.id,
|
||||
).all()
|
||||
|
||||
if len(cards) != len(parsed_ids):
|
||||
raise HTTPException(status_code=400, detail="Some cards are not in your collection")
|
||||
|
||||
total = sum(c.cost for c in cards)
|
||||
|
||||
for card in cards:
|
||||
db.delete(card)
|
||||
|
||||
user.shards += total
|
||||
db.commit()
|
||||
return {"shards": user.shards, "gained": total}
|
||||
|
||||
|
||||
@router.post("/store/stripe/checkout")
|
||||
def create_stripe_checkout(req: StripeCheckoutRequest, user: UserModel = Depends(get_current_user)):
|
||||
package = SHARD_PACKAGES.get(req.package_id)
|
||||
if not package:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[{
|
||||
"price_data": {
|
||||
"currency": "dkk",
|
||||
"product_data": {"name": f"WikiTCG Shards — {package['price_label']}"},
|
||||
"unit_amount": package["price_oere"],
|
||||
},
|
||||
"quantity": 1,
|
||||
}],
|
||||
mode="payment",
|
||||
success_url=f"{FRONTEND_URL}/store?payment=success",
|
||||
cancel_url=f"{FRONTEND_URL}/store",
|
||||
metadata={"user_id": str(user.id), "shards": str(package["shards"])},
|
||||
)
|
||||
return {"url": session.url}
|
||||
|
||||
|
||||
@router.post("/stripe/webhook")
|
||||
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
payload = await request.body()
|
||||
sig = request.headers.get("stripe-signature", "")
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)
|
||||
except stripe.error.SignatureVerificationError: # type: ignore
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
# Guard against duplicate delivery: Stripe retries on timeout/5xx, so the same
|
||||
# event can arrive more than once. The PK constraint on stripe_event_id is the
|
||||
# arbiter — if the INSERT fails, we've already processed this event.
|
||||
try:
|
||||
db.add(ProcessedWebhookEvent(stripe_event_id=event["id"]))
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return {"ok": True}
|
||||
|
||||
if event["type"] == "checkout.session.completed":
|
||||
data = event["data"]["object"]
|
||||
user_id = data.get("metadata", {}).get("user_id")
|
||||
shards = data.get("metadata", {}).get("shards")
|
||||
if user_id and shards:
|
||||
user = db.query(UserModel).filter(UserModel.id == uuid.UUID(user_id)).first()
|
||||
if user:
|
||||
user.shards += int(shards)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/store/config")
|
||||
def store_config():
|
||||
return {
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
"shard_packages": SHARD_PACKAGES,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/store/buy-specific-card")
|
||||
@limiter.limit("10/hour")
|
||||
async def buy_specific_card(request: Request, req: BuySpecificCardRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if user.shards < SPECIFIC_CARD_COST:
|
||||
raise HTTPException(status_code=400, detail="Not enough shards")
|
||||
|
||||
card = await _get_specific_card_async(req.wiki_title)
|
||||
if card is None:
|
||||
raise HTTPException(status_code=404, detail="Could not generate a card for that Wikipedia page")
|
||||
|
||||
db_card = CardModel(
|
||||
name=card.name,
|
||||
image_link=card.image_link,
|
||||
card_rarity=card.card_rarity.name,
|
||||
card_type=card.card_type.name,
|
||||
text=card.text,
|
||||
attack=card.attack,
|
||||
defense=card.defense,
|
||||
cost=card.cost,
|
||||
user_id=user.id,
|
||||
received_at=datetime.now(),
|
||||
)
|
||||
db.add(db_card)
|
||||
user.shards -= SPECIFIC_CARD_COST
|
||||
db.commit()
|
||||
db.refresh(db_card)
|
||||
|
||||
return {
|
||||
**{c.name: getattr(db_card, c.name) for c in db_card.__table__.columns},
|
||||
"card_rarity": db_card.card_rarity,
|
||||
"card_type": db_card.card_type,
|
||||
"shards": user.shards,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/store/buy")
|
||||
def store_buy(req: StoreBuyRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
cost = STORE_PACKAGES.get(req.quantity)
|
||||
if cost is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid package")
|
||||
if user.shards < cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough shards")
|
||||
user.shards -= cost
|
||||
user.boosters += req.quantity
|
||||
db.commit()
|
||||
return {"shards": user.shards, "boosters": user.boosters}
|
||||
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