🏓 Initial ServeSync demo build

- FastAPI backend with PostgreSQL + Redis
- 4 core features: Registration, Courts, Matchmaking, Tournament
- Double elimination tournament with bracket visualization
- ELO-based matchmaking (Stage 1 Open, Stage 2 Skill-Based)
- Real-time WebSocket updates
- TV Screen display for courts
- Vue 3 + Tailwind CSS frontend
- Seed data: 12 players, 4 courts, active matches, tournament in progress
- Docker compose stack with Nginx reverse proxy
This commit is contained in:
Forge
2026-03-19 00:58:17 +08:00
commit 46fd2953c3
45 changed files with 4383 additions and 0 deletions

View File

218
backend/app/api/courts.py Normal file
View File

@@ -0,0 +1,218 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.database import get_db
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.player import Player
from app.models.match import Match, MatchStatus
from pydantic import BaseModel
from datetime import datetime, timedelta
from typing import Optional, List
router = APIRouter(prefix="/courts", tags=["courts"])
class BookingCreate(BaseModel):
player_id: int
court_id: int
start_time: datetime
duration_hours: float = 1.0
class CourtResponse(BaseModel):
id: int
name: str
court_number: int
hourly_rate: float
is_active: bool
surface_type: str
features: str
current_status: str
current_match: Optional[dict] = None
class Config:
from_attributes = True
class BookingResponse(BaseModel):
id: int
player_id: int
player_name: str
court_id: int
court_name: str
start_time: datetime
end_time: datetime
duration_hours: float
total_cost: float
status: str
class Config:
from_attributes = True
def get_court_status(court: Court, db: Session) -> tuple:
"""Get current status of a court"""
# Check if match is in progress
match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
if match:
team1 = [mp.player.name for mp in match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in match.match_players if mp.team == 2]
return "occupied", {
"match_id": match.id,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"stage": match.stage.value,
"started_at": match.started_at.isoformat() if match.started_at else None,
}
# Check if there's a booking now
now = datetime.utcnow()
booking = db.query(Booking).filter(
Booking.court_id == court.id,
Booking.start_time <= now,
Booking.end_time >= now,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).first()
if booking:
return "booked", None
return "available", None
@router.get("/", response_model=List[CourtResponse])
def get_courts(db: Session = Depends(get_db)):
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
status, match_info = get_court_status(court, db)
result.append(CourtResponse(
id=court.id,
name=court.name,
court_number=court.court_number,
hourly_rate=court.hourly_rate,
is_active=court.is_active,
surface_type=court.surface_type,
features=court.features,
current_status=status,
current_match=match_info,
))
return result
@router.get("/{court_id}")
def get_court(court_id: int, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
status, match_info = get_court_status(court, db)
return {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"hourly_rate": court.hourly_rate,
"surface_type": court.surface_type,
"features": court.features,
"current_status": status,
"current_match": match_info,
}
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
"""Get bookings for a court for the next 7 days"""
now = datetime.utcnow()
end = now + timedelta(days=7)
bookings = db.query(Booking).filter(
Booking.court_id == court_id,
Booking.start_time >= now,
Booking.end_time <= end,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).order_by(Booking.start_time).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"duration_hours": b.duration_hours,
"is_match_booking": b.is_match_booking,
}
for b in bookings
]
@router.post("/book")
def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == booking_data.court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
player = db.query(Player).filter(Player.id == booking_data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours)
# Check for conflicts
conflict = db.query(Booking).filter(
Booking.court_id == booking_data.court_id,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]),
and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time)
).first()
if conflict:
raise HTTPException(status_code=400, detail="Court is already booked for this time slot")
total_cost = court.hourly_rate * booking_data.duration_hours
booking = Booking(
player_id=booking_data.player_id,
court_id=booking_data.court_id,
start_time=booking_data.start_time,
end_time=end_time,
duration_hours=booking_data.duration_hours,
total_cost=total_cost,
status=BookingStatus.CONFIRMED,
)
db.add(booking)
db.commit()
db.refresh(booking)
return {
"id": booking.id,
"court_name": court.name,
"player_name": player.name,
"start_time": booking.start_time.isoformat(),
"end_time": booking.end_time.isoformat(),
"total_cost": booking.total_cost,
"status": booking.status.value,
}
@router.get("/bookings/upcoming")
def get_upcoming_bookings(db: Session = Depends(get_db)):
now = datetime.utcnow()
bookings = db.query(Booking).filter(
Booking.start_time >= now,
Booking.status == BookingStatus.CONFIRMED
).order_by(Booking.start_time).limit(20).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"court_name": b.court.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"total_cost": b.total_cost,
}
for b in bookings
]

