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

View File

224
backend/routers/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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"})

View 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
View 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
View 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
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]