Added h2h communication.

This commit is contained in:
2026-01-11 11:25:33 -06:00
parent 174abb7f56
commit e0af183086
12 changed files with 743 additions and 3 deletions

View File

@ -32,6 +32,28 @@ async def get_current_user(
return user
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
async def get_current_user_optional(
token: str = Depends(oauth2_scheme_optional),
db: AsyncSession = Depends(get_db)
):
"""Get current user if authenticated, otherwise return None."""
if not token:
return None
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
return None
except JWTError:
return None
user = await get_user_by_id(db, int(user_id))
return user
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,

View File

@ -0,0 +1,181 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List, Optional
from app.database import get_db
from app.models import User, SpreadBet, MatchComment
from app.models.spread_bet import SpreadBetStatus
from app.schemas.match_comment import (
MatchComment as MatchCommentSchema,
MatchCommentCreate,
MatchDetailResponse,
MatchBetDetail,
MatchUser
)
from app.routers.auth import get_current_user, get_current_user_optional
from app.routers.websocket import broadcast_to_match
router = APIRouter(prefix="/api/v1/matches", tags=["matches"])
async def get_match_bet(bet_id: int, db: AsyncSession) -> SpreadBet:
"""Get a matched bet by ID with all relationships loaded."""
result = await db.execute(
select(SpreadBet)
.options(
selectinload(SpreadBet.event),
selectinload(SpreadBet.creator),
selectinload(SpreadBet.taker)
)
.where(SpreadBet.id == bet_id)
)
bet = result.scalar_one_or_none()
if not bet:
raise HTTPException(status_code=404, detail="Bet not found")
if bet.status not in [SpreadBetStatus.MATCHED, SpreadBetStatus.COMPLETED]:
raise HTTPException(status_code=400, detail="Bet is not matched")
return bet
@router.get("/{bet_id}", response_model=MatchDetailResponse)
async def get_match_detail(
bet_id: int,
db: AsyncSession = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional)
):
"""Get match details - public access, anyone can view."""
bet = await get_match_bet(bet_id, db)
# Get comments
comments_result = await db.execute(
select(MatchComment)
.options(selectinload(MatchComment.user))
.where(MatchComment.spread_bet_id == bet_id)
.order_by(MatchComment.created_at.asc())
)
comments = comments_result.scalars().all()
# Check if current user can comment
can_comment = False
if current_user:
can_comment = current_user.id in [bet.creator_id, bet.taker_id]
return MatchDetailResponse(
bet=MatchBetDetail(
id=bet.id,
event_id=bet.event_id,
spread=bet.spread,
team=bet.team,
stake_amount=bet.stake_amount,
house_commission_percent=bet.house_commission_percent,
status=bet.status,
payout_amount=bet.payout_amount,
winner_id=bet.winner_id,
created_at=bet.created_at,
matched_at=bet.matched_at,
completed_at=bet.completed_at,
home_team=bet.event.home_team,
away_team=bet.event.away_team,
game_time=bet.event.game_time,
official_spread=bet.event.official_spread,
creator=MatchUser(id=bet.creator.id, username=bet.creator.username),
taker=MatchUser(id=bet.taker.id, username=bet.taker.username) if bet.taker else None
),
comments=[
MatchCommentSchema(
id=c.id,
spread_bet_id=c.spread_bet_id,
user_id=c.user_id,
username=c.user.username,
content=c.content,
created_at=c.created_at
)
for c in comments
],
can_comment=can_comment
)
@router.get("/{bet_id}/comments", response_model=List[MatchCommentSchema])
async def get_match_comments(
bet_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get all comments for a match - public access."""
# Verify bet exists and is matched
await get_match_bet(bet_id, db)
result = await db.execute(
select(MatchComment)
.options(selectinload(MatchComment.user))
.where(MatchComment.spread_bet_id == bet_id)
.order_by(MatchComment.created_at.asc())
)
comments = result.scalars().all()
return [
MatchCommentSchema(
id=c.id,
spread_bet_id=c.spread_bet_id,
user_id=c.user_id,
username=c.user.username,
content=c.content,
created_at=c.created_at
)
for c in comments
]
@router.post("/{bet_id}/comments", response_model=MatchCommentSchema)
async def add_match_comment(
bet_id: int,
comment_data: MatchCommentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Add a comment to a match - only participants can comment."""
bet = await get_match_bet(bet_id, db)
# Check if user is a participant
if current_user.id not in [bet.creator_id, bet.taker_id]:
raise HTTPException(
status_code=403,
detail="Only bet participants can comment"
)
# Create comment
comment = MatchComment(
spread_bet_id=bet_id,
user_id=current_user.id,
content=comment_data.content
)
db.add(comment)
await db.commit()
await db.refresh(comment)
comment_response = MatchCommentSchema(
id=comment.id,
spread_bet_id=comment.spread_bet_id,
user_id=comment.user_id,
username=current_user.username,
content=comment.content,
created_at=comment.created_at
)
# Broadcast new comment to match subscribers
await broadcast_to_match(
bet_id,
"new_comment",
{
"id": comment.id,
"spread_bet_id": comment.spread_bet_id,
"user_id": comment.user_id,
"username": current_user.username,
"content": comment.content,
"created_at": comment.created_at.isoformat()
}
)
return comment_response

View File

@ -14,6 +14,9 @@ active_connections: Dict[str, WebSocket] = {}
# Store connections subscribed to specific events
event_subscriptions: Dict[int, Set[str]] = {} # event_id -> set of connection_ids
# Store connections subscribed to specific matches (bet_id)
match_subscriptions: Dict[int, Set[str]] = {} # bet_id -> set of connection_ids
# Map connection_id to websocket
connection_websockets: Dict[str, WebSocket] = {}
@ -22,7 +25,8 @@ connection_websockets: Dict[str, WebSocket] = {}
async def websocket_endpoint(
websocket: WebSocket,
token: str = Query(...),
event_id: Optional[int] = Query(None)
event_id: Optional[int] = Query(None),
match_id: Optional[int] = Query(None)
):
await websocket.accept()
@ -51,6 +55,13 @@ async def websocket_endpoint(
event_subscriptions[event_id].add(connection_id)
print(f"[WebSocket] Subscribed {connection_id} to event {event_id}. Total subscribers: {len(event_subscriptions[event_id])}")
# Subscribe to match if specified
if match_id:
if match_id not in match_subscriptions:
match_subscriptions[match_id] = set()
match_subscriptions[match_id].add(connection_id)
print(f"[WebSocket] Subscribed {connection_id} to match {match_id}. Total subscribers: {len(match_subscriptions[match_id])}")
try:
while True:
data = await websocket.receive_text()
@ -68,6 +79,17 @@ async def websocket_endpoint(
if eid in event_subscriptions:
event_subscriptions[eid].discard(connection_id)
print(f"[WebSocket] {connection_id} unsubscribed from event {eid}")
elif msg.get('action') == 'subscribe_match' and msg.get('match_id'):
mid = msg['match_id']
if mid not in match_subscriptions:
match_subscriptions[mid] = set()
match_subscriptions[mid].add(connection_id)
print(f"[WebSocket] {connection_id} subscribed to match {mid}")
elif msg.get('action') == 'unsubscribe_match' and msg.get('match_id'):
mid = msg['match_id']
if mid in match_subscriptions:
match_subscriptions[mid].discard(connection_id)
print(f"[WebSocket] {connection_id} unsubscribed from match {mid}")
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
@ -84,6 +106,12 @@ async def websocket_endpoint(
subs.discard(connection_id)
print(f"[WebSocket] Removed {connection_id} from event {eid} subscriptions")
# Clean up match subscriptions
for mid, subs in match_subscriptions.items():
if connection_id in subs:
subs.discard(connection_id)
print(f"[WebSocket] Removed {connection_id} from match {mid} subscriptions")
async def broadcast_to_event(event_id: int, event_type: str, data: dict):
"""Broadcast a message to all connections subscribed to an event"""
@ -136,3 +164,42 @@ async def broadcast_event(event_type: str, data: dict, user_ids: list[int] = Non
await ws.send_text(message)
except Exception:
pass
async def broadcast_to_match(match_id: int, event_type: str, data: dict):
"""Broadcast a message to all connections subscribed to a match"""
message = json.dumps({
"type": event_type,
"data": {"match_id": match_id, **data}
})
print(f"[WebSocket] Broadcasting {event_type} to match {match_id}")
if match_id not in match_subscriptions:
print(f"[WebSocket] No subscribers for match {match_id}")
return
subscribers = match_subscriptions[match_id].copy()
print(f"[WebSocket] Found {len(subscribers)} subscribers for match {match_id}")
disconnected = set()
for conn_id in subscribers:
ws = connection_websockets.get(conn_id)
if ws:
try:
await ws.send_text(message)
print(f"[WebSocket] Sent message to {conn_id}")
except Exception as e:
print(f"[WebSocket] Failed to send to {conn_id}: {e}")
disconnected.add(conn_id)
else:
print(f"[WebSocket] Connection {conn_id} not found in websockets map")
disconnected.add(conn_id)
# Clean up disconnected connections
for conn_id in disconnected:
match_subscriptions[match_id].discard(conn_id)
if conn_id in active_connections:
del active_connections[conn_id]
if conn_id in connection_websockets:
del connection_websockets[conn_id]