160
backend/app/api/matches.py Normal file
View File

@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.player import Player
from app.services import matchmaking
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/matches", tags=["matches"])
class CreateMatchRequest(BaseModel):
title: str
stage: str = "open"
match_type: str = "doubles"
creator_player_id: int
elo_tolerance: float = 200
class JoinMatchRequest(BaseModel):
player_id: int
team: int
class ScoreUpdateRequest(BaseModel):
team1_score: int
team2_score: int
def match_to_dict(match: Match, db: Session) -> dict:
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all()
team1 = []
team2 = []
for mp in players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pdata = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"elo_change": mp.elo_change,
}
if mp.team == 1:
team1.append(pdata)
else:
team2.append(pdata)
return {
"id": match.id,
"title": match.title,
"stage": match.stage.value,
"match_type": match.match_type.value,
"status": match.status.value,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"max_players": match.max_players,
"current_players": len(players),
"min_elo": match.min_elo,
"max_elo": match.max_elo,
"court_id": match.court_id,
"court_name": match.court.name if match.court else None,
"started_at": match.started_at.isoformat() if match.started_at else None,
"ended_at": match.ended_at.isoformat() if match.ended_at else None,
"created_at": match.created_at.isoformat() if match.created_at else None,
}
@router.get("/")
def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(Match)
if status:
query = query.filter(Match.status == status)
if stage:
query = query.filter(Match.stage == stage)
matches = query.order_by(Match.created_at.desc()).limit(50).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/lobby")
def get_lobby(db: Session = Depends(get_db)):
matches = matchmaking.get_lobby_matches(db)
return [match_to_dict(m, db) for m in matches]
@router.get("/active")
def get_active_matches(db: Session = Depends(get_db)):
matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/{match_id}")
def get_match(match_id: int, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
return match_to_dict(match, db)
@router.post("/")
def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == req.creator_player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES
if req.stage == "open":
match = matchmaking.create_open_match(db, req.title, match_type)
elif req.stage == "skill_based":
match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance)
else:
raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'")
# Auto-join creator to team 1
matchmaking.join_match(db, match.id, player.id, 1)
return match_to_dict(match, db)
@router.post("/{match_id}/join")
def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)):
result = matchmaking.join_match(db, match_id, req.player_id, req.team)
if not result:
raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)")
match = db.query(Match).filter(Match.id == match_id).first()
return match_to_dict(match, db)
@router.post("/{match_id}/start")
def start_match(match_id: int, db: Session = Depends(get_db)):
match = matchmaking.start_match(db, match_id)
if not match:
raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)")
return match_to_dict(match, db)
@router.post("/{match_id}/score")
def update_score(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
raise HTTPException(status_code=404, detail="Match not found")
if match.status != MatchStatus.IN_PROGRESS:
raise HTTPException(status_code=400, detail="Match not in progress")
match.team1_score = req.team1_score
match.team2_score = req.team2_score
db.commit()
return match_to_dict(match, db)
@router.post("/{match_id}/complete")
def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score)
if not match:
raise HTTPException(status_code=400, detail="Cannot complete match")
return match_to_dict(match, db)

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.player import Player, MembershipTier
from app.services.elo import get_tier_from_elo
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/players", tags=["players"])
class PlayerCreate(BaseModel):
name: str
email: str
phone: Optional[str] = None
class PlayerUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
class PlayerResponse(BaseModel):
id: int
name: str
email: str
phone: Optional[str]
elo_rating: float
membership_tier: str
wins: int
losses: int
total_matches: int
win_rate: float
avatar_color: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, player: Player):
return cls(
id=player.id,
name=player.name,
email=player.email,
phone=player.phone,
elo_rating=round(player.elo_rating, 1),
membership_tier=player.membership_tier.value,
wins=player.wins,
losses=player.losses,
total_matches=player.total_matches,
win_rate=player.win_rate,
avatar_color=player.avatar_color,
)
@router.get("/", response_model=List[PlayerResponse])
def get_players(db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/leaderboard", response_model=List[PlayerResponse])
def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/{player_id}", response_model=PlayerResponse)
def get_player(player_id: int, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerResponse.from_orm(player)
@router.post("/", response_model=PlayerResponse)
def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)):
existing = db.query(Player).filter(Player.email == player_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
color = colors[db.query(Player).count() % len(colors)]
player = Player(
name=player_data.name,
email=player_data.email,
phone=player_data.phone,
avatar_color=color,
)
db.add(player)
db.commit()
db.refresh(player)
return PlayerResponse.from_orm(player)

156
backend/app/api/screen.py Normal file
View File

@@ -0,0 +1,156 @@
"""Screen Display API - for TV/display views"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.database import get_db
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStatus
from app.models.player import Player
from app.models.tournament import Tournament, TournamentStatus
from app.services.tournament import get_bracket_data
from datetime import datetime
router = APIRouter(prefix="/screen", tags=["screen"])
@router.get("/court/{court_id}")
def get_court_display(court_id: int, db: Session = Depends(get_db)):
"""Full court display data for TV screen"""
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
return {"error": "Court not found"}
# Current active match
active_match = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.IN_PROGRESS
).first()
match_data = None
if active_match:
team1_players = []
team2_players = []
for mp in active_match.match_players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pd = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"membership_tier": p.membership_tier.value,
}
if mp.team == 1:
team1_players.append(pd)
else:
team2_players.append(pd)
elapsed = None
if active_match.started_at:
elapsed = int((datetime.utcnow() - active_match.started_at).total_seconds())
match_data = {
"id": active_match.id,
"stage": active_match.stage.value,
"match_type": active_match.match_type.value,
"team1": team1_players,
"team2": team2_players,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"elapsed_seconds": elapsed,
}
# Recent completed matches on this court
recent_matches = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.COMPLETED
).order_by(desc(Match.ended_at)).limit(3).all()
recent = []
for m in recent_matches:
team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 1]
team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 2]
recent.append({
"team1": team1,
"team2": team2,
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner": "team1" if m.team1_score > m.team2_score else "team2",
})
return {
"court": {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"surface_type": court.surface_type,
},
"active_match": match_data,
"recent_matches": recent,
"timestamp": datetime.utcnow().isoformat(),
}
@router.get("/overview")
def get_overview(db: Session = Depends(get_db)):
"""All courts overview for main display"""
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
active_match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
court_data = {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"status": "occupied" if active_match else "available",
}
if active_match:
team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2]
court_data["match"] = {
"team1_names": team1,
"team2_names": team2,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"stage": active_match.stage.value,
}
result.append(court_data)
# Active tournament
tournament_data = None
active_tournament = db.query(Tournament).filter(
Tournament.status == TournamentStatus.IN_PROGRESS
).first()
if active_tournament:
tournament_data = get_bracket_data(db, active_tournament.id)
# Leaderboard top 5
top_players = db.query(Player).filter(Player.is_active == True).order_by(
Player.elo_rating.desc()
).limit(5).all()
return {
"courts": result,
"tournament": tournament_data,
"leaderboard": [
{
"rank": i + 1,
"name": p.name,
"elo": round(p.elo_rating, 1),
"tier": p.membership_tier.value,
"wins": p.wins,
"losses": p.losses,
"avatar_color": p.avatar_color,
}
for i, p in enumerate(top_players)
],
"timestamp": datetime.utcnow().isoformat(),
}

View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus
from app.services import tournament as tournament_service
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/tournaments", tags=["tournaments"])
class CreateTournamentRequest(BaseModel):
name: str
max_participants: int = 8
class RegisterPlayerRequest(BaseModel):
player_id: int
class MatchScoreRequest(BaseModel):
team1_score: int
team2_score: int
@router.get("/")
def get_tournaments(db: Session = Depends(get_db)):
tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"status": t.status.value,
"max_participants": t.max_participants,
"current_participants": len(t.entries),
"started_at": t.started_at.isoformat() if t.started_at else None,
}
for t in tournaments
]
@router.get("/{tournament_id}")
def get_tournament(tournament_id: int, db: Session = Depends(get_db)):
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/")
def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)):
t = tournament_service.create_tournament(db, req.name, req.max_participants)
return {"id": t.id, "name": t.name, "status": t.status.value}
@router.post("/{tournament_id}/register")
def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)):
entry = tournament_service.register_player(db, tournament_id, req.player_id)
if not entry:
raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)")
return {"message": "Registered successfully", "player_id": req.player_id}
@router.post("/{tournament_id}/start")
def start_tournament(tournament_id: int, db: Session = Depends(get_db)):
t = tournament_service.start_tournament(db, tournament_id)
if not t:
raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)")
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/{tournament_id}/matches/{match_id}/score")
def complete_tournament_match(
tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db)
):
tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score)
if not tm:
raise HTTPException(status_code=400, detail="Cannot complete match")
return tournament_service.get_bracket_data(db, tournament_id)
@router.get("/{tournament_id}/leaderboard")
def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)):
data = tournament_service.get_bracket_data(db, tournament_id)
return data.get("leaderboard", [])