Coverage for app.py: 12.52%
2754 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:24 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:24 +0000
1import asyncio
2import os
3from datetime import UTC, datetime
4from typing import Any
6import httpx
7from dotenv import load_dotenv
8from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, Response, UploadFile
9from fastapi.middleware.cors import CORSMiddleware
10from pydantic import BaseModel
12from api.channel_requests import router as channel_requests_router
13from api.invite_requests import router as invite_requests_router
14from api.invites import router as invites_router
15from auth import (
16 AuthManager,
17 get_current_user_required,
18 require_admin,
19 require_match_management_permission,
20 require_team_manager_or_admin,
21)
22from constants import PLAYER_POSITIONS
23from csrf_protection import provide_csrf_token
24from middleware import TraceMiddleware
25from models import (
26 AdminPlayerTeamAssignment,
27 AdminPlayerUpdate,
28 AdvanceWinnerRequest,
29 AgeGroupCreate,
30 AgeGroupUpdate,
31 BatchPlayerStatsUpdate,
32 BulkRenumberRequest,
33 BulkRosterCreate,
34 Club,
35 DivisionCreate,
36 DivisionUpdate,
37 EnhancedMatch,
38 ForfeitMatchRequest,
39 ForgotPasswordRequest,
40 GenerateBracketRequest,
41 GoalEvent,
42 GoalEventUpdate,
43 JerseyNumberUpdate,
44 LeagueCreate,
45 LeagueUpdate,
46 LineupSave,
47 LiveCardEvent,
48 LiveMatchClock,
49 MatchPatch,
50 MatchSubmissionData,
51 MessageEvent,
52 PlayerCustomization,
53 PlayerHistoryCreate,
54 PlayerHistoryUpdate,
55 PostMatchCard,
56 PostMatchGoal,
57 PostMatchSubstitution,
58 ProfilePhotoSlot,
59 RefreshTokenRequest,
60 ResetPasswordRequest,
61 RoleUpdate,
62 RosterPlayerCreate,
63 RosterPlayerUpdate,
64 SeasonCreate,
65 SeasonUpdate,
66 Team,
67 TeamMappingCreate,
68 TeamMatchTypeMapping,
69 TeamUpdate,
70 UserLogin,
71 UserProfile,
72 UserProfileUpdate,
73 UserSignup,
74)
75from services import EmailService, InviteService
77# Legacy flag kept for backwards compatibility so existing envs keep working.
78DISABLE_SECURITY = os.getenv("DISABLE_SECURITY", "false").lower() == "true"
80from dao.audit_dao import AuditDAO
81from dao.club_dao import ClubDAO
82from dao.exceptions import DuplicateRecordError
83from dao.league_dao import LeagueDAO
84from dao.lineup_dao import LineupDAO
85from dao.match_dao import MatchDAO
86from dao.match_dao import SupabaseConnection as DbConnectionHolder
87from dao.match_event_dao import MatchEventDAO
88from dao.match_type_dao import MatchTypeDAO
89from dao.player_dao import PlayerDAO
90from dao.player_stats_dao import PlayerStatsDAO
91from dao.playoff_dao import PlayoffDAO
92from dao.roster_dao import RosterDAO
93from dao.season_dao import SeasonDAO
94from dao.team_dao import TeamDAO
95from dao.tournament_dao import TournamentDAO
98# Load environment variables with environment-specific support
99def load_environment():
100 """Load environment variables based on APP_ENV or default to local."""
101 # First load base .env file
102 load_dotenv()
104 # Determine which environment to use
105 app_env = os.getenv("APP_ENV", "local") # Default to local
107 # Load environment-specific file
108 env_file = f".env.{app_env}"
109 if os.path.exists(env_file): 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 load_dotenv(env_file, override=True)
111 else:
112 # Fallback to .env.local for backwards compatibility
113 if os.path.exists(".env.local"): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 load_dotenv(".env.local", override=True)
117load_environment()
119# Configure structured logging with JSON output for Loki
120from logging_config import get_logger, setup_logging
122setup_logging(service_name="backend")
123logger = get_logger(__name__)
125app = FastAPI(title="Enhanced Sports League API", version="2.0.0")
127# Setup Prometheus metrics - exposes /metrics endpoint for Grafana
128from metrics_config import setup_metrics
130setup_metrics(app)
131logger.info("prometheus_metrics_enabled", endpoint="/metrics")
133# Configure Rate Limiting
134# TODO: Fix middleware order issue
135# limiter = create_rate_limit_middleware(app)
137# Add CSRF Protection Middleware
138# TODO: Fix middleware order issue
139# app.middleware("http")(csrf_middleware)
142# Configure CORS
143def get_cors_origins():
144 """Get CORS origins based on environment"""
145 local_origins = [
146 "http://localhost:8080",
147 "http://localhost:8081",
148 "http://192.168.1.2:8080",
149 "http://192.168.1.2:8081",
150 ]
152 # Production origins (HTTPS only - HTTP redirects to HTTPS via Ingress)
153 # After deprecating dev.missingtable.com (2025-11-19), only production domains remain
154 production_origins = [
155 "https://missingtable.com",
156 "https://www.missingtable.com",
157 ]
159 # Allow additional CORS origins from environment variable
160 extra_origins_str = os.getenv("CORS_ORIGINS", "")
161 extra_origins = [origin.strip() for origin in extra_origins_str.split(",") if origin.strip()]
163 # Get environment-specific origins
164 environment = os.getenv("ENVIRONMENT", "development")
166 # All production domains point to the same namespace (missing-table-dev)
167 all_cloud_origins = production_origins
169 # In production, only allow production origins (security best practice)
170 # In development/dev, allow both local and cloud origins for flexibility
171 if environment == "production": 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 return all_cloud_origins + extra_origins
173 else:
174 # Development/dev environment: allow local origins + cloud origins (consolidated architecture)
175 return local_origins + all_cloud_origins + extra_origins
178origins = get_cors_origins()
180app.add_middleware(
181 CORSMiddleware,
182 allow_origins=origins,
183 allow_credentials=True,
184 allow_methods=["*"],
185 allow_headers=["*"],
186)
188# Add trace middleware for distributed logging (session_id, request_id)
189app.add_middleware(TraceMiddleware)
191# Initialize Supabase connection - use CLI for local development
192supabase_url = os.getenv("SUPABASE_URL", "")
193logger.debug(
194 "Supabase URL configuration",
195 url=supabase_url,
196 is_local="localhost" in supabase_url or "127.0.0.1" in supabase_url,
197)
198if "localhost" in supabase_url or "127.0.0.1" in supabase_url: 198 ↛ 201line 198 didn't jump to line 201 because the condition on line 198 was always true
199 logger.info("Using Supabase CLI local development", url=supabase_url)
200else:
201 logger.info("Using enhanced Supabase connection")
203# Initialize all DAOs with shared connection
204db_conn_holder_obj = DbConnectionHolder()
205match_dao = MatchDAO(db_conn_holder_obj)
206match_event_dao = MatchEventDAO(db_conn_holder_obj)
207team_dao = TeamDAO(db_conn_holder_obj)
208club_dao = ClubDAO(db_conn_holder_obj)
209player_dao = PlayerDAO(db_conn_holder_obj)
210roster_dao = RosterDAO(db_conn_holder_obj)
211player_stats_dao = PlayerStatsDAO(db_conn_holder_obj)
212lineup_dao = LineupDAO(db_conn_holder_obj)
213season_dao = SeasonDAO(db_conn_holder_obj)
214league_dao = LeagueDAO(db_conn_holder_obj)
215match_type_dao = MatchTypeDAO(db_conn_holder_obj)
216playoff_dao = PlayoffDAO(db_conn_holder_obj)
217audit_dao = AuditDAO(db_conn_holder_obj)
218tournament_dao = TournamentDAO(db_conn_holder_obj)
221# === Simple Redis Caching ===
222# Initialize Authentication Manager with a dedicated service client
223# This prevents login operations from modifying the client used for profile lookups
224from supabase import create_client
226auth_service_client = create_client(
227 os.getenv("SUPABASE_URL", ""),
228 os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_ANON_KEY", ""),
229)
230auth_manager = AuthManager(auth_service_client)
232# Create a separate client for auth operations (login/signup/logout)
233# This prevents auth operations from modifying the client used by match_dao
234auth_ops_client = create_client(
235 os.getenv("SUPABASE_URL", ""),
236 os.getenv("SUPABASE_ANON_KEY", ""), # Auth operations use anon key
237)
240def get_client_ip(request: Request) -> str:
241 """Best-effort client IP resolving for logging/auditing."""
242 forwarded_for = request.headers.get("x-forwarded-for")
243 if forwarded_for:
244 return forwarded_for.split(",")[0].strip()
245 if request.client and request.client.host:
246 return request.client.host
247 return "unknown"
250# === Include API Routers ===
251app.include_router(invites_router)
252app.include_router(invite_requests_router)
253app.include_router(channel_requests_router)
255# Version endpoint
256import contextlib
258from endpoints.version import router as version_router
260app.include_router(version_router)
262# === Authentication Endpoints ===
265async def _update_existing_user_role(user_data, invite_info, audit_logger):
266 """Helper to update an existing user's role when they re-signup with an invite code.
268 This handles the case where a user already exists (e.g., created as team-fan)
269 but is now signing up with an invite code to get a different role (e.g., team-manager).
270 """
271 from auth import username_to_internal_email
273 try:
274 logger.info(f"Updating existing user {user_data.username} role via invite code")
276 # Get the user's ID from user_profiles
277 existing_profile = player_dao.get_user_profile_by_username(user_data.username)
278 if not existing_profile:
279 # Try to find by internal email in auth.users
280 internal_email = username_to_internal_email(user_data.username)
281 auth_response = auth_service_client.auth.admin.list_users()
282 existing_user = None
283 for user in auth_response:
284 if user.email == internal_email:
285 existing_user = user
286 break
288 if not existing_user:
289 raise HTTPException(status_code=400, detail=f"User {user_data.username} not found")
290 user_id = existing_user.id
291 else:
292 user_id = existing_profile["id"]
294 # Map invite type to user role
295 role_mapping = {
296 "club_manager": "club_manager",
297 "club_fan": "club-fan",
298 "team_manager": "team-manager",
299 "team_player": "team-player",
300 "team_fan": "team-fan",
301 }
302 new_role = role_mapping.get(invite_info["invite_type"], "team-fan")
304 # Update user profile with new role and invite info
305 update_data = {
306 "username": user_data.username.lower(),
307 "role": new_role,
308 "team_id": invite_info.get("team_id"),
309 "club_id": invite_info.get("club_id"),
310 }
311 player_dao.update_user_profile(user_id, update_data)
313 # Redeem the invitation
314 invite_service = InviteService(db_conn_holder_obj.client)
315 invite_service.redeem_invitation(user_data.invite_code, user_id)
317 logger.info(f"Updated existing user {user_id} role to {new_role} via invite code")
318 audit_logger.info("auth_role_update_success", user_id=user_id, new_role=new_role)
320 role_display = invite_info["invite_type"].replace("_", " ")
321 return {
322 "message": f"Welcome back, {user_data.username}! Your role has been updated to {role_display}.",
323 "user_id": user_id,
324 "username": user_data.username,
325 }
326 except HTTPException:
327 raise
328 except Exception as e:
329 logger.error(f"Failed to update existing user role: {e}", exc_info=True)
330 raise HTTPException(status_code=400, detail=str(e)) from e
333@app.post("/api/auth/signup")
334# @rate_limit("3 per hour")
335async def signup(request: Request, user_data: UserSignup):
336 """User signup endpoint with username authentication."""
337 from auth import check_username_available, username_to_internal_email
339 client_ip = get_client_ip(request)
340 audit_logger = logger.bind(flow="auth_signup", username=user_data.username, client_ip=client_ip)
342 try:
343 # Validate invite code if provided (do this FIRST)
344 invite_info = None
345 if user_data.invite_code:
346 invite_service = InviteService(db_conn_holder_obj.client)
347 invite_info = invite_service.validate_invite_code(user_data.invite_code)
348 if not invite_info:
349 raise HTTPException(status_code=400, detail="Invalid or expired invite code")
351 # If invite has email specified, verify it matches (if user provided email)
352 if invite_info.get("email") and user_data.email:
353 if invite_info["email"] != user_data.email:
354 raise HTTPException(
355 status_code=400,
356 detail=f"This invite code is for {invite_info['email']}. Please use that email address.",
357 )
359 # Validate username availability
360 username_available = await check_username_available(db_conn_holder_obj.client, user_data.username)
361 if not username_available:
362 # If user has invite code, allow them to "re-signup" to update their role
363 if invite_info:
364 logger.info(f"User {user_data.username} exists but has invite code - will update role")
365 # Redirect to the role update flow
366 return await _update_existing_user_role(user_data, invite_info, audit_logger)
367 raise HTTPException(status_code=400, detail=f"Username '{user_data.username}' is already taken")
369 if invite_info:
370 logger.info(f"Valid invite code for {invite_info['invite_type']}: {user_data.invite_code}")
372 # Convert username to internal email for Supabase Auth
373 internal_email = username_to_internal_email(user_data.username)
375 # Create Supabase Auth user with internal email
376 # Use auth_ops_client to avoid modifying the match_dao client
377 response = auth_ops_client.auth.sign_up(
378 {
379 "email": internal_email,
380 "password": user_data.password,
381 "options": {
382 "data": {
383 "username": user_data.username,
384 "display_name": user_data.display_name or user_data.username,
385 "is_username_auth": True,
386 }
387 },
388 }
389 )
391 if response.user:
392 # Create/update user profile with username and optional contact info
393 # Convert empty strings to None for optional fields
394 profile_data = {
395 "id": response.user.id,
396 "username": user_data.username,
397 "email": user_data.email if user_data.email else None, # Optional real email
398 "phone_number": user_data.phone_number if user_data.phone_number else None, # Optional phone
399 "display_name": user_data.display_name or user_data.username,
400 "role": "team-fan", # Default role
401 }
403 # If signup with invite code, override role and team
404 if invite_info:
405 # Map invite type to user role
406 role_mapping = {
407 "club_manager": "club_manager",
408 "club_fan": "club-fan",
409 "team_manager": "team-manager",
410 "team_player": "team-player",
411 "team_fan": "team-fan",
412 }
413 profile_data["role"] = role_mapping.get(invite_info["invite_type"], "team-fan")
414 profile_data["team_id"] = invite_info.get("team_id")
415 profile_data["club_id"] = invite_info.get("club_id")
417 # Insert user profile
418 player_dao.create_or_update_user_profile(profile_data)
420 # Redeem invitation if used
421 if invite_info:
422 invite_service.redeem_invitation(user_data.invite_code, response.user.id)
423 logger.info(f"User {response.user.id} assigned role {profile_data['role']} via invite code")
425 audit_logger.info(
426 "auth_signup_success",
427 user_id=response.user.id,
428 used_invite=bool(user_data.invite_code),
429 )
431 message = f"Account created successfully! Welcome, {user_data.username}!"
432 if invite_info:
433 invite_type_display = invite_info["invite_type"].replace("_", " ")
434 if invite_info.get("club_name"):
435 message += f" You have been assigned to {invite_info['club_name']} as a {invite_type_display}."
436 elif invite_info.get("team_name"):
437 message += f" You have been assigned to {invite_info['team_name']} as a {invite_type_display}."
439 return {"message": message, "user_id": response.user.id, "username": user_data.username}
440 else:
441 audit_logger.warning("auth_signup_failed", reason="supabase_user_missing")
442 raise HTTPException(status_code=400, detail="Failed to create user")
444 except HTTPException:
445 raise
446 except Exception as e:
447 error_msg = str(e)
448 # Handle "User already registered" case - update role if invite code is valid
449 logger.info(f"Signup exception handler: error='{error_msg}', invite_info={invite_info is not None}")
450 if "User already registered" in error_msg and invite_info:
451 try:
452 logger.info(f"Attempting to update existing user {user_data.username} role via invite")
453 # Get existing user by internal email from auth.users
454 from auth import username_to_internal_email
456 internal_email = username_to_internal_email(user_data.username)
457 # Query auth.users to get user_id
458 auth_response = auth_service_client.auth.admin.list_users()
459 existing_user = None
460 for user in auth_response:
461 if user.email == internal_email:
462 existing_user = user
463 break
464 logger.info(f"Found existing auth user: {existing_user.id if existing_user else None}")
465 if existing_user:
466 # Map invite type to user role
467 role_mapping = {
468 "club_manager": "club_manager",
469 "club_fan": "club-fan",
470 "team_manager": "team-manager",
471 "team_player": "team-player",
472 "team_fan": "team-fan",
473 }
474 new_role = role_mapping.get(invite_info["invite_type"], "team-fan")
476 # Update user profile with new role, username, and invite info
477 update_data = {
478 "username": user_data.username.lower(), # Fix missing username
479 "role": new_role,
480 "team_id": invite_info.get("team_id"),
481 "club_id": invite_info.get("club_id"),
482 }
483 player_dao.update_user_profile(existing_user.id, update_data)
485 # Redeem the invitation
486 invite_service = InviteService(db_conn_holder_obj.client)
487 invite_service.redeem_invitation(user_data.invite_code, existing_user.id)
489 logger.info(f"Updated existing user {existing_user.id} role to {new_role} via invite code")
490 role_display = invite_info["invite_type"].replace("_", " ")
491 return {
492 "message": f"Welcome back! Your role has been updated to {role_display}.",
493 "user_id": existing_user.id,
494 "username": user_data.username,
495 }
496 except Exception as update_error:
497 logger.error(f"Failed to update existing user role: {update_error}", exc_info=True)
499 audit_logger.error("auth_signup_error", error=error_msg, email=user_data.email)
500 logger.error(f"Signup error: {e}", exc_info=True)
501 raise HTTPException(status_code=400, detail=error_msg) from e
504@app.post("/api/auth/login")
505# @rate_limit("5 per minute")
506async def login(request: Request, user_data: UserLogin):
507 """User login endpoint with username authentication."""
508 from auth import username_to_internal_email
510 client_ip = get_client_ip(request)
511 auth_logger = logger.bind(flow="auth_login", username=user_data.username, client_ip=client_ip)
513 try:
514 # Convert username to internal email for Supabase Auth
515 internal_email = username_to_internal_email(user_data.username)
517 # Authenticate with Supabase using internal email
518 # Use auth_ops_client to avoid modifying the match_dao client
519 response = auth_ops_client.auth.sign_in_with_password({"email": internal_email, "password": user_data.password})
521 if response.user and response.session:
522 # Get user profile with username, team, AND club info
523 profile = player_dao.get_user_profile_with_relationships(response.user.id)
524 if not profile:
525 profile = {"username": user_data.username} # Fallback
527 auth_logger.info("auth_login_success", user_id=response.user.id)
529 # Record login event
530 try:
531 auth_service_client.table("login_events").insert({
532 "user_id": str(response.user.id),
533 "username": user_data.username,
534 "client_ip": client_ip,
535 "success": True,
536 "role": profile.get("role"),
537 }).execute()
538 except Exception as log_err:
539 logger.warning(f"Failed to record login event: {log_err}")
541 return {
542 "access_token": response.session.access_token,
543 "refresh_token": response.session.refresh_token,
544 "user": {
545 "id": response.user.id,
546 "username": profile.get("username"),
547 "email": profile.get("email"), # Real email if provided
548 "display_name": profile.get("display_name"),
549 "role": profile.get("role"),
550 "team_id": profile.get("team_id"),
551 "club_id": profile.get("club_id"),
552 "team": profile.get("team"),
553 "club": profile.get("club"),
554 "created_at": profile.get("created_at"),
555 "updated_at": profile.get("updated_at"),
556 },
557 }
558 else:
559 auth_logger.warning("auth_login_failed", reason="invalid_credentials")
560 # Record failed login event
561 try:
562 auth_service_client.table("login_events").insert({
563 "username": user_data.username,
564 "client_ip": client_ip,
565 "success": False,
566 "failure_reason": "invalid_credentials",
567 }).execute()
568 except Exception as log_err:
569 logger.warning(f"Failed to record login event: {log_err}")
570 raise HTTPException(status_code=401, detail="Invalid username or password")
572 except HTTPException:
573 raise
574 except Exception as e:
575 auth_logger.error("auth_login_error", error=str(e))
576 logger.error(f"Login error: {e}", exc_info=True)
577 # Record failed login event
578 try:
579 auth_service_client.table("login_events").insert({
580 "username": user_data.username,
581 "client_ip": client_ip,
582 "success": False,
583 "failure_reason": "account_error",
584 }).execute()
585 except Exception as log_err:
586 logger.warning(f"Failed to record login event: {log_err}")
587 raise HTTPException(status_code=401, detail="Invalid credentials") from e
590@app.post("/api/auth/forgot-password")
591async def forgot_password(request: Request, body: ForgotPasswordRequest):
592 """
593 Initiate password reset flow.
595 - If user has an email on file: generate token, send reset email.
596 - If user has NO email: return ``needs_email: true`` so frontend can collect it.
597 - If user supplies an email in the body alongside a no-email account: save email first, then send.
598 - User not found: return generic success to prevent username enumeration.
599 """
600 client_ip = get_client_ip(request)
601 pw_logger = logger.bind(flow="forgot_password", client_ip=client_ip)
603 _GENERIC_RESPONSE = {"message": "If an account exists, a reset link has been sent."}
605 try:
606 user = player_dao.get_user_for_password_reset(body.identifier)
608 if not user:
609 # Generic response — don't reveal whether account exists
610 pw_logger.info("forgot_password_user_not_found")
611 return _GENERIC_RESPONSE
613 user_id: str = user["id"]
614 username: str = user.get("username", body.identifier)
615 email_on_file: str | None = user.get("email")
617 # Case: no email stored, and none provided in this request
618 if not email_on_file and not body.email:
619 pw_logger.info("forgot_password_needs_email", user_id=user_id)
620 return {"needs_email": True}
622 # Case: no email stored, but user just provided one
623 if not email_on_file and body.email:
624 # Persist it so future flows work too
625 try:
626 auth_service_client.table("user_profiles").update({"email": body.email}).eq("id", user_id).execute()
627 email_on_file = body.email
628 pw_logger.info("forgot_password_email_saved", user_id=user_id)
629 except Exception as save_err:
630 pw_logger.error("forgot_password_email_save_failed", user_id=user_id, error=str(save_err))
631 raise HTTPException(status_code=500, detail="Failed to save email") from save_err
633 # Generate reset token and send email
634 reset_token = auth_manager.create_password_reset_token(user_id)
635 try:
636 email_service = EmailService()
637 email_service.send_password_reset(email_on_file, reset_token, username)
638 except Exception as email_err:
639 pw_logger.error("forgot_password_email_send_failed", user_id=user_id, error=str(email_err))
640 # Don't leak the failure to the caller — still return generic success
641 return _GENERIC_RESPONSE
643 pw_logger.info("forgot_password_email_sent", user_id=user_id)
644 return _GENERIC_RESPONSE
646 except HTTPException:
647 raise
648 except Exception as e:
649 pw_logger.error("forgot_password_error", error=str(e))
650 logger.error(f"Forgot password error: {e}", exc_info=True)
651 return _GENERIC_RESPONSE # Always return generic to prevent leaking info
654@app.post("/api/auth/reset-password")
655async def reset_password(request: Request, body: ResetPasswordRequest):
656 """
657 Complete password reset: validate token and update the user's password.
658 No authentication header required — the token is the proof of identity.
659 """
660 client_ip = get_client_ip(request)
661 pw_logger = logger.bind(flow="reset_password", client_ip=client_ip)
663 user_id = auth_manager.verify_password_reset_token(body.token)
664 if not user_id:
665 raise HTTPException(status_code=400, detail="Invalid or expired reset link. Please request a new one.")
667 try:
668 auth_service_client.auth.admin.update_user_by_id(user_id, {"password": body.new_password})
669 pw_logger.info("reset_password_success", user_id=user_id)
670 return {"message": "Password updated successfully. You can now log in with your new password."}
671 except Exception as e:
672 pw_logger.error("reset_password_failed", user_id=user_id, error=str(e))
673 logger.error(f"Password reset update failed: {e}", exc_info=True)
674 raise HTTPException(status_code=500, detail="Failed to update password. Please try again.") from e
677@app.get("/api/auth/username-available/{username}")
678async def check_username_availability(username: str):
679 """
680 Check if a username is available.
682 Returns:
683 - available: boolean
684 - suggestions: list of alternative usernames if taken
685 """
686 import re
688 from auth import check_username_available
690 try:
691 # Validate username format
692 if not re.match(r"^[a-zA-Z0-9_]{3,50}$", username):
693 return {
694 "available": False,
695 "message": "Username must be 3-50 characters (letters, numbers, underscores only)",
696 }
698 # Check availability
699 available = await check_username_available(db_conn_holder_obj.client, username)
701 if available:
702 return {"available": True, "message": f"Username '{username}' is available!"}
703 else:
704 # Generate suggestions
705 import hashlib
707 hash_suffix = int(hashlib.md5(username.encode()).hexdigest(), 16) % 100
708 suggestions = [
709 f"{username}_1",
710 f"{username}_2",
711 f"{username}_{hash_suffix}",
712 ]
714 return {
715 "available": False,
716 "message": f"Username '{username}' is taken",
717 "suggestions": suggestions,
718 }
720 except Exception as e:
721 logger.error(f"Error checking username: {e}", exc_info=True)
722 raise HTTPException(status_code=500, detail="Error checking username availability") from e
725@app.post("/api/auth/logout")
726async def logout(current_user: dict[str, Any] = Depends(get_current_user_required)):
727 """User logout endpoint."""
728 try:
729 # Use auth_ops_client to avoid modifying the match_dao client
730 auth_ops_client.auth.sign_out()
731 return {"success": True, "message": "Logged out successfully"}
732 except Exception as e:
733 logger.error(f"Logout error: {e}", exc_info=True)
734 return {
735 "success": True, # Return success even on error since logout should be client-side primarily
736 "message": "Logged out successfully",
737 }
740class OAuthCallbackData(BaseModel):
741 """OAuth callback data from frontend."""
743 access_token: str
744 refresh_token: str | None = None
745 provider: str = "google"
746 invite_code: str | None = None # Optional - required for signup, not required for login
747 display_name: str | None = None # Optional - user's custom display name from signup form
750@app.post("/api/auth/oauth/callback")
751async def oauth_callback(callback_data: OAuthCallbackData, request: Request):
752 """
753 Handle OAuth callback from frontend.
755 After Supabase OAuth flow completes, the frontend sends the tokens here
756 to verify and get/create the user profile.
758 TWO FLOWS:
759 1. LOGIN (no invite_code): For returning users who already have an account
760 2. SIGNUP (with invite_code): For new users, invite determines role/team/club
761 """
762 from services.invite_service import InviteService
764 client_ip = get_client_ip(request)
765 is_login_flow = callback_data.invite_code is None
766 oauth_logger = logger.bind(
767 flow="auth_oauth_callback",
768 provider=callback_data.provider,
769 client_ip=client_ip,
770 is_login_flow=is_login_flow,
771 )
773 try:
774 # Verify the token by getting user info from Supabase FIRST
775 # This is needed for both login and signup flows
776 from supabase import create_client
778 temp_client = create_client(os.getenv("SUPABASE_URL", ""), os.getenv("SUPABASE_ANON_KEY", ""))
779 temp_client.auth.set_session(callback_data.access_token, callback_data.refresh_token or "")
780 user_response = temp_client.auth.get_user()
782 if not user_response or not user_response.user:
783 oauth_logger.warning("oauth_callback_failed", reason="invalid_token")
784 raise HTTPException(status_code=401, detail="Invalid OAuth token")
786 supabase_user = user_response.user
787 user_id = supabase_user.id
788 email = supabase_user.email
789 user_metadata = supabase_user.user_metadata or {}
791 oauth_logger = oauth_logger.bind(user_id=user_id, email=email)
793 # Check if user profile exists by email or user_id
794 email_profile_response = (
795 db_conn_holder_obj.client.table("user_profiles").select("*").eq("email", email).execute()
796 )
797 existing_by_email = email_profile_response.data[0] if email_profile_response.data else None
799 profile_response = db_conn_holder_obj.client.table("user_profiles").select("*").eq("id", user_id).execute()
800 existing_by_id = profile_response.data[0] if profile_response.data else None
802 # Check if it's a trigger stub (incomplete profile)
803 is_trigger_stub = (
804 existing_by_id
805 and existing_by_id.get("username") is None
806 and existing_by_id.get("auth_provider") == "password"
807 )
809 # Determine existing profile (prefer email match, then id match)
810 existing_profile = existing_by_email or (existing_by_id if not is_trigger_stub else None)
812 # ===== LOGIN FLOW (no invite code) =====
813 if is_login_flow:
814 if not existing_profile:
815 oauth_logger.warning("oauth_login_failed", reason="no_account")
816 # Sign out of Supabase to clean up the session
817 with contextlib.suppress(Exception):
818 await asyncio.to_thread(temp_client.auth.sign_out)
819 raise HTTPException(
820 status_code=400,
821 detail="No account found with this Google account. Please sign up with an invite code first.",
822 )
824 # Verify auth_provider is google (not password user trying to use OAuth)
825 if existing_profile.get("auth_provider") == "password":
826 oauth_logger.warning("oauth_login_failed", reason="wrong_auth_provider")
827 with contextlib.suppress(Exception):
828 await asyncio.to_thread(temp_client.auth.sign_out)
829 raise HTTPException(
830 status_code=400,
831 detail="This account was created with username/password. Please use the login form instead.",
832 )
834 oauth_logger.info("oauth_login_success", username=existing_profile.get("username"))
835 return {"message": "Login successful", "user": existing_profile}
837 # ===== SIGNUP FLOW (with invite code) =====
838 # Validate the invite code
839 invite_service = InviteService(db_conn_holder_obj.client)
840 invite_info = invite_service.validate_invite_code(callback_data.invite_code)
842 if not invite_info:
843 oauth_logger.warning("oauth_callback_failed", reason="invalid_invite_code")
844 raise HTTPException(status_code=400, detail="Invalid or expired invite code")
846 oauth_logger = oauth_logger.bind(invite_type=invite_info.get("invite_type"))
848 # Extract user info for signup
849 display_name = (
850 callback_data.display_name
851 or user_metadata.get("full_name")
852 or user_metadata.get("name")
853 or email.split("@")[0]
854 )
855 avatar_url = user_metadata.get("avatar_url") or user_metadata.get("picture")
857 # For signup, reject if account already exists
858 if existing_profile:
859 oauth_logger.warning(
860 "oauth_callback_failed",
861 reason="email_already_exists",
862 existing_username=existing_profile.get("username"),
863 )
864 username = existing_profile.get("username")
865 raise HTTPException(
866 status_code=400,
867 detail=f"An account with this email already exists (username: {username}). Please login instead.",
868 )
870 # Either new user OR trigger-created stub that needs to be completed
871 # Map invite_type to role
872 invite_type_to_role = {
873 "club_manager": "club_manager",
874 "club_fan": "club_fan",
875 "team_manager": "team-manager",
876 "team_player": "team-player",
877 "team_fan": "team-fan",
878 }
879 role = invite_type_to_role.get(invite_info.get("invite_type"), "team-fan")
881 # Generate a unique username from email
882 base_username = email.split("@")[0].replace(".", "_").replace("-", "_")[:40]
883 username = base_username
885 # Check if username exists and make unique if needed
886 from auth import check_username_available
888 counter = 1
889 while not await check_username_available(db_conn_holder_obj.client, username):
890 username = f"{base_username}_{counter}"
891 counter += 1
892 if counter > 100:
893 # Fallback to UUID suffix
894 import uuid
896 username = f"{base_username}_{str(uuid.uuid4())[:8]}"
897 break
899 # Profile data with invite-based role and team/club
900 profile_data = {
901 "username": username,
902 "display_name": display_name,
903 "email": email,
904 "role": role,
905 "team_id": invite_info.get("team_id"),
906 "club_id": invite_info.get("club_id"),
907 "profile_photo_url": avatar_url,
908 "auth_provider": callback_data.provider,
909 "updated_at": datetime.utcnow().isoformat(),
910 }
912 if is_trigger_stub:
913 # Update the stub profile created by Supabase trigger
914 oauth_logger.info("oauth_updating_trigger_stub", user_id=user_id)
915 db_conn_holder_obj.client.table("user_profiles").update(profile_data).eq("id", user_id).execute()
916 else:
917 # Create new profile (shouldn't happen if trigger exists, but handle it)
918 profile_data["id"] = user_id
919 profile_data["created_at"] = datetime.utcnow().isoformat()
920 db_conn_holder_obj.client.table("user_profiles").insert(profile_data).execute()
922 # Redeem the invite (marks as used) - do this for both update and insert
923 invite_service.redeem_invitation(callback_data.invite_code, user_id)
925 oauth_logger.info("oauth_signup_success", username=username, role=role)
927 return {
928 "success": True,
929 "user": {
930 "id": user_id,
931 "email": email,
932 "username": username,
933 "display_name": display_name,
934 "role": role,
935 "team_id": invite_info.get("team_id"),
936 "club_id": invite_info.get("club_id"),
937 "profile_photo_url": avatar_url,
938 "auth_provider": callback_data.provider,
939 "is_new_user": True,
940 },
941 }
943 except HTTPException:
944 raise
945 except Exception as e:
946 oauth_logger.error("oauth_callback_error", error=str(e))
947 logger.error(f"OAuth callback error: {e}", exc_info=True)
948 raise HTTPException(status_code=500, detail="OAuth authentication failed") from e
951@app.get("/api/auth/profile")
952async def get_profile(current_user: dict[str, Any] = Depends(get_current_user_required)):
953 """Get current user's profile."""
954 try:
955 profile = player_dao.get_user_profile_with_relationships(current_user["user_id"])
956 if not profile:
957 raise HTTPException(status_code=404, detail="Profile not found")
959 return {
960 "id": profile["id"],
961 "username": profile.get("username"),
962 "email": profile.get("email"),
963 "role": profile["role"],
964 "team_id": profile.get("team_id"),
965 "club_id": profile.get("club_id"),
966 "team": profile.get("team"),
967 "club": profile.get("club"),
968 "display_name": profile.get("display_name"),
969 "player_number": profile.get("player_number"),
970 "positions": profile.get("positions", []),
971 "created_at": profile.get("created_at"),
972 "updated_at": profile.get("updated_at"),
973 # Photo fields
974 "photo_1_url": profile.get("photo_1_url"),
975 "photo_2_url": profile.get("photo_2_url"),
976 "photo_3_url": profile.get("photo_3_url"),
977 "profile_photo_slot": profile.get("profile_photo_slot"),
978 "overlay_style": profile.get("overlay_style"),
979 "primary_color": profile.get("primary_color"),
980 "text_color": profile.get("text_color"),
981 "accent_color": profile.get("accent_color"),
982 # Social media handles
983 "instagram_handle": profile.get("instagram_handle"),
984 "snapchat_handle": profile.get("snapchat_handle"),
985 "tiktok_handle": profile.get("tiktok_handle"),
986 # Telegram/Discord handles
987 "telegram_handle": profile.get("telegram_handle"),
988 "discord_handle": profile.get("discord_handle"),
989 # Personal info
990 "first_name": profile.get("first_name"),
991 "last_name": profile.get("last_name"),
992 "hometown": profile.get("hometown"),
993 }
995 except Exception as e:
996 logger.error(f"Get profile error: {e}", exc_info=True)
997 raise HTTPException(status_code=500, detail="Failed to get profile") from e
1000@app.put("/api/auth/profile")
1001async def update_profile(profile_data: UserProfile, current_user: dict[str, Any] = Depends(get_current_user_required)):
1002 """Update current user's profile."""
1003 try:
1004 update_data = {}
1005 if profile_data.display_name is not None:
1006 update_data["display_name"] = profile_data.display_name
1008 # Email update with validation
1009 if profile_data.email is not None:
1010 # Basic email format validation
1011 import re
1013 email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
1014 if profile_data.email.strip() and not re.match(email_pattern, profile_data.email):
1015 raise HTTPException(status_code=400, detail="Invalid email format")
1017 # Check for email uniqueness (if not empty)
1018 if profile_data.email.strip():
1019 existing = player_dao.get_user_profile_by_email(
1020 profile_data.email, exclude_user_id=current_user["user_id"]
1021 )
1022 if existing:
1023 logger.warning(
1024 f"Email conflict: {profile_data.email!r} already on profile {existing.get('id')} "
1025 f"(requesting user: {current_user['user_id']})"
1026 )
1027 raise HTTPException(status_code=409, detail="Email already in use")
1029 update_data["email"] = profile_data.email if profile_data.email.strip() else None
1031 if profile_data.team_id is not None:
1032 update_data["team_id"] = profile_data.team_id
1033 if profile_data.player_number is not None:
1034 update_data["player_number"] = profile_data.player_number
1035 if profile_data.positions is not None:
1036 update_data["positions"] = profile_data.positions
1038 # Customization fields (overlay style and colors)
1039 if profile_data.overlay_style is not None:
1040 if profile_data.overlay_style not in ("badge", "jersey", "caption", "none"):
1041 raise HTTPException(status_code=400, detail="Invalid overlay_style")
1042 update_data["overlay_style"] = profile_data.overlay_style
1043 if profile_data.primary_color is not None:
1044 update_data["primary_color"] = profile_data.primary_color
1045 if profile_data.text_color is not None:
1046 update_data["text_color"] = profile_data.text_color
1047 if profile_data.accent_color is not None:
1048 update_data["accent_color"] = profile_data.accent_color
1050 # Telegram/Discord handles
1051 if profile_data.telegram_handle is not None:
1052 update_data["telegram_handle"] = profile_data.telegram_handle or None
1053 if profile_data.discord_handle is not None:
1054 update_data["discord_handle"] = profile_data.discord_handle or None
1056 # Only allow role updates by admins
1057 if profile_data.role is not None:
1058 if current_user.get("role") != "admin":
1059 raise HTTPException(status_code=403, detail="Only admins can change roles")
1060 update_data["role"] = profile_data.role
1062 if update_data:
1063 from datetime import datetime
1065 update_data["updated_at"] = datetime.now(UTC).isoformat()
1066 player_dao.update_user_profile(current_user["user_id"], update_data)
1068 return {"message": "Profile updated successfully"}
1070 except HTTPException:
1071 raise
1072 except Exception as e:
1073 logger.error(f"Update profile error: {e}", exc_info=True)
1074 raise HTTPException(status_code=500, detail="Failed to update profile") from e
1077# =============================================================================
1078# PLAYER PROFILE PHOTO ENDPOINTS
1079# =============================================================================
1081MAX_PHOTO_SIZE = 500 * 1024 # 500KB
1082ALLOWED_PHOTO_TYPES = ["image/jpeg", "image/png", "image/webp"]
1085class StorageHelper:
1086 """Helper for direct Supabase Storage API calls (bypasses RLS issues with client)."""
1088 def __init__(self):
1089 self.url = os.getenv("SUPABASE_URL")
1090 self.service_key = os.getenv("SUPABASE_SERVICE_KEY")
1092 def upload(self, bucket: str, file_path: str, content: bytes, content_type: str) -> dict:
1093 """Upload a file to storage using direct HTTP API."""
1094 headers = {
1095 "apikey": self.service_key,
1096 "Authorization": f"Bearer {self.service_key}",
1097 "Content-Type": content_type,
1098 "x-upsert": "true",
1099 }
1100 response = httpx.post(
1101 f"{self.url}/storage/v1/object/{bucket}/{file_path}",
1102 content=content,
1103 headers=headers,
1104 timeout=30.0,
1105 )
1106 if response.status_code not in (200, 201):
1107 raise Exception(f"Storage upload failed: {response.text}")
1108 return response.json()
1110 def delete(self, bucket: str, file_paths: list[str]) -> dict:
1111 """Delete files from storage using direct HTTP API."""
1112 headers = {
1113 "apikey": self.service_key,
1114 "Authorization": f"Bearer {self.service_key}",
1115 "Content-Type": "application/json",
1116 }
1117 response = httpx.delete(
1118 f"{self.url}/storage/v1/object/{bucket}",
1119 json={"prefixes": file_paths},
1120 headers=headers,
1121 timeout=30.0,
1122 )
1123 if response.status_code not in (200, 204):
1124 # Ignore 404s - file might already be gone
1125 if response.status_code != 404:
1126 raise Exception(f"Storage delete failed: {response.text}")
1127 return {"deleted": file_paths}
1129 def get_public_url(self, bucket: str, file_path: str) -> str:
1130 """Get the public URL for a file."""
1131 return f"{self.url}/storage/v1/object/public/{bucket}/{file_path}"
1134storage_helper = StorageHelper()
1137def require_player_role(current_user: dict[str, Any] = Depends(get_current_user_required)):
1138 """Dependency that requires team-player role."""
1139 if current_user.get("role") != "team-player":
1140 raise HTTPException(status_code=403, detail="This feature is only available for players")
1141 return current_user
1144@app.post("/api/auth/profile/photo/{slot}")
1145async def upload_player_photo(
1146 slot: int,
1147 file: UploadFile = File(...),
1148 current_user: dict[str, Any] = Depends(require_player_role),
1149):
1150 """Upload a profile photo to a slot (1, 2, or 3). Players only.
1152 Uploads the image to Supabase Storage and updates the user profile.
1153 Accepted formats: PNG, JPG/JPEG, WebP. Max size: 500KB.
1154 """
1155 # Validate slot
1156 if slot not in (1, 2, 3):
1157 raise HTTPException(status_code=400, detail="Slot must be 1, 2, or 3")
1159 # Validate file type
1160 if file.content_type not in ALLOWED_PHOTO_TYPES:
1161 raise HTTPException(
1162 status_code=400,
1163 detail=f"Invalid file type. Allowed: JPG, PNG, WebP. Got: {file.content_type}",
1164 )
1166 # Read file content
1167 content = await file.read()
1169 # Validate file size
1170 if len(content) > MAX_PHOTO_SIZE:
1171 raise HTTPException(
1172 status_code=400,
1173 detail=f"File too large. Maximum size is 500KB. Got: {len(content) / 1024:.1f}KB",
1174 )
1176 # Determine file extension
1177 ext_map = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
1178 ext = ext_map.get(file.content_type, "jpg")
1179 user_id = current_user["user_id"]
1180 file_path = f"{user_id}/photo_{slot}.{ext}"
1182 try:
1183 # Upload to player-photos bucket using direct HTTP API
1184 storage_helper.upload("player-photos", file_path, content, file.content_type)
1186 # Get public URL
1187 public_url = storage_helper.get_public_url("player-photos", file_path)
1189 # Update user profile with the photo URL
1190 from datetime import datetime
1192 photo_column = f"photo_{slot}_url"
1193 update_data = {photo_column: public_url, "updated_at": datetime.now(UTC).isoformat()}
1195 # If no profile photo is set, set this as the profile photo
1196 profile = player_dao.get_user_profile_with_relationships(user_id)
1197 if not profile.get("profile_photo_slot"):
1198 update_data["profile_photo_slot"] = slot
1200 player_dao.update_user_profile(user_id, update_data)
1202 # Return updated profile
1203 updated_profile = player_dao.get_user_profile_with_relationships(user_id)
1204 return {
1205 "message": f"Photo uploaded to slot {slot}",
1206 "photo_url": public_url,
1207 "profile": updated_profile,
1208 }
1210 except Exception as e:
1211 logger.error(f"Error uploading player photo: {e!s}", exc_info=True)
1212 raise HTTPException(status_code=500, detail=str(e)) from e
1215@app.delete("/api/auth/profile/photo/{slot}")
1216async def delete_player_photo(slot: int, current_user: dict[str, Any] = Depends(require_player_role)):
1217 """Delete a profile photo from a slot (1, 2, or 3). Players only.
1219 If this was the profile photo, auto-selects the next available photo.
1220 """
1221 # Validate slot
1222 if slot not in (1, 2, 3):
1223 raise HTTPException(status_code=400, detail="Slot must be 1, 2, or 3")
1225 user_id = current_user["user_id"]
1227 try:
1228 # Get current profile
1229 profile = player_dao.get_user_profile_with_relationships(user_id)
1230 photo_column = f"photo_{slot}_url"
1231 current_url = profile.get(photo_column)
1233 if not current_url:
1234 raise HTTPException(status_code=404, detail=f"No photo in slot {slot}")
1236 # Delete from storage - try all extensions
1237 file_path = f"{user_id}/photo_{slot}"
1238 for ext in ["jpg", "png", "webp"]:
1239 try:
1240 storage_helper.delete("player-photos", [f"{file_path}.{ext}"])
1241 except Exception:
1242 pass # File might not exist with this extension
1244 # Update profile to remove URL
1245 from datetime import datetime
1247 update_data = {photo_column: None, "updated_at": datetime.now(UTC).isoformat()}
1249 # If this was the profile photo, find next available
1250 if profile.get("profile_photo_slot") == slot:
1251 new_profile_slot = None
1252 for check_slot in [1, 2, 3]:
1253 if check_slot != slot and profile.get(f"photo_{check_slot}_url"):
1254 new_profile_slot = check_slot
1255 break
1256 update_data["profile_photo_slot"] = new_profile_slot
1258 player_dao.update_user_profile(user_id, update_data)
1260 # Return updated profile
1261 updated_profile = player_dao.get_user_profile_with_relationships(user_id)
1262 return {"message": f"Photo deleted from slot {slot}", "profile": updated_profile}
1264 except HTTPException:
1265 raise
1266 except Exception as e:
1267 logger.error(f"Error deleting player photo: {e!s}", exc_info=True)
1268 raise HTTPException(status_code=500, detail=str(e)) from e
1271@app.put("/api/auth/profile/photo/profile-slot")
1272async def set_profile_photo_slot(
1273 slot_data: ProfilePhotoSlot, current_user: dict[str, Any] = Depends(require_player_role)
1274):
1275 """Set which photo slot is the profile picture. Players only.
1277 The specified slot must have a photo uploaded.
1278 """
1279 slot = slot_data.slot
1280 user_id = current_user["user_id"]
1282 try:
1283 # Get current profile
1284 profile = player_dao.get_user_profile_with_relationships(user_id)
1285 photo_url = profile.get(f"photo_{slot}_url")
1287 if not photo_url:
1288 raise HTTPException(status_code=400, detail=f"No photo in slot {slot}. Upload a photo first.")
1290 # Update profile photo slot
1291 from datetime import datetime
1293 player_dao.update_user_profile(
1294 user_id, {"profile_photo_slot": slot, "updated_at": datetime.now(UTC).isoformat()}
1295 )
1297 # Return updated profile
1298 updated_profile = player_dao.get_user_profile_with_relationships(user_id)
1299 return {"message": f"Profile photo set to slot {slot}", "profile": updated_profile}
1301 except HTTPException:
1302 raise
1303 except Exception as e:
1304 logger.error(f"Error setting profile photo slot: {e!s}", exc_info=True)
1305 raise HTTPException(status_code=500, detail=str(e)) from e
1308@app.put("/api/auth/profile/customization")
1309async def update_player_customization(
1310 customization: PlayerCustomization, current_user: dict[str, Any] = Depends(require_player_role)
1311):
1312 """Update player profile customization (colors, style). Players only.
1314 This is a convenience endpoint for updating multiple customization
1315 fields at once. All fields are optional.
1316 """
1317 user_id = current_user["user_id"]
1319 try:
1320 from datetime import datetime
1322 update_data = {"updated_at": datetime.now(UTC).isoformat()}
1324 # Personal info
1325 if customization.first_name is not None:
1326 update_data["first_name"] = customization.first_name
1327 if customization.last_name is not None:
1328 update_data["last_name"] = customization.last_name
1329 if customization.hometown is not None:
1330 update_data["hometown"] = customization.hometown
1331 # Visual customization
1332 if customization.overlay_style is not None:
1333 update_data["overlay_style"] = customization.overlay_style
1334 if customization.primary_color is not None:
1335 update_data["primary_color"] = customization.primary_color
1336 if customization.text_color is not None:
1337 update_data["text_color"] = customization.text_color
1338 if customization.accent_color is not None:
1339 update_data["accent_color"] = customization.accent_color
1340 if customization.player_number is not None:
1341 update_data["player_number"] = customization.player_number
1342 if customization.positions is not None:
1343 update_data["positions"] = customization.positions
1344 # Social media handles
1345 if customization.instagram_handle is not None:
1346 update_data["instagram_handle"] = customization.instagram_handle
1347 if customization.snapchat_handle is not None:
1348 update_data["snapchat_handle"] = customization.snapchat_handle
1349 if customization.tiktok_handle is not None:
1350 update_data["tiktok_handle"] = customization.tiktok_handle
1351 # Telegram/Discord handles
1352 if customization.telegram_handle is not None:
1353 update_data["telegram_handle"] = customization.telegram_handle
1354 if customization.discord_handle is not None:
1355 update_data["discord_handle"] = customization.discord_handle
1357 if len(update_data) > 1: # More than just updated_at
1358 player_dao.update_user_profile(user_id, update_data)
1360 # Return updated profile
1361 updated_profile = player_dao.get_user_profile_with_relationships(user_id)
1362 return {"message": "Customization updated", "profile": updated_profile}
1364 except Exception as e:
1365 logger.error(f"Error updating player customization: {e!s}", exc_info=True)
1366 raise HTTPException(status_code=500, detail=str(e)) from e
1369# === Player Team History Endpoints ===
1372@app.get("/api/auth/profile/history")
1373async def get_player_history(current_user: dict[str, Any] = Depends(get_current_user_required)):
1374 """Get player's team history across all seasons.
1376 Returns history entries ordered by season (most recent first),
1377 with full team, season, age_group, league, and division details.
1378 """
1379 user_id = current_user["user_id"]
1381 try:
1382 history = player_dao.get_player_team_history(user_id)
1383 return {"success": True, "history": history}
1384 except Exception as e:
1385 logger.error(f"Error getting player history: {e!s}", exc_info=True)
1386 raise HTTPException(status_code=500, detail=str(e)) from e
1389@app.get("/api/auth/profile/history/current")
1390async def get_current_team_assignment(
1391 current_user: dict[str, Any] = Depends(get_current_user_required),
1392):
1393 """Get player's current team assignment (is_current=true).
1395 Returns the current history entry with full related data,
1396 or null if no current assignment exists.
1397 """
1398 user_id = current_user["user_id"]
1400 try:
1401 current = player_dao.get_current_player_team_assignment(user_id)
1402 return {"success": True, "current": current}
1403 except Exception as e:
1404 logger.error(f"Error getting current team assignment: {e!s}", exc_info=True)
1405 raise HTTPException(status_code=500, detail=str(e)) from e
1408@app.get("/api/auth/profile/teams/current")
1409async def get_all_current_teams(current_user: dict[str, Any] = Depends(get_current_user_required)):
1410 """Get ALL current team assignments for a player.
1412 Returns all team assignments where is_current=true.
1413 This supports players being on multiple teams simultaneously
1414 (e.g., for futsal/soccer leagues).
1416 Returns:
1417 List of current teams with club info for team selector UI.
1418 """
1419 user_id = current_user["user_id"]
1421 try:
1422 teams = player_dao.get_all_current_player_teams(user_id)
1423 return {"success": True, "teams": teams}
1424 except Exception as e:
1425 logger.error(f"Error getting all current teams: {e!s}", exc_info=True)
1426 raise HTTPException(status_code=500, detail=str(e)) from e
1429@app.post("/api/auth/profile/history")
1430async def create_player_history(
1431 history_data: PlayerHistoryCreate,
1432 current_user: dict[str, Any] = Depends(get_current_user_required),
1433):
1434 """Create a new player team history entry.
1436 Players can add their own history entries for different seasons.
1437 The entry will automatically capture age_group, league, and division
1438 from the team's current configuration.
1439 """
1440 user_id = current_user["user_id"]
1442 try:
1443 entry = player_dao.create_player_history_entry(
1444 player_id=user_id,
1445 team_id=history_data.team_id,
1446 season_id=history_data.season_id,
1447 jersey_number=history_data.jersey_number,
1448 positions=history_data.positions,
1449 notes=history_data.notes,
1450 is_current=history_data.is_current,
1451 )
1453 if entry:
1454 return {"success": True, "entry": entry}
1455 else:
1456 raise HTTPException(status_code=500, detail="Failed to create history entry")
1458 except Exception as e:
1459 logger.error(f"Error creating player history entry: {e!s}", exc_info=True)
1460 raise HTTPException(status_code=500, detail=str(e)) from e
1463@app.put("/api/auth/profile/history/{history_id}")
1464async def update_player_history(
1465 history_id: int,
1466 history_data: PlayerHistoryUpdate,
1467 current_user: dict[str, Any] = Depends(get_current_user_required),
1468):
1469 """Update a player team history entry.
1471 Players can only update their own history entries.
1472 """
1473 user_id = current_user["user_id"]
1475 try:
1476 # Verify this entry belongs to the user
1477 existing = player_dao.get_player_history_entry_by_id(history_id)
1478 if not existing:
1479 raise HTTPException(status_code=404, detail="History entry not found")
1480 if existing.get("player_id") != user_id:
1481 raise HTTPException(status_code=403, detail="Not authorized to update this entry")
1483 entry = player_dao.update_player_history_entry(
1484 history_id=history_id,
1485 jersey_number=history_data.jersey_number,
1486 positions=history_data.positions,
1487 notes=history_data.notes,
1488 is_current=history_data.is_current,
1489 )
1491 if entry:
1492 return {"success": True, "entry": entry}
1493 else:
1494 raise HTTPException(status_code=500, detail="Failed to update history entry")
1496 except HTTPException:
1497 raise
1498 except Exception as e:
1499 logger.error(f"Error updating player history entry: {e!s}", exc_info=True)
1500 raise HTTPException(status_code=500, detail=str(e)) from e
1503@app.delete("/api/auth/profile/history/{history_id}")
1504async def delete_player_history(history_id: int, current_user: dict[str, Any] = Depends(get_current_user_required)):
1505 """Delete a player team history entry.
1507 Players can only delete their own history entries.
1508 """
1509 user_id = current_user["user_id"]
1511 try:
1512 # Verify this entry belongs to the user
1513 existing = player_dao.get_player_history_entry_by_id(history_id)
1514 if not existing:
1515 raise HTTPException(status_code=404, detail="History entry not found")
1516 if existing.get("player_id") != user_id:
1517 raise HTTPException(status_code=403, detail="Not authorized to delete this entry")
1519 success = player_dao.delete_player_history_entry(history_id)
1521 if success:
1522 return {"success": True, "message": "History entry deleted"}
1523 else:
1524 raise HTTPException(status_code=500, detail="Failed to delete history entry")
1526 except HTTPException:
1527 raise
1528 except Exception as e:
1529 logger.error(f"Error deleting player history entry: {e!s}", exc_info=True)
1530 raise HTTPException(status_code=500, detail=str(e)) from e
1533# === Admin Player Management Endpoints ===
1536@app.get("/api/admin/players")
1537async def get_admin_players(
1538 search: str | None = None,
1539 club_id: int | None = None,
1540 team_id: int | None = None,
1541 limit: int = Query(default=50, le=100),
1542 offset: int = Query(default=0, ge=0),
1543 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
1544):
1545 """Get all players with team assignments for admin management.
1547 Supports filtering by search text, club, and team.
1548 Returns paginated results with current team assignments.
1549 """
1550 try:
1551 result = player_dao.get_all_players_admin(
1552 search=search, club_id=club_id, team_id=team_id, limit=limit, offset=offset
1553 )
1554 return result
1556 except Exception as e:
1557 logger.error(f"Error getting admin players: {e!s}", exc_info=True)
1558 raise HTTPException(status_code=500, detail=str(e)) from e
1561@app.put("/api/admin/players/{player_id}")
1562async def update_admin_player(
1563 player_id: str,
1564 data: AdminPlayerUpdate,
1565 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
1566):
1567 """Update a player's profile info (admin/manager operation).
1569 Allows updating display_name, player_number, and positions.
1570 """
1571 try:
1572 result = player_dao.update_player_admin(
1573 player_id=player_id,
1574 display_name=data.display_name,
1575 player_number=data.player_number,
1576 positions=data.positions,
1577 )
1579 if result:
1580 return {"success": True, "player": result}
1581 else:
1582 raise HTTPException(status_code=404, detail="Player not found")
1584 except HTTPException:
1585 raise
1586 except Exception as e:
1587 logger.error(f"Error updating admin player: {e!s}", exc_info=True)
1588 raise HTTPException(status_code=500, detail=str(e)) from e
1591@app.post("/api/admin/players/{player_id}/teams")
1592async def add_admin_player_team(
1593 player_id: str,
1594 data: AdminPlayerTeamAssignment,
1595 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
1596):
1597 """Assign a player to a team (admin/manager operation).
1599 Creates a player_team_history entry and a corresponding roster (players table)
1600 entry so the player appears in both the Admin/Players and Admin/Teams/Roster views.
1601 """
1602 try:
1603 # Use the existing create_player_history_entry method
1604 entry = player_dao.create_player_history_entry(
1605 player_id=player_id,
1606 team_id=data.team_id,
1607 season_id=data.season_id,
1608 jersey_number=data.jersey_number,
1609 is_current=data.is_current,
1610 )
1612 if not entry:
1613 raise HTTPException(status_code=500, detail="Failed to create team assignment")
1615 # Also create a roster (players table) entry if one doesn't already exist
1616 # for this user/team/season, so the player shows up in Admin/Teams/Roster.
1617 if data.jersey_number:
1618 existing_roster = roster_dao.get_player_by_user_profile_id(
1619 player_id, team_id=data.team_id, season_id=data.season_id
1620 )
1621 if not existing_roster:
1622 # Get player profile for name info
1623 profile = player_dao.get_user_profile_with_relationships(player_id)
1624 display_name = profile.get("display_name", "") if profile else ""
1625 name_parts = display_name.split(" ", 1) if display_name else [""]
1626 first_name = name_parts[0] if name_parts else None
1627 last_name = name_parts[1] if len(name_parts) > 1 else None
1629 roster_entry = roster_dao.create_player(
1630 team_id=data.team_id,
1631 season_id=data.season_id,
1632 jersey_number=data.jersey_number,
1633 first_name=first_name,
1634 last_name=last_name,
1635 created_by=current_user.get("user_id"),
1636 )
1637 # Link the roster entry to the user profile
1638 if roster_entry:
1639 roster_dao.link_user_to_player(roster_entry["id"], player_id)
1641 # Backfill club_id on user_profiles if not already set.
1642 # Roster-managed players get player_team_history entries but their
1643 # user_profiles.club_id was never populated, causing them to see
1644 # unrelated clubs/leagues in the UI.
1645 team_info = team_dao.get_team_by_id(data.team_id)
1646 if team_info and team_info.get("club_id"):
1647 profile = player_dao.get_user_profile_with_relationships(player_id)
1648 if profile and not profile.get("club_id"):
1649 player_dao.update_user_profile(player_id, {"club_id": team_info["club_id"]})
1651 return {"success": True, "assignment": entry}
1653 except HTTPException:
1654 raise
1655 except Exception as e:
1656 logger.error(f"Error adding admin player team: {e!s}", exc_info=True)
1657 raise HTTPException(status_code=500, detail=str(e)) from e
1660@app.put("/api/admin/players/teams/{history_id}/end")
1661async def end_admin_player_team(
1662 history_id: int,
1663 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
1664):
1665 """End a player's team assignment (admin/manager operation).
1667 Sets is_current=false on the player_team_history entry.
1668 """
1669 try:
1670 result = player_dao.end_player_team_assignment(history_id=history_id)
1672 if result:
1673 return {"success": True, "assignment": result}
1674 else:
1675 raise HTTPException(status_code=404, detail="Assignment not found")
1677 except HTTPException:
1678 raise
1679 except Exception as e:
1680 logger.error(f"Error ending admin player team: {e!s}", exc_info=True)
1681 raise HTTPException(status_code=500, detail=str(e)) from e
1684@app.get("/api/auth/users")
1685async def get_users(current_user: dict[str, Any] = Depends(require_admin)):
1686 """Get all users (admin only)."""
1687 try:
1688 return player_dao.get_all_user_profiles()
1690 except Exception as e:
1691 logger.error(f"Get users error: {e}", exc_info=True)
1692 raise HTTPException(status_code=500, detail="Failed to get users") from e
1695@app.post("/api/auth/refresh")
1696async def refresh_token(request: Request, refresh_data: RefreshTokenRequest):
1697 """Refresh JWT token using refresh token."""
1698 refresh_logger = logger.bind(flow="auth_refresh", client_ip=get_client_ip(request))
1699 try:
1700 # Use auth_ops_client to avoid modifying the match_dao client
1701 response = auth_ops_client.auth.refresh_session(refresh_data.refresh_token)
1703 if response.session:
1704 refresh_logger.info("auth_refresh_success")
1705 return {
1706 "success": True,
1707 "session": {
1708 "access_token": response.session.access_token,
1709 "refresh_token": response.session.refresh_token,
1710 "expires_at": response.session.expires_at,
1711 "token_type": "bearer",
1712 },
1713 }
1714 else:
1715 refresh_logger.warning("auth_refresh_failed", reason="no_session")
1716 raise HTTPException(status_code=401, detail="Failed to refresh token")
1718 except Exception as e:
1719 refresh_logger.error("auth_refresh_error", error=str(e))
1720 logger.error(f"Token refresh error: {e}", exc_info=True)
1721 raise HTTPException(status_code=401, detail="Invalid refresh token") from e
1724@app.get("/api/auth/me")
1725async def get_current_user_info(current_user: dict = Depends(get_current_user_required)):
1726 """Get current user info for frontend auth state."""
1727 try:
1728 # Get fresh profile data with team AND club info
1729 profile = player_dao.get_user_profile_with_relationships(current_user["user_id"]) or {}
1731 # For team-players, include current team assignments from player_team_history.
1732 # This is the source of truth for team membership (user_profiles.team_id is only
1733 # set during invite-code signup, not when added via roster manager).
1734 current_teams = []
1735 role = profile.get("role", "team-fan")
1736 if role == "team-player":
1737 teams_data = player_dao.get_all_current_player_teams(current_user["user_id"])
1738 current_teams = [
1739 {"team_id": t.get("team_id"), "team": t.get("team"), "season": t.get("season")} for t in teams_data
1740 ]
1742 return {
1743 "success": True,
1744 "user": {
1745 "id": current_user["user_id"],
1746 "email": current_user["email"],
1747 "profile": {
1748 "username": profile.get("username"),
1749 "email": profile.get("email"),
1750 "role": role,
1751 "team_id": profile.get("team_id"),
1752 "club_id": profile.get("club_id"),
1753 "display_name": profile.get("display_name"),
1754 "name": profile.get("name"),
1755 "player_number": profile.get("player_number"),
1756 "positions": profile.get("positions"),
1757 "team": profile.get("team"),
1758 "club": profile.get("club"),
1759 "current_teams": current_teams,
1760 "created_at": profile.get("created_at"),
1761 "updated_at": profile.get("updated_at"),
1762 # Photo fields
1763 "photo_1_url": profile.get("photo_1_url"),
1764 "photo_2_url": profile.get("photo_2_url"),
1765 "photo_3_url": profile.get("photo_3_url"),
1766 "profile_photo_slot": profile.get("profile_photo_slot"),
1767 "overlay_style": profile.get("overlay_style"),
1768 "primary_color": profile.get("primary_color"),
1769 "text_color": profile.get("text_color"),
1770 "accent_color": profile.get("accent_color"),
1771 # Social media handles
1772 "instagram_handle": profile.get("instagram_handle"),
1773 "snapchat_handle": profile.get("snapchat_handle"),
1774 "tiktok_handle": profile.get("tiktok_handle"),
1775 # Telegram/Discord handles
1776 "telegram_handle": profile.get("telegram_handle"),
1777 "discord_handle": profile.get("discord_handle"),
1778 # Personal info
1779 "first_name": profile.get("first_name"),
1780 "last_name": profile.get("last_name"),
1781 "hometown": profile.get("hometown"),
1782 },
1783 },
1784 }
1786 except Exception as e:
1787 logger.error(f"Get user info error: {e}", exc_info=True)
1788 raise HTTPException(status_code=500, detail="Failed to get user info") from e
1791@app.get("/api/positions")
1792async def get_positions(current_user: dict[str, Any] = Depends(get_current_user_required)):
1793 """Get all available player positions."""
1794 return PLAYER_POSITIONS
1797@app.put("/api/auth/users/role")
1798async def update_user_role(role_data: RoleUpdate, current_user: dict[str, Any] = Depends(require_admin)):
1799 """Update user role (admin only)."""
1800 try:
1801 from datetime import datetime
1803 update_data = {"role": role_data.role, "updated_at": datetime.now(UTC).isoformat()}
1805 if role_data.team_id:
1806 update_data["team_id"] = role_data.team_id
1808 player_dao.update_user_profile(role_data.user_id, update_data)
1810 return {"message": "User role updated successfully"}
1812 except Exception as e:
1813 logger.error(f"Update user role error: {e}", exc_info=True)
1814 raise HTTPException(status_code=500, detail="Failed to update user role") from e
1817@app.put("/api/auth/users/profile")
1818async def update_user_profile(
1819 profile_data: UserProfileUpdate,
1820 current_user: dict[str, Any] = Depends(require_admin),
1821):
1822 """Update user profile information (admin only)."""
1823 import re
1825 try:
1826 update_data = {}
1828 # Validate and handle username update
1829 if profile_data.username is not None:
1830 username = profile_data.username.strip()
1832 # Validate username format
1833 if username and not re.match(r"^[a-zA-Z0-9_]{3,50}$", username):
1834 raise HTTPException(
1835 status_code=400,
1836 detail="Username must be 3-50 characters (letters, numbers, underscores only)",
1837 )
1839 # Check if username is already taken by another user
1840 if username:
1841 existing = player_dao.get_user_profile_by_username(username, exclude_user_id=profile_data.user_id)
1842 if existing:
1843 raise HTTPException(status_code=409, detail=f"Username '{username}' is already taken")
1845 update_data["username"] = username.lower()
1847 # Update auth.users email to internal format
1848 # Use auth_service_client for admin operations (requires service key)
1849 internal_email = f"{username.lower()}@missingtable.local"
1850 try:
1851 auth_service_client.auth.admin.update_user_by_id(
1852 profile_data.user_id,
1853 {
1854 "email": internal_email,
1855 "user_metadata": {
1856 "username": username.lower(),
1857 "is_username_auth": True,
1858 },
1859 },
1860 )
1861 except Exception as e:
1862 logger.warning(f"Failed to update auth.users email: {e}")
1864 # Handle display name update
1865 if profile_data.display_name is not None:
1866 update_data["display_name"] = profile_data.display_name.strip()
1868 # Handle email update (real email for notifications)
1869 if profile_data.email is not None:
1870 email = profile_data.email.strip()
1871 if email:
1872 # Basic email validation
1873 if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email):
1874 raise HTTPException(status_code=400, detail="Invalid email format")
1875 update_data["email"] = email
1877 if not update_data:
1878 raise HTTPException(status_code=400, detail="No fields to update")
1880 # Update user_profiles
1881 player_dao.update_user_profile(profile_data.user_id, update_data)
1883 return {"message": "User profile updated successfully"}
1885 except HTTPException:
1886 raise
1887 except Exception as e:
1888 logger.error(f"Update user profile error: {e}", exc_info=True)
1889 raise HTTPException(status_code=500, detail=f"Failed to update user profile: {e!s}") from e
1892@app.delete("/api/auth/users/{user_id}")
1893async def delete_user(
1894 user_id: str,
1895 current_user: dict[str, Any] = Depends(require_admin),
1896):
1897 """
1898 Delete a user (admin only).
1900 Deletes from both auth.users and user_profiles table.
1901 user_profiles must be deleted explicitly (no FK cascade exists).
1902 """
1903 try:
1904 profile = auth_service_client.table("user_profiles").select("*").eq("id", user_id).execute()
1905 if not profile.data:
1906 raise HTTPException(status_code=404, detail="User not found")
1908 # Delete from auth.users
1909 try:
1910 auth_service_client.auth.admin.delete_user(user_id)
1911 except Exception as auth_err:
1912 # Auth user may already be gone (orphaned profile)
1913 logger.warning(f"Auth user {user_id} not in auth.users, continuing: {auth_err}")
1915 # Explicitly delete from user_profiles (no FK cascade exists)
1916 auth_service_client.table("user_profiles").delete().eq("id", user_id).execute()
1918 # Invalidate cached user list so subsequent queries reflect the deletion
1919 from dao.base_dao import clear_cache
1921 clear_cache("mt:dao:players:*")
1923 logger.info(f"User {user_id} deleted by admin {current_user.get('user_id')}")
1924 return {"message": "User deleted successfully"}
1926 except HTTPException:
1927 raise
1928 except Exception as e:
1929 logger.error(f"Delete user error: {e}", exc_info=True)
1930 raise HTTPException(status_code=500, detail=f"Failed to delete user: {e!s}") from e
1933# === CSRF Token Endpoint ===
1936@app.get("/api/csrf-token")
1937async def get_csrf_token_endpoint(request: Request, response: Response):
1938 """Get CSRF token for the session."""
1939 return await provide_csrf_token(request, response)
1942# === Reference Data Endpoints ===
1945@app.get("/api/age-groups")
1946async def get_age_groups(current_user: dict[str, Any] = Depends(get_current_user_required)):
1947 """Get all age groups."""
1948 try:
1949 logger.info(f"age-groups endpoint - current_user: {current_user}")
1950 age_groups = season_dao.get_all_age_groups()
1951 logger.info(f"age-groups endpoint - returning {len(age_groups)} groups")
1952 return age_groups
1953 except Exception as e:
1954 logger.error(f"Error retrieving age groups: {e!s}", exc_info=True)
1955 raise HTTPException(
1956 status_code=503, detail="Database connection failed. Please check Supabase connection."
1957 ) from e
1960@app.get("/api/seasons")
1961async def get_seasons(current_user: dict[str, Any] = Depends(get_current_user_required)):
1962 """Get all seasons."""
1963 try:
1964 seasons = season_dao.get_all_seasons()
1965 return seasons
1966 except Exception as e:
1967 logger.error(f"Error retrieving seasons: {e!s}", exc_info=True)
1968 raise HTTPException(
1969 status_code=503, detail="Database connection failed. Please check Supabase connection."
1970 ) from e
1973@app.get("/api/current-season")
1974async def get_current_season(current_user: dict[str, Any] = Depends(get_current_user_required)):
1975 """Get the current active season."""
1976 try:
1977 current_season = season_dao.get_current_season()
1978 if not current_season:
1979 # Default to 2024-2025 season if no current season found
1980 seasons = season_dao.get_all_seasons()
1981 current_season = next((s for s in seasons if s["name"] == "2024-2025"), seasons[0] if seasons else None)
1982 return current_season
1983 except Exception as e:
1984 logger.error(f"Error retrieving current season: {e!s}", exc_info=True)
1985 raise HTTPException(status_code=500, detail=str(e)) from e
1988@app.get("/api/active-seasons")
1989async def get_active_seasons(current_user: dict[str, Any] = Depends(get_current_user_required)):
1990 """Get active seasons (current and future) for scheduling new matches."""
1991 try:
1992 active_seasons = season_dao.get_active_seasons()
1993 return active_seasons
1994 except Exception as e:
1995 logger.error(f"Error retrieving active seasons: {e!s}", exc_info=True)
1996 raise HTTPException(status_code=500, detail=str(e)) from e
1999@app.get("/api/match-types")
2000async def get_match_types(current_user: dict[str, Any] = Depends(get_current_user_required)):
2001 """Get all match types."""
2002 try:
2003 match_types = match_type_dao.get_all_match_types()
2004 return match_types
2005 except Exception as e:
2006 logger.error(f"Error retrieving match types: {e!s}", exc_info=True)
2007 raise HTTPException(status_code=500, detail=str(e)) from e
2010@app.get("/api/divisions")
2011async def get_divisions(
2012 current_user: dict[str, Any] = Depends(get_current_user_required), league_id: int | None = None
2013):
2014 """Get all divisions, optionally filtered by league."""
2015 try:
2016 divisions = league_dao.get_divisions_by_league(league_id) if league_id else league_dao.get_all_divisions()
2017 return divisions
2018 except Exception as e:
2019 logger.error(f"Error retrieving divisions: {e!s}", exc_info=True)
2020 raise HTTPException(status_code=500, detail=str(e)) from e
2023# === Enhanced Team Endpoints ===
2026@app.get("/api/teams")
2027async def get_teams(
2028 current_user: dict[str, Any] = Depends(get_current_user_required),
2029 match_type_id: int | None = None,
2030 age_group_id: int | None = None,
2031 division_id: int | None = None,
2032 include_parent: bool = False,
2033 include_game_count: bool = False,
2034 club_id: int | None = None,
2035 for_match_edit: bool = False,
2036):
2037 """
2038 Get teams, optionally filtered by match type, age group, division, or club.
2040 Args:
2041 match_type_id: Filter by match type
2042 age_group_id: Filter by age group (requires match_type_id)
2043 division_id: Filter by division (e.g., Bracket A for Futsal, Northeast for Homegrown)
2044 include_parent: If true, include parent club information
2045 include_game_count: If true, include count of games for each team (performance optimized)
2046 club_id: Filter to only teams belonging to this parent club
2047 for_match_edit: If true, return all teams (for match editing dropdowns)
2049 Note: Club managers automatically see only their club's teams unless for_match_edit=true.
2050 """
2051 try:
2052 # Club managers should only see their club's teams (unless editing matches)
2053 user_role = current_user.get("role")
2054 user_club_id = current_user.get("club_id")
2056 if user_role == "club_manager" and user_club_id and not for_match_edit:
2057 # Override any club_id filter - club managers only see their own club
2058 club_id = user_club_id
2060 # Get teams based on filters
2061 if match_type_id and age_group_id:
2062 teams = team_dao.get_teams_by_match_type_and_age_group(match_type_id, age_group_id, division_id=division_id)
2063 elif club_id:
2064 teams = team_dao.get_club_teams(club_id)
2065 else:
2066 teams = team_dao.get_all_teams()
2068 # Enrich teams with additional data if requested
2069 if include_parent or include_game_count:
2070 enriched_teams = []
2072 # Get game counts for all teams in one query (performance optimization)
2073 game_counts = {}
2074 if include_game_count:
2075 game_counts = team_dao.get_team_game_counts()
2077 # Pre-fetch clubs once (not inside loop!) for parent club lookup
2078 clubs_by_id = {}
2079 if include_parent:
2080 clubs = club_dao.get_all_clubs()
2081 clubs_by_id = {c["id"]: c for c in clubs}
2083 for team in teams:
2084 team_data = {**team}
2086 # Add parent club info if requested
2087 if include_parent:
2088 if team.get("club_id"):
2089 team_data["parent_club"] = clubs_by_id.get(team["club_id"])
2090 else:
2091 team_data["parent_club"] = None
2093 # Check if this team is itself a parent club
2094 if hasattr(team_dao, "is_parent_club"):
2095 team_data["is_parent_club"] = team_dao.is_parent_club(team["id"])
2096 else:
2097 team_data["is_parent_club"] = False
2099 # Add game count if requested
2100 if include_game_count:
2101 team_data["game_count"] = game_counts.get(team["id"], 0)
2103 enriched_teams.append(team_data)
2105 return enriched_teams
2107 return teams
2108 except Exception as e:
2109 logger.error(f"Error retrieving teams: {e!s}", exc_info=True)
2110 raise HTTPException(
2111 status_code=503, detail="Database connection failed. Please check Supabase connection."
2112 ) from e
2115@app.post("/api/teams")
2116async def add_team(
2117 request: Request,
2118 team: Team,
2119 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
2120):
2121 """Add a new team with age groups, division, and optional parent club.
2123 Division represents location (e.g., Northeast Division for Homegrown, New England Conference for Academy).
2124 All age groups for a team share the same division.
2126 Club managers can only add teams to their assigned club.
2127 """
2128 try:
2129 user_role = current_user.get("role")
2130 user_club_id = current_user.get("club_id")
2132 # Team managers cannot create teams (only manage existing ones)
2133 if user_role == "team-manager":
2134 raise HTTPException(status_code=403, detail="Team managers cannot create teams")
2136 # Club managers can only add teams to their assigned club
2137 if user_role == "club_manager":
2138 if not user_club_id:
2139 raise HTTPException(status_code=403, detail="Club manager must have a club assigned")
2140 # Force team to be created in the club manager's club
2141 if team.club_id and team.club_id != user_club_id:
2142 raise HTTPException(status_code=403, detail="Can only create teams for your assigned club")
2143 # Auto-assign club_id if not provided
2144 team.club_id = user_club_id
2146 # Call add_team with keyword arguments
2147 success = team_dao.add_team(
2148 name=team.name,
2149 city=team.city,
2150 age_group_ids=team.age_group_ids,
2151 match_type_ids=team.match_type_ids,
2152 division_id=team.division_id,
2153 club_id=team.club_id,
2154 academy_team=team.academy_team,
2155 )
2156 if success:
2157 return {"message": "Team added successfully"}
2158 else:
2159 raise HTTPException(status_code=500, detail="Failed to add team")
2160 except Exception as e:
2161 error_str = str(e)
2162 logger.error(f"Error adding team: {error_str}", exc_info=True)
2164 # Check for duplicate team constraint violation
2165 if (
2166 "teams_name_division_unique" in error_str
2167 or "teams_name_academy_unique" in error_str
2168 or "duplicate key value" in error_str.lower()
2169 ):
2170 raise HTTPException(status_code=409, detail="Team with this name already exists in this division") from e
2172 raise HTTPException(status_code=500, detail=error_str) from e
2175# === Enhanced Match Endpoints ===
2178@app.get("/api/matches")
2179async def get_matches(
2180 request: Request,
2181 current_user: dict[str, Any] = Depends(get_current_user_required),
2182 season_id: int | None = Query(None, description="Filter by season ID"),
2183 age_group_id: int | None = Query(None, description="Filter by age group ID"),
2184 division_id: int | None = Query(None, description="Filter by division ID"),
2185 team_id: int | None = Query(None, description="Filter by team ID (home or away)"),
2186 match_type: str | None = Query(None, description="Filter by match type name"),
2187 start_date: str | None = Query(None, description="Filter by start date (YYYY-MM-DD)"),
2188 end_date: str | None = Query(None, description="Filter by end date (YYYY-MM-DD)"),
2189):
2190 """Get all matches with optional filters (requires authentication)."""
2191 try:
2192 matches = match_dao.get_all_matches(
2193 season_id=season_id,
2194 age_group_id=age_group_id,
2195 division_id=division_id,
2196 team_id=team_id,
2197 match_type=match_type,
2198 start_date=start_date,
2199 end_date=end_date,
2200 )
2202 # Enrich matches with card event data
2203 match_ids = [m["id"] for m in matches if m.get("id")]
2204 if match_ids:
2205 card_events = match_event_dao.get_card_events_for_matches(match_ids)
2206 for m in matches:
2207 cards = card_events.get(m["id"], [])
2208 m["red_cards"] = [
2209 {"team_id": c["team_id"], "player_name": c["player_name"]}
2210 for c in cards
2211 if c["event_type"] == "red_card"
2212 ]
2213 m["yellow_cards"] = [
2214 {"team_id": c["team_id"], "player_name": c["player_name"]}
2215 for c in cards
2216 if c["event_type"] == "yellow_card"
2217 ]
2219 return matches
2220 except Exception as e:
2221 logger.error(f"Error retrieving matches: {e!s}", exc_info=True)
2222 raise HTTPException(
2223 status_code=503, detail="Database connection failed. Please check Supabase connection."
2224 ) from e
2227@app.get("/api/matches/live")
2228async def get_live_matches(
2229 current_user: dict[str, Any] = Depends(get_current_user_required),
2230):
2231 """Get all currently live matches.
2233 Used for the LIVE tab to check if there are active live matches.
2234 Returns minimal data for efficient polling.
2235 """
2236 try:
2237 live_matches = match_dao.get_live_matches()
2238 return live_matches
2239 except Exception as e:
2240 logger.error(f"Error getting live matches: {e!s}", exc_info=True)
2241 raise HTTPException(status_code=500, detail=str(e)) from e
2244@app.get("/api/matches/preview/{home_team_id}/{away_team_id}")
2245async def get_match_preview(
2246 home_team_id: int,
2247 away_team_id: int,
2248 season_id: int | None = Query(None, description="Season ID for recent form and common opponents"),
2249 age_group_id: int | None = Query(None, description="Filter by age group"),
2250 recent_count: int = Query(5, ge=1, le=20, description="Number of recent matches per team"),
2251 current_user: dict[str, Any] = Depends(get_current_user_required),
2252):
2253 """Get match preview data for two teams.
2255 Returns recent form for each team, common opponents (with match results), and
2256 head-to-head history spanning all seasons.
2257 """
2258 try:
2259 preview = match_dao.get_match_preview(
2260 home_team_id=home_team_id,
2261 away_team_id=away_team_id,
2262 season_id=season_id,
2263 age_group_id=age_group_id,
2264 recent_count=recent_count,
2265 )
2266 return preview
2267 except Exception as e:
2268 logger.error(f"Error building match preview: {e!s}", exc_info=True)
2269 raise HTTPException(status_code=500, detail=str(e)) from e
2272@app.get("/api/matches/{match_id}")
2273async def get_match(
2274 request: Request,
2275 match_id: int,
2276 current_user: dict[str, Any] = Depends(get_current_user_required),
2277):
2278 """Get a specific match by ID (requires authentication)."""
2279 try:
2280 # Use get_match_by_id for efficient single-match lookup with club data
2281 match = match_dao.get_match_by_id(match_id)
2283 if not match:
2284 raise HTTPException(status_code=404, detail=f"Match with ID {match_id} not found")
2286 return match
2287 except HTTPException:
2288 raise
2289 except Exception as e:
2290 logger.error(f"Error retrieving match {match_id}: {e!s}", exc_info=True)
2291 raise HTTPException(
2292 status_code=503, detail="Database connection failed. Please check Supabase connection."
2293 ) from e
2296@app.post("/api/matches")
2297async def add_match(
2298 request: Request,
2299 match: EnhancedMatch,
2300 current_user: dict[str, Any] = Depends(require_match_management_permission),
2301):
2302 """Add a new match (requires admin, team manager, or service account with manage_matches permission)."""
2303 try:
2304 # Use username for regular users, service_name for service accounts
2305 user_identifier = current_user.get("username") or current_user.get("service_name", "unknown")
2306 logger.info(f"POST /api/matches - User: {user_identifier}, Role: {current_user.get('role', 'unknown')}")
2307 logger.info(f"POST /api/matches - Match data: {match.model_dump()}")
2309 # Validate division_id for League matches
2310 match_type = match_type_dao.get_match_type_by_id(match.match_type_id)
2311 if match_type and match_type.get("name") == "League" and match.division_id is None:
2312 raise HTTPException(status_code=422, detail="division_id is required for League matches")
2314 success = match_dao.add_match(
2315 home_team_id=match.home_team_id,
2316 away_team_id=match.away_team_id,
2317 match_date=match.match_date,
2318 home_score=match.home_score,
2319 away_score=match.away_score,
2320 season_id=match.season_id,
2321 age_group_id=match.age_group_id,
2322 match_type_id=match.match_type_id,
2323 division_id=match.division_id,
2324 status=match.status,
2325 created_by=current_user.get("user_id"), # Track who created the match
2326 source=match.source, # Track source (manual, match-scraper, etc.)
2327 external_match_id=match.external_match_id, # Store external match identifier if provided
2328 scheduled_kickoff=match.scheduled_kickoff.isoformat() if match.scheduled_kickoff else None,
2329 )
2330 if success:
2331 return {"message": "Match added successfully"}
2332 else:
2333 raise HTTPException(status_code=500, detail="Failed to add match")
2334 except DuplicateRecordError as e:
2335 logger.warning(f"Duplicate match: {e.message}", details=e.details)
2336 raise HTTPException(status_code=409, detail=e.message) from e
2337 except HTTPException:
2338 raise
2339 except Exception as e:
2340 logger.error(f"Error adding match: {e!s}", exc_info=True)
2341 raise HTTPException(status_code=500, detail=str(e)) from e
2344@app.put("/api/matches/{match_id}")
2345async def update_match(
2346 match_id: int,
2347 match: EnhancedMatch,
2348 current_user: dict[str, Any] = Depends(require_match_management_permission),
2349):
2350 """Update an existing match (requires admin, team manager, or service account with manage_matches)."""
2351 try:
2352 # Get current match to check permissions
2353 current_match = match_dao.get_match_by_id(match_id)
2354 if not current_match:
2355 raise HTTPException(status_code=404, detail="Match not found")
2357 # Check if user can edit this match
2358 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2359 raise HTTPException(status_code=403, detail="You don't have permission to edit this match")
2361 # Validate division_id for League matches
2362 match_type = match_type_dao.get_match_type_by_id(match.match_type_id)
2363 if match_type and match_type.get("name") == "League" and match.division_id is None:
2364 raise HTTPException(status_code=422, detail="division_id is required for League matches")
2366 updated_match = match_dao.update_match(
2367 match_id=match_id,
2368 home_team_id=match.home_team_id,
2369 away_team_id=match.away_team_id,
2370 match_date=match.match_date,
2371 home_score=match.home_score,
2372 away_score=match.away_score,
2373 season_id=match.season_id,
2374 age_group_id=match.age_group_id,
2375 match_type_id=match.match_type_id,
2376 division_id=match.division_id,
2377 status=match.status,
2378 updated_by=current_user.get("user_id"), # Track who updated the match
2379 external_match_id=match.external_match_id, # Update external_match_id if provided
2380 scheduled_kickoff=match.scheduled_kickoff.isoformat() if match.scheduled_kickoff else None,
2381 )
2382 if updated_match:
2383 return {"message": "Match updated successfully"}
2384 else:
2385 raise HTTPException(status_code=500, detail="Failed to update match")
2386 except Exception as e:
2387 logger.error(f"Error updating match: {e!s}", exc_info=True)
2388 raise HTTPException(status_code=500, detail=str(e)) from e
2391@app.patch("/api/matches/{match_id}")
2392async def patch_match(
2393 match_id: int,
2394 match_patch: MatchPatch,
2395 current_user: dict[str, Any] = Depends(require_match_management_permission),
2396):
2397 """Partially update a match (requires manage_matches permission).
2399 This endpoint allows updating specific fields without requiring all fields.
2400 Commonly used for score updates from match-scraper.
2401 """
2402 try:
2403 # Get current match to check permissions and get existing values
2404 current_match = match_dao.get_match_by_id(match_id)
2405 if not current_match:
2406 raise HTTPException(status_code=404, detail="Match not found")
2408 # Check if user can edit this match
2409 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2410 raise HTTPException(status_code=403, detail="You don't have permission to edit this match")
2412 # Validate scores if provided
2413 if match_patch.home_score is not None and match_patch.home_score < 0:
2414 raise HTTPException(status_code=400, detail="home_score must be non-negative")
2415 if match_patch.away_score is not None and match_patch.away_score < 0:
2416 raise HTTPException(status_code=400, detail="away_score must be non-negative")
2418 # Validate match_status if provided (must match database CHECK constraint)
2419 valid_statuses = ["scheduled", "live", "completed", "postponed", "cancelled", "forfeit"]
2420 status_to_check = match_patch.match_status or match_patch.status
2421 if status_to_check is not None and status_to_check not in valid_statuses:
2422 raise HTTPException(status_code=400, detail=f"status must be one of: {', '.join(valid_statuses)}")
2424 # Build update data, using existing values for fields not provided
2425 update_data = {
2426 "match_id": match_id,
2427 "home_team_id": match_patch.home_team_id
2428 if match_patch.home_team_id is not None
2429 else current_match["home_team_id"],
2430 "away_team_id": match_patch.away_team_id
2431 if match_patch.away_team_id is not None
2432 else current_match["away_team_id"],
2433 "match_date": match_patch.match_date if match_patch.match_date is not None else current_match["match_date"],
2434 "home_score": match_patch.home_score if match_patch.home_score is not None else current_match["home_score"],
2435 "away_score": match_patch.away_score if match_patch.away_score is not None else current_match["away_score"],
2436 "season_id": match_patch.season_id if match_patch.season_id is not None else current_match["season_id"],
2437 "age_group_id": match_patch.age_group_id
2438 if match_patch.age_group_id is not None
2439 else current_match["age_group_id"],
2440 "match_type_id": match_patch.match_type_id
2441 if match_patch.match_type_id is not None
2442 else current_match["match_type_id"],
2443 "division_id": match_patch.division_id
2444 if match_patch.division_id is not None
2445 else current_match.get("division_id"),
2446 "status": match_patch.match_status
2447 if match_patch.match_status is not None
2448 else (
2449 match_patch.status if match_patch.status is not None else current_match.get("match_status", "scheduled")
2450 ),
2451 "external_match_id": match_patch.external_match_id
2452 if match_patch.external_match_id is not None
2453 else current_match.get("external_match_id"),
2454 "scheduled_kickoff": match_patch.scheduled_kickoff.isoformat()
2455 if match_patch.scheduled_kickoff is not None
2456 else current_match.get("scheduled_kickoff"),
2457 "updated_by": current_user.get("user_id"),
2458 }
2460 # Validate division_id for League matches (after building final update data)
2461 final_match_type_id = update_data["match_type_id"]
2462 final_division_id = update_data["division_id"]
2463 match_type = match_type_dao.get_match_type_by_id(final_match_type_id)
2464 if match_type and match_type.get("name") == "League" and final_division_id is None:
2465 raise HTTPException(status_code=422, detail="division_id is required for League matches")
2467 logger.info(f"PATCH /api/matches/{match_id} - Calling update_match with: {update_data}")
2468 updated_match = match_dao.update_match(**update_data)
2469 logger.info(f"PATCH /api/matches/{match_id} - Update success: {bool(updated_match)}")
2471 if updated_match:
2472 # Return the updated match data directly from update_match
2473 # This avoids read-after-write consistency issues
2474 logger.info(f"PATCH /api/matches/{match_id} - Returning updated match: {updated_match}")
2475 return updated_match
2476 else:
2477 logger.error(f"PATCH /api/matches/{match_id} - update_match returned None!")
2478 raise HTTPException(status_code=500, detail="Failed to update match")
2480 except HTTPException:
2481 raise
2482 except Exception as e:
2483 logger.error(f"Error patching match: {e!s}", exc_info=True)
2484 raise HTTPException(status_code=500, detail=str(e)) from e
2487@app.delete("/api/matches/{match_id}")
2488async def delete_match(match_id: int, current_user: dict[str, Any] = Depends(require_team_manager_or_admin)):
2489 """Delete a match (admin or team manager only)."""
2490 try:
2491 # Get current match to check permissions
2492 current_match = match_dao.get_match_by_id(match_id)
2493 if not current_match:
2494 raise HTTPException(status_code=404, detail="Match not found")
2496 # Check if user can edit this match
2497 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2498 raise HTTPException(status_code=403, detail="You don't have permission to delete this match")
2500 success = match_dao.delete_match(match_id)
2501 if success:
2502 return {"message": "Match deleted successfully"}
2503 else:
2504 raise HTTPException(status_code=500, detail="Failed to delete match")
2505 except Exception as e:
2506 logger.error(f"Error deleting match: {e!s}", exc_info=True)
2507 raise HTTPException(status_code=500, detail=str(e)) from e
2510@app.get("/api/matches/team/{team_id}")
2511async def get_matches_by_team(
2512 team_id: int,
2513 current_user: dict[str, Any] = Depends(get_current_user_required),
2514 season_id: int | None = Query(None, description="Filter by season ID"),
2515 age_group_id: int | None = Query(None, description="Filter by age group ID"),
2516):
2517 """Get matches for a specific team."""
2518 try:
2519 matches = match_dao.get_matches_by_team(team_id, season_id=season_id, age_group_id=age_group_id)
2520 if not matches:
2521 return []
2523 # Enrich matches with card event data
2524 match_ids = [m["id"] for m in matches if m.get("id")]
2525 if match_ids:
2526 card_events = match_event_dao.get_card_events_for_matches(match_ids)
2527 for m in matches:
2528 cards = card_events.get(m["id"], [])
2529 m["red_cards"] = [
2530 {"team_id": c["team_id"], "player_name": c["player_name"]}
2531 for c in cards
2532 if c["event_type"] == "red_card"
2533 ]
2534 m["yellow_cards"] = [
2535 {"team_id": c["team_id"], "player_name": c["player_name"]}
2536 for c in cards
2537 if c["event_type"] == "yellow_card"
2538 ]
2540 return matches
2541 except Exception as e:
2542 logger.error(f"Error retrieving matches for team '{team_id}': {e!s}", exc_info=True)
2543 raise HTTPException(status_code=500, detail=str(e)) from e
2546# === Live Match Endpoints ===
2549def calculate_match_minute(match: dict) -> tuple[int | None, int | None]:
2550 """Calculate the current match minute based on timestamps.
2552 Args:
2553 match: Match dict with kickoff_time, halftime_start, second_half_start, half_duration
2555 Returns:
2556 Tuple of (match_minute, extra_time) where:
2557 - match_minute: The base minute (1-45 for first half, 46-90 for second half)
2558 - extra_time: Stoppage time minutes if applicable (e.g., 5 for "45+5")
2559 """
2560 from datetime import datetime
2562 kickoff_time = match.get("kickoff_time")
2563 halftime_start = match.get("halftime_start")
2564 second_half_start = match.get("second_half_start")
2565 half_duration = match.get("half_duration") or 45
2567 if not kickoff_time:
2568 return None, None
2570 now = datetime.now(UTC)
2572 # Parse kickoff time
2573 if isinstance(kickoff_time, str):
2574 kickoff_time = datetime.fromisoformat(kickoff_time.replace("Z", "+00:00"))
2576 # In second half
2577 if second_half_start:
2578 if isinstance(second_half_start, str):
2579 second_half_start = datetime.fromisoformat(second_half_start.replace("Z", "+00:00"))
2580 elapsed_seconds = (now - second_half_start).total_seconds()
2581 elapsed_minutes = int(elapsed_seconds / 60) + 1 # Round up to current minute
2582 total_minute = half_duration + elapsed_minutes
2584 # Check for stoppage time (beyond 90 for 45-min halves)
2585 full_time = half_duration * 2
2586 if total_minute > full_time:
2587 return full_time, total_minute - full_time
2588 return total_minute, None
2590 # At halftime - return end of first half
2591 if halftime_start:
2592 return half_duration, None
2594 # In first half
2595 elapsed_seconds = (now - kickoff_time).total_seconds()
2596 elapsed_minutes = int(elapsed_seconds / 60) + 1 # Round up to current minute
2598 # Check for stoppage time (beyond 45 for 45-min halves)
2599 if elapsed_minutes > half_duration:
2600 return half_duration, elapsed_minutes - half_duration
2601 return elapsed_minutes, None
2604@app.get("/api/matches/{match_id}/live")
2605async def get_live_match_state(
2606 match_id: int,
2607 current_user: dict[str, Any] = Depends(get_current_user_required),
2608):
2609 """Get full live match state including clock timestamps and recent events.
2611 Returns match data with clock fields for the live match view.
2612 """
2613 try:
2614 match_state = match_dao.get_live_match_state(match_id)
2615 if not match_state:
2616 raise HTTPException(status_code=404, detail="Match not found")
2618 # Get recent events
2619 events = match_event_dao.get_events(match_id, limit=50)
2620 match_state["recent_events"] = events
2622 return match_state
2623 except HTTPException:
2624 raise
2625 except Exception as e:
2626 logger.error(f"Error getting live match state: {e!s}", exc_info=True)
2627 raise HTTPException(status_code=500, detail=str(e)) from e
2630@app.post("/api/matches/{match_id}/live/clock")
2631async def update_match_clock(
2632 match_id: int,
2633 clock: LiveMatchClock,
2634 current_user: dict[str, Any] = Depends(require_match_management_permission),
2635):
2636 """Update match clock (start match, halftime, second half, end match).
2638 Only accessible by admins, club managers, and team managers who can edit this match.
2639 """
2640 try:
2641 # Get current match to check permissions
2642 current_match = match_dao.get_match_by_id(match_id)
2643 if not current_match:
2644 raise HTTPException(status_code=404, detail="Match not found")
2646 # Check if user can edit this match
2647 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2648 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
2650 # Validate action
2651 valid_actions = [
2652 "start_first_half",
2653 "start_halftime",
2654 "start_second_half",
2655 "end_match",
2656 ]
2657 if clock.action not in valid_actions:
2658 raise HTTPException(
2659 status_code=400,
2660 detail=f"Invalid action. Must be one of: {', '.join(valid_actions)}",
2661 )
2663 # Update the clock
2664 user_id = current_user.get("user_id") or current_user.get("id")
2665 result = match_dao.update_match_clock(
2666 match_id, clock.action, updated_by=user_id, half_duration=clock.half_duration
2667 )
2668 if not result:
2669 raise HTTPException(status_code=500, detail="Failed to update match clock")
2671 # Create status change event
2672 action_messages = {
2673 "start_first_half": "Match kicked off",
2674 "start_halftime": "Halftime",
2675 "start_second_half": "Second half started",
2676 "end_match": "Full time",
2677 }
2678 match_event_dao.create_event(
2679 match_id=match_id,
2680 event_type="status_change",
2681 message=action_messages.get(clock.action, clock.action),
2682 created_by=user_id,
2683 created_by_username=current_user.get("username"),
2684 )
2686 # When a match ends, invalidate stats cache so leaderboard picks up new goals
2687 if clock.action == "end_match":
2688 from dao.base_dao import clear_cache
2690 clear_cache("mt:dao:stats:*")
2692 return result
2693 except HTTPException:
2694 raise
2695 except Exception as e:
2696 logger.error(f"Error updating match clock: {e!s}", exc_info=True)
2697 raise HTTPException(status_code=500, detail=str(e)) from e
2700@app.post("/api/matches/{match_id}/live/goal")
2701async def post_goal(
2702 match_id: int,
2703 goal: GoalEvent,
2704 current_user: dict[str, Any] = Depends(require_match_management_permission),
2705):
2706 """Post a goal event and update the match score.
2708 Only accessible by admins, club managers, and team managers who can edit this match.
2710 Accepts either player_id (preferred, from roster) or player_name (legacy).
2711 When player_id is provided, the goal is tracked in player_match_stats.
2712 """
2713 try:
2714 # Get current match to check permissions and current scores
2715 current_match = match_dao.get_match_by_id(match_id)
2716 if not current_match:
2717 raise HTTPException(status_code=404, detail="Match not found")
2719 # Check if user can edit this match
2720 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2721 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
2723 # Validate team_id is one of the match teams
2724 if goal.team_id not in [
2725 current_match["home_team_id"],
2726 current_match["away_team_id"],
2727 ]:
2728 raise HTTPException(status_code=400, detail="Team must be one of the match participants")
2730 # Resolve player name - either from player_id or from the request
2731 player_name = goal.player_name
2732 player_id = goal.player_id
2734 if player_id:
2735 # Validate player exists and is on the scoring team
2736 player = roster_dao.get_player_by_id(player_id)
2737 if not player:
2738 raise HTTPException(status_code=400, detail="Player not found")
2739 if player["team_id"] != goal.team_id:
2740 raise HTTPException(status_code=400, detail="Player must be on the scoring team")
2741 # Use player display name from roster
2742 player_name = player.get("display_name", f"#{player['jersey_number']}")
2744 # Require at least one identifier
2745 if not player_name and not player_id:
2746 raise HTTPException(status_code=400, detail="Either player_id or player_name is required")
2748 # Calculate new scores
2749 home_score = current_match.get("home_score") or 0
2750 away_score = current_match.get("away_score") or 0
2752 if goal.team_id == current_match["home_team_id"]:
2753 home_score += 1
2754 team_name = current_match.get("home_team_name", "Home")
2755 else:
2756 away_score += 1
2757 team_name = current_match.get("away_team_name", "Away")
2759 # Update the score
2760 user_id = current_user.get("user_id") or current_user.get("id")
2761 result = match_dao.update_match_score(match_id, home_score, away_score, updated_by=user_id)
2762 if not result:
2763 raise HTTPException(status_code=500, detail="Failed to update match score")
2765 # Calculate match minute for the goal
2766 match_minute, extra_time = calculate_match_minute(current_match)
2768 # Create goal event
2769 goal_message = f"GOAL! {team_name} - {player_name}"
2770 if goal.message:
2771 goal_message += f" ({goal.message})"
2773 match_event_dao.create_event(
2774 match_id=match_id,
2775 event_type="goal",
2776 message=goal_message,
2777 created_by=user_id,
2778 created_by_username=current_user.get("username"),
2779 team_id=goal.team_id,
2780 player_name=player_name,
2781 player_id=player_id,
2782 match_minute=match_minute,
2783 extra_time=extra_time,
2784 )
2786 # Update player stats if player_id is provided
2787 if player_id:
2788 player_stats_dao.increment_goals(player_id, match_id)
2790 return result
2791 except HTTPException:
2792 raise
2793 except Exception as e:
2794 logger.error(f"Error posting goal: {e!s}", exc_info=True)
2795 raise HTTPException(status_code=500, detail=str(e)) from e
2798@app.post("/api/matches/{match_id}/live/card")
2799async def post_live_card(
2800 match_id: int,
2801 card: LiveCardEvent,
2802 current_user: dict[str, Any] = Depends(require_match_management_permission),
2803):
2804 """Record a card event during a live match.
2806 Creates a card event in the match timeline and updates player_match_stats.
2807 The match minute is auto-calculated from the match clock.
2808 """
2809 try:
2810 current_match = match_dao.get_match_by_id(match_id)
2811 if not current_match:
2812 raise HTTPException(status_code=404, detail="Match not found")
2814 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2815 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
2817 if card.team_id not in [current_match["home_team_id"], current_match["away_team_id"]]:
2818 raise HTTPException(status_code=400, detail="Team must be one of the match participants")
2820 # Validate player exists and is on the team
2821 player = roster_dao.get_player_by_id(card.player_id)
2822 if not player:
2823 raise HTTPException(status_code=400, detail="Player not found")
2824 if player["team_id"] != card.team_id:
2825 raise HTTPException(status_code=400, detail="Player must be on the specified team")
2827 player_name = player.get("display_name", f"#{player['jersey_number']}")
2828 card_label = "RED CARD" if card.card_type == "red_card" else "YELLOW CARD"
2830 card_message = f"{card_label}: {player_name}"
2831 if card.message:
2832 card_message += f" ({card.message})"
2834 # Auto-calculate match minute
2835 match_minute, extra_time = calculate_match_minute(current_match)
2837 user_id = current_user.get("user_id") or current_user.get("id")
2839 event = match_event_dao.create_event(
2840 match_id=match_id,
2841 event_type=card.card_type,
2842 message=card_message,
2843 created_by=user_id,
2844 created_by_username=current_user.get("username"),
2845 team_id=card.team_id,
2846 player_name=player_name,
2847 player_id=card.player_id,
2848 match_minute=match_minute,
2849 extra_time=extra_time,
2850 )
2852 if not event:
2853 raise HTTPException(status_code=500, detail="Failed to create card event")
2855 # Update player card stats
2856 stats = player_stats_dao.get_or_create_match_stats(card.player_id, match_id)
2857 if stats:
2858 card_field = "red_cards" if card.card_type == "red_card" else "yellow_cards"
2859 current_count = stats.get(card_field, 0)
2860 player_stats_dao.client.table("player_match_stats").update(
2861 {card_field: current_count + 1, "played": True}
2862 ).eq("player_id", card.player_id).eq("match_id", match_id).execute()
2864 logger.info(
2865 "live_card_recorded",
2866 match_id=match_id,
2867 player_id=card.player_id,
2868 card_type=card.card_type,
2869 minute=match_minute,
2870 )
2872 return event
2874 except HTTPException:
2875 raise
2876 except Exception as e:
2877 logger.error(f"Error recording live card: {e!s}", exc_info=True)
2878 raise HTTPException(status_code=500, detail=str(e)) from e
2881@app.post("/api/matches/{match_id}/live/message")
2882async def post_message(
2883 match_id: int,
2884 message: MessageEvent,
2885 current_user: dict[str, Any] = Depends(get_current_user_required),
2886):
2887 """Post a chat message to the live match stream.
2889 Any authenticated user can post messages.
2890 """
2891 try:
2892 # Verify match exists
2893 current_match = match_dao.get_match_by_id(match_id)
2894 if not current_match:
2895 raise HTTPException(status_code=404, detail="Match not found")
2897 # Create message event
2898 user_id = current_user.get("user_id") or current_user.get("id")
2899 event = match_event_dao.create_event(
2900 match_id=match_id,
2901 event_type="message",
2902 message=message.message,
2903 created_by=user_id,
2904 created_by_username=current_user.get("username"),
2905 )
2907 if not event:
2908 raise HTTPException(status_code=500, detail="Failed to post message")
2910 return event
2911 except HTTPException:
2912 raise
2913 except Exception as e:
2914 logger.error(f"Error posting message: {e!s}", exc_info=True)
2915 raise HTTPException(status_code=500, detail=str(e)) from e
2918@app.delete("/api/matches/{match_id}/live/events/{event_id}")
2919async def delete_event(
2920 match_id: int,
2921 event_id: int,
2922 current_user: dict[str, Any] = Depends(require_match_management_permission),
2923):
2924 """Soft delete a match event (for moderation).
2926 Only accessible by admins, club managers, and team managers who can edit this match.
2927 """
2928 try:
2929 # Get current match to check permissions
2930 current_match = match_dao.get_match_by_id(match_id)
2931 if not current_match:
2932 raise HTTPException(status_code=404, detail="Match not found")
2934 # Check if user can edit this match
2935 if not auth_manager.can_edit_match(current_user, current_match["home_team_id"], current_match["away_team_id"]):
2936 raise HTTPException(status_code=403, detail="You don't have permission to moderate this match")
2938 # Verify the event belongs to this match
2939 event = match_event_dao.get_event_by_id(event_id)
2940 if not event:
2941 raise HTTPException(status_code=404, detail="Event not found")
2942 if event.get("match_id") != match_id:
2943 raise HTTPException(status_code=400, detail="Event does not belong to this match")
2945 # Soft delete the event
2946 user_id = current_user.get("user_id") or current_user.get("id")
2947 success = match_event_dao.soft_delete_event(event_id, deleted_by=user_id)
2949 if not success:
2950 raise HTTPException(status_code=500, detail="Failed to delete event")
2952 # If it was a goal, decrement the score
2953 if event.get("event_type") == "goal":
2954 team_id = event.get("team_id")
2955 home_score = current_match.get("home_score") or 0
2956 away_score = current_match.get("away_score") or 0
2958 if team_id == current_match["home_team_id"] and home_score > 0:
2959 home_score -= 1
2960 elif team_id == current_match["away_team_id"] and away_score > 0:
2961 away_score -= 1
2963 match_dao.update_match_score(match_id, home_score, away_score, updated_by=user_id)
2965 # Also decrement player stats if player_id was tracked
2966 goal_player_id = event.get("player_id")
2967 if goal_player_id:
2968 player_stats_dao.decrement_goals(goal_player_id, match_id)
2970 return {"message": "Event deleted successfully"}
2971 except HTTPException:
2972 raise
2973 except Exception as e:
2974 logger.error(f"Error deleting event: {e!s}", exc_info=True)
2975 raise HTTPException(status_code=500, detail=str(e)) from e
2978@app.get("/api/matches/{match_id}/live/events")
2979async def get_match_events(
2980 match_id: int,
2981 current_user: dict[str, Any] = Depends(get_current_user_required),
2982 limit: int = Query(50, le=100, description="Maximum events to return"),
2983 before_id: int | None = Query(None, description="Return events before this ID"),
2984):
2985 """Get paginated events for a match.
2987 Used for loading more events in the activity stream.
2988 """
2989 try:
2990 events = match_event_dao.get_events(match_id, limit=limit, before_id=before_id)
2991 return events
2992 except Exception as e:
2993 logger.error(f"Error getting match events: {e!s}", exc_info=True)
2994 raise HTTPException(status_code=500, detail=str(e)) from e
2997# === Match Lineup Endpoints ===
3000@app.get("/api/matches/{match_id}/lineup/{team_id}")
3001async def get_lineup(
3002 match_id: int,
3003 team_id: int,
3004 current_user: dict[str, Any] = Depends(get_current_user_required),
3005):
3006 """Get lineup for a team in a specific match.
3008 Returns lineup with formation name and player-position assignments,
3009 enriched with player details (jersey number, name).
3010 """
3011 try:
3012 lineup = lineup_dao.get_lineup(match_id, team_id)
3013 return lineup
3014 except Exception as e:
3015 logger.error(f"Error getting lineup: {e!s}", exc_info=True)
3016 raise HTTPException(status_code=500, detail=str(e)) from e
3019@app.put("/api/matches/{match_id}/lineup/{team_id}")
3020async def save_lineup(
3021 match_id: int,
3022 team_id: int,
3023 lineup: LineupSave,
3024 current_user: dict[str, Any] = Depends(require_match_management_permission),
3025):
3026 """Save or update lineup for a team in a match.
3028 Only accessible by admins, club managers, and team managers who can edit this match.
3029 When lineup is saved, also marks all assigned players as started in player_match_stats.
3030 """
3031 try:
3032 # Get user ID from current user
3033 user_id = current_user.get("id")
3035 # Convert Pydantic models to dicts for storage
3036 positions_data = [{"player_id": p.player_id, "position": p.position} for p in lineup.positions]
3038 # Save the lineup
3039 saved_lineup = lineup_dao.save_lineup(
3040 match_id=match_id,
3041 team_id=team_id,
3042 formation_name=lineup.formation_name,
3043 positions=positions_data,
3044 user_id=user_id,
3045 )
3047 if not saved_lineup:
3048 raise HTTPException(status_code=500, detail="Failed to save lineup")
3050 # Mark all players in lineup as started
3051 for position in lineup.positions:
3052 player_stats_dao.set_started(position.player_id, match_id, started=True)
3054 logger.info(
3055 "Lineup saved",
3056 match_id=match_id,
3057 team_id=team_id,
3058 formation=lineup.formation_name,
3059 player_count=len(lineup.positions),
3060 )
3062 return saved_lineup
3063 except HTTPException:
3064 raise
3065 except Exception as e:
3066 logger.error(f"Error saving lineup: {e!s}", exc_info=True)
3067 raise HTTPException(status_code=500, detail=str(e)) from e
3070# === Post-Match Stats Endpoints ===
3073def validate_post_match_access(user: dict[str, Any], match: dict, team_id: int) -> None:
3074 """Validate user has access to edit post-match stats for a team.
3076 Checks:
3077 - Match is completed
3078 - User can edit the match
3079 - Team is a participant in the match
3081 Raises HTTPException on failure.
3082 """
3083 if match.get("match_status") != "completed":
3084 raise HTTPException(status_code=400, detail="Match must be completed to edit post-match stats")
3086 if team_id not in [match["home_team_id"], match["away_team_id"]]:
3087 raise HTTPException(status_code=400, detail="Team must be a participant in this match")
3089 if not auth_manager.can_edit_match(user, match["home_team_id"], match["away_team_id"]):
3090 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
3092 # Team-scoped check: team managers can only edit their own team
3093 role = user.get("role")
3094 if role == "team-manager":
3095 user_team_id = user.get("team_id")
3096 if user_team_id != team_id:
3097 raise HTTPException(status_code=403, detail="Team managers can only edit stats for their own team")
3100@app.post("/api/matches/{match_id}/post-match/goal")
3101async def post_match_add_goal(
3102 match_id: int,
3103 goal: PostMatchGoal,
3104 current_user: dict[str, Any] = Depends(require_match_management_permission),
3105):
3106 """Record a goal for a completed match.
3108 Creates a goal event and increments the player's goal count in player_match_stats.
3109 Does NOT modify the match score (already set).
3110 """
3111 try:
3112 current_match = match_dao.get_match_by_id(match_id)
3113 if not current_match:
3114 raise HTTPException(status_code=404, detail="Match not found")
3116 validate_post_match_access(current_user, current_match, goal.team_id)
3118 # Resolve player - either from roster or free-text name
3119 player_id = goal.player_id
3120 player_name = goal.player_name
3122 if player_id:
3123 player = roster_dao.get_player_by_id(player_id)
3124 if not player:
3125 raise HTTPException(status_code=400, detail="Player not found")
3126 if player["team_id"] != goal.team_id:
3127 raise HTTPException(status_code=400, detail="Player must be on the specified team")
3128 player_name = player.get("display_name", f"#{player['jersey_number']}")
3129 elif not player_name:
3130 raise HTTPException(status_code=400, detail="Either player_id or player_name is required")
3132 team_name = (
3133 current_match.get("home_team_name")
3134 if goal.team_id == current_match["home_team_id"]
3135 else current_match.get("away_team_name")
3136 )
3138 goal_message = f"GOAL! {team_name} - {player_name}"
3139 if goal.message:
3140 goal_message += f" ({goal.message})"
3142 user_id = current_user.get("user_id") or current_user.get("id")
3144 event = match_event_dao.create_event(
3145 match_id=match_id,
3146 event_type="goal",
3147 message=goal_message,
3148 created_by=user_id,
3149 created_by_username=current_user.get("username"),
3150 team_id=goal.team_id,
3151 player_name=player_name,
3152 player_id=player_id,
3153 match_minute=goal.match_minute,
3154 extra_time=goal.extra_time,
3155 )
3157 if not event:
3158 raise HTTPException(status_code=500, detail="Failed to create goal event")
3160 # Increment player goal stats (only if roster player)
3161 if player_id:
3162 player_stats_dao.increment_goals(player_id, match_id)
3164 logger.info(
3165 "post_match_goal_recorded",
3166 match_id=match_id,
3167 player_id=goal.player_id,
3168 team_id=goal.team_id,
3169 minute=goal.match_minute,
3170 )
3172 return event
3174 except HTTPException:
3175 raise
3176 except Exception as e:
3177 logger.error(f"Error recording post-match goal: {e!s}", exc_info=True)
3178 raise HTTPException(status_code=500, detail=str(e)) from e
3181@app.delete("/api/matches/{match_id}/post-match/goal/{event_id}")
3182async def post_match_remove_goal(
3183 match_id: int,
3184 event_id: int,
3185 current_user: dict[str, Any] = Depends(require_match_management_permission),
3186):
3187 """Remove a goal event from a completed match.
3189 Soft-deletes the event and decrements the player's goal count.
3190 """
3191 try:
3192 current_match = match_dao.get_match_by_id(match_id)
3193 if not current_match:
3194 raise HTTPException(status_code=404, detail="Match not found")
3196 # Get the event to find team_id and player_id
3197 event = match_event_dao.get_event_by_id(event_id)
3198 if not event:
3199 raise HTTPException(status_code=404, detail="Event not found")
3200 if event.get("match_id") != match_id:
3201 raise HTTPException(status_code=400, detail="Event does not belong to this match")
3202 if event.get("event_type") != "goal":
3203 raise HTTPException(status_code=400, detail="Event is not a goal")
3205 team_id = event.get("team_id")
3206 if team_id:
3207 validate_post_match_access(current_user, current_match, team_id)
3208 else:
3209 # No team_id on the event; just check general match access
3210 if not auth_manager.can_edit_match(
3211 current_user, current_match["home_team_id"], current_match["away_team_id"]
3212 ):
3213 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
3215 user_id = current_user.get("user_id") or current_user.get("id")
3216 success = match_event_dao.soft_delete_event(event_id, deleted_by=user_id)
3217 if not success:
3218 raise HTTPException(status_code=500, detail="Failed to delete goal event")
3220 # Decrement player goal stats
3221 player_id = event.get("player_id")
3222 if player_id:
3223 player_stats_dao.decrement_goals(player_id, match_id)
3225 logger.info(
3226 "post_match_goal_removed",
3227 match_id=match_id,
3228 event_id=event_id,
3229 player_id=player_id,
3230 )
3232 return {"detail": "Goal removed"}
3234 except HTTPException:
3235 raise
3236 except Exception as e:
3237 logger.error(f"Error removing post-match goal: {e!s}", exc_info=True)
3238 raise HTTPException(status_code=500, detail=str(e)) from e
3241@app.post("/api/matches/{match_id}/post-match/substitution")
3242async def post_match_add_substitution(
3243 match_id: int,
3244 sub: PostMatchSubstitution,
3245 current_user: dict[str, Any] = Depends(require_match_management_permission),
3246):
3247 """Record a substitution for a completed match."""
3248 try:
3249 current_match = match_dao.get_match_by_id(match_id)
3250 if not current_match:
3251 raise HTTPException(status_code=404, detail="Match not found")
3253 validate_post_match_access(current_user, current_match, sub.team_id)
3255 # Validate both players exist and are on the team
3256 player_in = roster_dao.get_player_by_id(sub.player_in_id)
3257 if not player_in:
3258 raise HTTPException(status_code=400, detail="Player coming on not found")
3259 if player_in["team_id"] != sub.team_id:
3260 raise HTTPException(status_code=400, detail="Player coming on must be on the specified team")
3262 player_out = roster_dao.get_player_by_id(sub.player_out_id)
3263 if not player_out:
3264 raise HTTPException(status_code=400, detail="Player coming off not found")
3265 if player_out["team_id"] != sub.team_id:
3266 raise HTTPException(status_code=400, detail="Player coming off must be on the specified team")
3268 player_in_name = player_in.get("display_name", f"#{player_in['jersey_number']}")
3269 player_out_name = player_out.get("display_name", f"#{player_out['jersey_number']}")
3271 sub_message = f"SUB: {player_in_name} on for {player_out_name}"
3273 user_id = current_user.get("user_id") or current_user.get("id")
3275 event = match_event_dao.create_event(
3276 match_id=match_id,
3277 event_type="substitution",
3278 message=sub_message,
3279 created_by=user_id,
3280 created_by_username=current_user.get("username"),
3281 team_id=sub.team_id,
3282 player_id=sub.player_in_id,
3283 player_out_id=sub.player_out_id,
3284 match_minute=sub.match_minute,
3285 extra_time=sub.extra_time,
3286 )
3288 if not event:
3289 raise HTTPException(status_code=500, detail="Failed to create substitution event")
3291 logger.info(
3292 "post_match_substitution_recorded",
3293 match_id=match_id,
3294 player_in_id=sub.player_in_id,
3295 player_out_id=sub.player_out_id,
3296 minute=sub.match_minute,
3297 )
3299 return event
3301 except HTTPException:
3302 raise
3303 except Exception as e:
3304 logger.error(f"Error recording post-match substitution: {e!s}", exc_info=True)
3305 raise HTTPException(status_code=500, detail=str(e)) from e
3308@app.delete("/api/matches/{match_id}/post-match/substitution/{event_id}")
3309async def post_match_remove_substitution(
3310 match_id: int,
3311 event_id: int,
3312 current_user: dict[str, Any] = Depends(require_match_management_permission),
3313):
3314 """Remove a substitution event from a completed match."""
3315 try:
3316 current_match = match_dao.get_match_by_id(match_id)
3317 if not current_match:
3318 raise HTTPException(status_code=404, detail="Match not found")
3320 event = match_event_dao.get_event_by_id(event_id)
3321 if not event:
3322 raise HTTPException(status_code=404, detail="Event not found")
3323 if event.get("match_id") != match_id:
3324 raise HTTPException(status_code=400, detail="Event does not belong to this match")
3325 if event.get("event_type") != "substitution":
3326 raise HTTPException(status_code=400, detail="Event is not a substitution")
3328 team_id = event.get("team_id")
3329 if team_id:
3330 validate_post_match_access(current_user, current_match, team_id)
3331 else:
3332 if not auth_manager.can_edit_match(
3333 current_user, current_match["home_team_id"], current_match["away_team_id"]
3334 ):
3335 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
3337 user_id = current_user.get("user_id") or current_user.get("id")
3338 success = match_event_dao.soft_delete_event(event_id, deleted_by=user_id)
3339 if not success:
3340 raise HTTPException(status_code=500, detail="Failed to delete substitution event")
3342 logger.info(
3343 "post_match_substitution_removed",
3344 match_id=match_id,
3345 event_id=event_id,
3346 )
3348 return {"detail": "Substitution removed"}
3350 except HTTPException:
3351 raise
3352 except Exception as e:
3353 logger.error(f"Error removing post-match substitution: {e!s}", exc_info=True)
3354 raise HTTPException(status_code=500, detail=str(e)) from e
3357@app.post("/api/matches/{match_id}/post-match/card")
3358async def post_match_add_card(
3359 match_id: int,
3360 card: PostMatchCard,
3361 current_user: dict[str, Any] = Depends(require_match_management_permission),
3362):
3363 """Record a card (yellow or red) for a completed match.
3365 Creates a card event and updates the player's card count in player_match_stats.
3366 """
3367 try:
3368 current_match = match_dao.get_match_by_id(match_id)
3369 if not current_match:
3370 raise HTTPException(status_code=404, detail="Match not found")
3372 validate_post_match_access(current_user, current_match, card.team_id)
3374 # Resolve player - either from roster or free-text name
3375 player_id = card.player_id
3376 player_name = card.player_name
3378 if player_id:
3379 player = roster_dao.get_player_by_id(player_id)
3380 if not player:
3381 raise HTTPException(status_code=400, detail="Player not found")
3382 if player["team_id"] != card.team_id:
3383 raise HTTPException(status_code=400, detail="Player must be on the specified team")
3384 player_name = player.get("display_name", f"#{player['jersey_number']}")
3385 elif not player_name:
3386 raise HTTPException(status_code=400, detail="Either player_id or player_name is required")
3388 card_label = "RED CARD" if card.card_type == "red_card" else "YELLOW CARD"
3390 card_message = f"{card_label}: {player_name}"
3391 if card.message:
3392 card_message += f" ({card.message})"
3394 user_id = current_user.get("user_id") or current_user.get("id")
3396 event = match_event_dao.create_event(
3397 match_id=match_id,
3398 event_type=card.card_type,
3399 message=card_message,
3400 created_by=user_id,
3401 created_by_username=current_user.get("username"),
3402 team_id=card.team_id,
3403 player_name=player_name,
3404 player_id=player_id,
3405 match_minute=card.match_minute,
3406 extra_time=card.extra_time,
3407 )
3409 if not event:
3410 raise HTTPException(status_code=500, detail="Failed to create card event")
3412 # Update player card stats (only if roster player)
3413 if player_id:
3414 stats = player_stats_dao.get_or_create_match_stats(player_id, match_id)
3415 if stats:
3416 card_field = "red_cards" if card.card_type == "red_card" else "yellow_cards"
3417 current_count = stats.get(card_field, 0)
3418 player_stats_dao.client.table("player_match_stats").update(
3419 {card_field: current_count + 1, "played": True}
3420 ).eq("player_id", player_id).eq("match_id", match_id).execute()
3422 logger.info(
3423 "post_match_card_recorded",
3424 match_id=match_id,
3425 player_id=card.player_id,
3426 card_type=card.card_type,
3427 minute=card.match_minute,
3428 )
3430 return event
3432 except HTTPException:
3433 raise
3434 except Exception as e:
3435 logger.error(f"Error recording post-match card: {e!s}", exc_info=True)
3436 raise HTTPException(status_code=500, detail=str(e)) from e
3439@app.delete("/api/matches/{match_id}/post-match/card/{event_id}")
3440async def post_match_remove_card(
3441 match_id: int,
3442 event_id: int,
3443 current_user: dict[str, Any] = Depends(require_match_management_permission),
3444):
3445 """Remove a card event from a completed match."""
3446 try:
3447 current_match = match_dao.get_match_by_id(match_id)
3448 if not current_match:
3449 raise HTTPException(status_code=404, detail="Match not found")
3451 event = match_event_dao.get_event_by_id(event_id)
3452 if not event:
3453 raise HTTPException(status_code=404, detail="Event not found")
3454 if event.get("match_id") != match_id:
3455 raise HTTPException(status_code=400, detail="Event does not belong to this match")
3456 if event.get("event_type") not in ("red_card", "yellow_card"):
3457 raise HTTPException(status_code=400, detail="Event is not a card")
3459 team_id = event.get("team_id")
3460 if team_id:
3461 validate_post_match_access(current_user, current_match, team_id)
3462 else:
3463 if not auth_manager.can_edit_match(
3464 current_user, current_match["home_team_id"], current_match["away_team_id"]
3465 ):
3466 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
3468 # Decrement player card stats
3469 player_id = event.get("player_id")
3470 if player_id:
3471 stats = player_stats_dao.get_match_stats(player_id, match_id)
3472 if stats:
3473 card_field = "red_cards" if event["event_type"] == "red_card" else "yellow_cards"
3474 new_count = max(0, stats.get(card_field, 0) - 1)
3475 player_stats_dao.client.table("player_match_stats").update({card_field: new_count}).eq(
3476 "player_id", player_id
3477 ).eq("match_id", match_id).execute()
3479 user_id = current_user.get("user_id") or current_user.get("id")
3480 success = match_event_dao.soft_delete_event(event_id, deleted_by=user_id)
3481 if not success:
3482 raise HTTPException(status_code=500, detail="Failed to delete card event")
3484 logger.info(
3485 "post_match_card_removed",
3486 match_id=match_id,
3487 event_id=event_id,
3488 )
3490 return {"detail": "Card removed"}
3492 except HTTPException:
3493 raise
3494 except Exception as e:
3495 logger.error(f"Error removing post-match card: {e!s}", exc_info=True)
3496 raise HTTPException(status_code=500, detail=str(e)) from e
3499@app.get("/api/matches/{match_id}/post-match/stats/{team_id}")
3500async def post_match_get_stats(
3501 match_id: int,
3502 team_id: int,
3503 current_user: dict[str, Any] = Depends(get_current_user_required),
3504):
3505 """Get player stats for a team in a completed match."""
3506 try:
3507 current_match = match_dao.get_match_by_id(match_id)
3508 if not current_match:
3509 raise HTTPException(status_code=404, detail="Match not found")
3511 if team_id not in [current_match["home_team_id"], current_match["away_team_id"]]:
3512 raise HTTPException(status_code=400, detail="Team must be a participant in this match")
3514 stats = player_stats_dao.get_team_match_stats(match_id, team_id)
3515 return {"stats": stats}
3517 except HTTPException:
3518 raise
3519 except Exception as e:
3520 logger.error(f"Error getting post-match stats: {e!s}", exc_info=True)
3521 raise HTTPException(status_code=500, detail=str(e)) from e
3524@app.put("/api/matches/{match_id}/post-match/stats/{team_id}")
3525async def post_match_update_stats(
3526 match_id: int,
3527 team_id: int,
3528 batch: BatchPlayerStatsUpdate,
3529 current_user: dict[str, Any] = Depends(require_match_management_permission),
3530):
3531 """Batch update player stats (started, minutes_played) for a team in a completed match."""
3532 try:
3533 current_match = match_dao.get_match_by_id(match_id)
3534 if not current_match:
3535 raise HTTPException(status_code=404, detail="Match not found")
3537 validate_post_match_access(current_user, current_match, team_id)
3539 player_stats = [
3540 {
3541 "player_id": entry.player_id,
3542 "started": entry.started,
3543 "played": entry.played,
3544 "minutes_played": entry.minutes_played,
3545 "yellow_cards": entry.yellow_cards,
3546 "red_cards": entry.red_cards,
3547 }
3548 for entry in batch.players
3549 ]
3551 success = player_stats_dao.batch_update_stats(match_id, player_stats)
3552 if not success:
3553 raise HTTPException(status_code=500, detail="Failed to update player stats")
3555 logger.info(
3556 "post_match_stats_updated",
3557 match_id=match_id,
3558 team_id=team_id,
3559 player_count=len(batch.players),
3560 )
3562 # Return updated stats
3563 stats = player_stats_dao.get_team_match_stats(match_id, team_id)
3564 return {"stats": stats}
3566 except HTTPException:
3567 raise
3568 except Exception as e:
3569 logger.error(f"Error updating post-match stats: {e!s}", exc_info=True)
3570 raise HTTPException(status_code=500, detail=str(e)) from e
3573# === Enhanced League Table Endpoint ===
3576@app.get("/api/table")
3577async def get_table(
3578 current_user: dict[str, Any] = Depends(get_current_user_required),
3579 season_id: int | None = Query(None, description="Filter by season ID"),
3580 age_group_id: int | None = Query(None, description="Filter by age group ID"),
3581 division_id: int | None = Query(None, description="Filter by division ID"),
3582 match_type: str | None = Query("League", description="Match type (League, Tournament, etc.)"),
3583):
3584 """Get league table with enhanced filtering."""
3585 try:
3586 # If no season specified, use current season (or most recent as fallback)
3587 if not season_id:
3588 current_season = season_dao.get_current_season()
3589 if current_season:
3590 season_id = current_season["id"]
3591 else:
3592 # Fallback to most recent season (sorted by start_date desc)
3593 seasons = season_dao.get_all_seasons()
3594 if seasons:
3595 season_id = seasons[0]["id"]
3597 table = match_dao.get_league_table(
3598 season_id=season_id,
3599 age_group_id=age_group_id,
3600 division_id=division_id,
3601 match_type=match_type,
3602 )
3604 logger.info(
3605 "League table query",
3606 season_id=season_id,
3607 age_group_id=age_group_id,
3608 division_id=division_id,
3609 match_type=match_type,
3610 rows_returned=len(table) if isinstance(table, list) else "N/A",
3611 )
3613 return table
3614 except Exception as e:
3615 logger.error(f"Error generating league table: {e!s}", exc_info=True)
3616 raise HTTPException(status_code=500, detail=str(e)) from e
3619@app.get("/api/leaderboards/goals")
3620async def get_goals_leaderboard(
3621 current_user: dict[str, Any] = Depends(get_current_user_required),
3622 season_id: int = Query(..., description="Season ID (required)"),
3623 league_id: int | None = Query(None, description="Filter by league ID"),
3624 division_id: int | None = Query(None, description="Filter by division ID"),
3625 age_group_id: int | None = Query(None, description="Filter by age group ID"),
3626 match_type_id: int | None = Query(None, description="Filter by match type ID (e.g. 4 for Playoff)"),
3627 limit: int = Query(50, description="Maximum results", ge=1, le=100),
3628):
3629 """Get goals leaderboard - top scorers filtered by season/league/division/age group/match type."""
3630 try:
3631 leaderboard = player_stats_dao.get_goals_leaderboard(
3632 season_id=season_id,
3633 league_id=league_id,
3634 division_id=division_id,
3635 age_group_id=age_group_id,
3636 match_type_id=match_type_id,
3637 limit=limit,
3638 )
3640 logger.info(
3641 "Goals leaderboard query",
3642 season_id=season_id,
3643 league_id=league_id,
3644 division_id=division_id,
3645 age_group_id=age_group_id,
3646 match_type_id=match_type_id,
3647 limit=limit,
3648 rows_returned=len(leaderboard),
3649 )
3651 return leaderboard
3652 except Exception as e:
3653 logger.error(f"Error generating goals leaderboard: {e!s}", exc_info=True)
3654 raise HTTPException(status_code=500, detail=str(e)) from e
3657# === Admin CRUD Endpoints ===
3660# Age Groups CRUD
3661@app.post("/api/age-groups")
3662async def create_age_group(age_group: AgeGroupCreate, current_user: dict[str, Any] = Depends(require_admin)):
3663 """Create a new age group (admin only)."""
3664 try:
3665 result = season_dao.create_age_group(age_group.name)
3666 return result
3667 except Exception as e:
3668 logger.error(f"Error creating age group: {e!s}", exc_info=True)
3669 raise HTTPException(status_code=500, detail=str(e)) from e
3672@app.put("/api/age-groups/{age_group_id}")
3673async def update_age_group(
3674 age_group_id: int,
3675 age_group: AgeGroupUpdate,
3676 current_user: dict[str, Any] = Depends(require_admin),
3677):
3678 """Update an age group (admin only)."""
3679 try:
3680 result = season_dao.update_age_group(age_group_id, age_group.name)
3681 if not result:
3682 raise HTTPException(status_code=404, detail="Age group not found")
3683 return result
3684 except HTTPException:
3685 raise
3686 except Exception as e:
3687 logger.error(f"Error updating age group: {e!s}", exc_info=True)
3688 raise HTTPException(status_code=500, detail=str(e)) from e
3691@app.delete("/api/age-groups/{age_group_id}")
3692async def delete_age_group(age_group_id: int, current_user: dict[str, Any] = Depends(require_admin)):
3693 """Delete an age group (admin only)."""
3694 try:
3695 result = season_dao.delete_age_group(age_group_id)
3696 if not result:
3697 raise HTTPException(status_code=404, detail="Age group not found")
3698 return {"message": "Age group deleted successfully"}
3699 except HTTPException:
3700 raise
3701 except Exception as e:
3702 logger.error(f"Error deleting age group: {e!s}", exc_info=True)
3703 raise HTTPException(status_code=500, detail=str(e)) from e
3706# Seasons CRUD
3707@app.post("/api/seasons")
3708async def create_season(season: SeasonCreate, current_user: dict[str, Any] = Depends(require_admin)):
3709 """Create a new season (admin only)."""
3710 try:
3711 result = season_dao.create_season(season.name, season.start_date, season.end_date)
3712 return result
3713 except Exception as e:
3714 logger.error(f"Error creating season: {e!s}", exc_info=True)
3715 raise HTTPException(status_code=500, detail=str(e)) from e
3718@app.put("/api/seasons/{season_id}")
3719async def update_season(season_id: int, season: SeasonUpdate, current_user: dict[str, Any] = Depends(require_admin)):
3720 """Update a season (admin only)."""
3721 try:
3722 result = season_dao.update_season(season_id, season.name, season.start_date, season.end_date)
3723 if not result:
3724 raise HTTPException(status_code=404, detail="Season not found")
3725 return result
3726 except HTTPException:
3727 raise
3728 except Exception as e:
3729 logger.error(f"Error updating season: {e!s}", exc_info=True)
3730 raise HTTPException(status_code=500, detail=str(e)) from e
3733@app.delete("/api/seasons/{season_id}")
3734async def delete_season(season_id: int, current_user: dict[str, Any] = Depends(require_admin)):
3735 """Delete a season (admin only)."""
3736 try:
3737 result = season_dao.delete_season(season_id)
3738 if not result:
3739 raise HTTPException(status_code=404, detail="Season not found")
3740 return {"message": "Season deleted successfully"}
3741 except HTTPException:
3742 raise
3743 except Exception as e:
3744 logger.error(f"Error deleting season: {e!s}", exc_info=True)
3745 raise HTTPException(status_code=500, detail=str(e)) from e
3748# Leagues CRUD
3749@app.get("/api/leagues")
3750async def get_leagues():
3751 """Get all leagues (public access)."""
3752 try:
3753 leagues = league_dao.get_all_leagues()
3754 return leagues
3755 except Exception as e:
3756 logger.error(f"Error fetching leagues: {e!s}", exc_info=True)
3757 raise HTTPException(status_code=500, detail=str(e)) from e
3760@app.get("/api/leagues/{league_id}")
3761async def get_league(league_id: int):
3762 """Get league by ID (public access)."""
3763 try:
3764 league = league_dao.get_league_by_id(league_id)
3765 if not league:
3766 raise HTTPException(status_code=404, detail="League not found")
3767 return league
3768 except HTTPException:
3769 raise
3770 except Exception as e:
3771 logger.error(f"Error fetching league {league_id}: {e!s}", exc_info=True)
3772 raise HTTPException(status_code=500, detail=str(e)) from e
3775@app.post("/api/leagues")
3776async def create_league(league: LeagueCreate, current_user: dict[str, Any] = Depends(require_admin)):
3777 """Create a new league (admin only)."""
3778 try:
3779 league_data = league.model_dump()
3780 result = league_dao.create_league(league_data)
3781 return result
3782 except Exception as e:
3783 logger.error(f"Error creating league: {e!s}", exc_info=True)
3784 raise HTTPException(status_code=400, detail=str(e)) from e
3787@app.put("/api/leagues/{league_id}")
3788async def update_league(
3789 league_id: int,
3790 league: LeagueUpdate,
3791 current_user: dict[str, Any] = Depends(require_admin),
3792):
3793 """Update a league (admin only)."""
3794 try:
3795 # Only include fields that were actually provided
3796 league_data = league.model_dump(exclude_unset=True)
3797 result = league_dao.update_league(league_id, league_data)
3798 if not result:
3799 raise HTTPException(status_code=404, detail="League not found")
3800 return result
3801 except HTTPException:
3802 raise
3803 except Exception as e:
3804 logger.error(f"Error updating league: {e!s}", exc_info=True)
3805 raise HTTPException(status_code=500, detail=str(e)) from e
3808@app.delete("/api/leagues/{league_id}")
3809async def delete_league(league_id: int, current_user: dict[str, Any] = Depends(require_admin)):
3810 """Delete a league (admin only). Will fail if divisions exist."""
3811 try:
3812 league_dao.delete_league(league_id)
3813 return {"message": "League deleted successfully"}
3814 except Exception as e:
3815 logger.error(f"Error deleting league: {e!s}", exc_info=True)
3816 if "foreign key" in str(e).lower():
3817 raise HTTPException(
3818 status_code=400,
3819 detail="Cannot delete league with existing divisions. Delete divisions first.",
3820 ) from e
3821 raise HTTPException(status_code=500, detail=str(e)) from e
3824# Divisions CRUD
3825@app.post("/api/divisions")
3826async def create_division(division: DivisionCreate, current_user: dict[str, Any] = Depends(require_admin)):
3827 """Create a new division (admin only)."""
3828 try:
3829 division_data = division.model_dump()
3830 result = league_dao.create_division(division_data)
3831 return result
3832 except Exception as e:
3833 logger.error(f"Error creating division: {e!s}", exc_info=True)
3834 raise HTTPException(status_code=400, detail=str(e)) from e
3837@app.put("/api/divisions/{division_id}")
3838async def update_division(
3839 request: Request,
3840 division_id: int,
3841 division: DivisionUpdate,
3842 current_user: dict[str, Any] = Depends(require_admin),
3843):
3844 """Update a division (admin only)."""
3845 try:
3846 division_data = division.model_dump(exclude_unset=True)
3847 result = league_dao.update_division(division_id, division_data)
3848 if not result:
3849 raise HTTPException(status_code=404, detail="Division not found")
3850 return result
3851 except HTTPException:
3852 raise
3853 except Exception as e:
3854 logger.error(f"Error updating division: {e!s}", exc_info=True)
3855 raise HTTPException(status_code=500, detail=str(e)) from e
3858@app.delete("/api/divisions/{division_id}")
3859async def delete_division(division_id: int, current_user: dict[str, Any] = Depends(require_admin)):
3860 """Delete a division (admin only)."""
3861 try:
3862 result = league_dao.delete_division(division_id)
3863 if not result:
3864 raise HTTPException(status_code=404, detail="Division not found")
3865 return {"message": "Division deleted successfully"}
3866 except HTTPException:
3867 raise
3868 except Exception as e:
3869 logger.error(f"Error deleting division: {e!s}", exc_info=True)
3870 raise HTTPException(status_code=500, detail=str(e)) from e
3873# Teams CRUD (update existing)
3874@app.put("/api/teams/{team_id}")
3875async def update_team(
3876 request: Request,
3877 team_id: int,
3878 team: TeamUpdate,
3879 current_user: dict[str, Any] = Depends(require_admin),
3880):
3881 """Update a team (admin only)."""
3882 try:
3883 logger.info(f"Updating team {team_id}: name={team.name}, club_id={team.club_id}")
3884 result = team_dao.update_team(team_id, team.name, team.city, team.academy_team, team.club_id)
3885 if not result:
3886 raise HTTPException(status_code=404, detail="Team not found")
3887 return result
3888 except HTTPException:
3889 raise
3890 except Exception as e:
3891 logger.error(f"Error updating team: {e!s}", exc_info=True)
3892 raise HTTPException(status_code=500, detail=str(e)) from e
3895@app.delete("/api/teams/{team_id}")
3896async def delete_team(team_id: int, current_user: dict[str, Any] = Depends(require_admin)):
3897 """Delete a team (admin only)."""
3898 try:
3899 result = team_dao.delete_team(team_id)
3900 if not result:
3901 raise HTTPException(status_code=404, detail="Team not found")
3902 return {"message": "Team deleted successfully"}
3903 except HTTPException:
3904 raise
3905 except Exception as e:
3906 logger.error(f"Error deleting team: {e!s}", exc_info=True)
3907 raise HTTPException(status_code=500, detail=str(e)) from e
3910@app.post("/api/teams/{team_id}/match-types")
3911async def add_team_match_type_participation(
3912 team_id: int,
3913 mapping: TeamMatchTypeMapping,
3914 current_user: dict[str, Any] = Depends(require_admin),
3915):
3916 """Add a team's participation in a specific match type and age group (admin only)."""
3917 try:
3918 success = team_dao.add_team_match_type_participation(team_id, mapping.match_type_id, mapping.age_group_id)
3919 if success:
3920 return {"message": "Team match type participation added successfully"}
3921 else:
3922 raise HTTPException(status_code=500, detail="Failed to add team match type participation")
3923 except HTTPException:
3924 raise
3925 except Exception as e:
3926 logger.error(f"Error adding team match type participation: {e!s}", exc_info=True)
3927 raise HTTPException(status_code=500, detail=str(e)) from e
3930@app.delete("/api/teams/{team_id}/match-types/{match_type_id}/{age_group_id}")
3931async def remove_team_match_type_participation(
3932 team_id: int,
3933 match_type_id: int,
3934 age_group_id: int,
3935 current_user: dict[str, Any] = Depends(require_admin),
3936):
3937 """Remove a team's participation in a specific match type and age group (admin only)."""
3938 try:
3939 success = team_dao.remove_team_match_type_participation(team_id, match_type_id, age_group_id)
3940 if success:
3941 return {"message": "Team match type participation removed successfully"}
3942 else:
3943 raise HTTPException(status_code=500, detail="Failed to remove team match type participation")
3944 except HTTPException:
3945 raise
3946 except Exception as e:
3947 logger.error(f"Error removing team match type participation: {e!s}", exc_info=True)
3948 raise HTTPException(status_code=500, detail=str(e)) from e
3951# === Club Endpoints ===
3954@app.get("/api/clubs")
3955async def get_clubs(include_teams: bool = True, current_user: dict[str, Any] = Depends(get_current_user_required)):
3956 """Get all clubs.
3958 Args:
3959 include_teams: If true, enriches clubs with their teams list (default: true)
3961 Returns:
3962 List of clubs with optional team details
3963 """
3964 try:
3965 # Get all clubs from clubs table
3966 logger.info(f"/api/clubs: Calling get_all_clubs DAO with include_team_counts: {not include_teams}")
3967 clubs = club_dao.get_all_clubs(include_team_counts=not include_teams)
3968 logger.info(f"/api/clubs: Fetched {len(clubs)} clubs")
3970 if not include_teams:
3971 # Return clubs without team details (faster for dropdowns)
3972 return clubs
3974 # Enrich with team details if requested (batched)
3975 club_ids = [club["id"] for club in clubs]
3976 teams = team_dao.get_teams_by_club_ids(club_ids)
3977 teams_by_club_id: dict[int, list[dict]] = {}
3978 for team in teams:
3979 club_id = team.get("club_id")
3980 if club_id is None:
3981 continue
3982 teams_by_club_id.setdefault(club_id, []).append(team)
3984 enriched_clubs = []
3985 for club in clubs:
3986 club_teams = teams_by_club_id.get(club["id"], [])
3987 enriched_club = {
3988 **club,
3989 "teams": club_teams,
3990 "team_count": len(club_teams),
3991 }
3992 enriched_clubs.append(enriched_club)
3993 logger.debug(f"Club '{club.get('name')}' has {len(club_teams)} teams")
3995 return enriched_clubs
3996 except Exception as e:
3997 logger.error(f"Error fetching clubs: {e!s}", exc_info=True)
3998 raise HTTPException(status_code=500, detail=str(e)) from e
4001@app.get("/api/clubs/{club_id}")
4002async def get_club(club_id: int, current_user: dict[str, Any] = Depends(get_current_user_required)):
4003 """Get a single club by ID."""
4004 try:
4005 clubs = club_dao.get_all_clubs()
4006 club = next((c for c in clubs if c["id"] == club_id), None)
4007 if not club:
4008 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found")
4009 return club
4010 except HTTPException:
4011 raise
4012 except Exception as e:
4013 logger.error(f"Error fetching club: {e!s}", exc_info=True)
4014 raise HTTPException(status_code=500, detail=str(e)) from e
4017@app.get("/api/clubs/{club_id}/teams")
4018async def get_club_teams(
4019 club_id: int,
4020 include_stats: bool = True,
4021 current_user: dict[str, Any] = Depends(get_current_user_required),
4022):
4023 """Get all teams for a specific club."""
4024 try:
4025 teams = team_dao.get_club_teams(club_id) if include_stats else team_dao.get_club_teams_basic(club_id)
4026 if not teams:
4027 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found")
4029 return teams
4030 except HTTPException:
4031 raise
4032 except Exception as e:
4033 logger.error(f"Error fetching club teams: {e!s}", exc_info=True)
4034 raise HTTPException(status_code=500, detail=str(e)) from e
4037@app.post("/api/clubs")
4038async def create_club(club: Club, current_user: dict[str, Any] = Depends(require_admin)):
4039 """Create a new club (admin only)."""
4040 try:
4041 new_club = club_dao.create_club(
4042 name=club.name,
4043 city=club.city,
4044 website=club.website,
4045 description=club.description,
4046 logo_url=club.logo_url,
4047 primary_color=club.primary_color,
4048 secondary_color=club.secondary_color,
4049 )
4050 logger.info(f"Created new club: {new_club['name']}")
4051 return new_club
4052 except Exception as e:
4053 error_str = str(e)
4054 logger.error(f"Error creating club: {error_str}", exc_info=True)
4056 # Check for duplicate key constraint violation
4057 if "duplicate key value violates unique constraint" in error_str.lower():
4058 if "clubs_name_unique" in error_str.lower() or "clubs_name_key" in error_str.lower():
4059 raise HTTPException(
4060 status_code=409,
4061 detail=f"A club with the name '{club.name}' already exists. Please use a different name.",
4062 ) from e
4063 else:
4064 raise HTTPException(status_code=409, detail="A club with this information already exists.") from e
4066 # For any other unexpected errors, return 500
4067 raise HTTPException(status_code=500, detail="An unexpected error occurred while creating the club.") from e
4070@app.put("/api/clubs/{club_id}")
4071async def update_club(club_id: int, club: Club, current_user: dict[str, Any] = Depends(require_admin)):
4072 """Update a club (admin only)."""
4073 try:
4074 updated_club = club_dao.update_club(
4075 club_id=club_id,
4076 name=club.name,
4077 city=club.city,
4078 website=club.website,
4079 description=club.description,
4080 logo_url=club.logo_url,
4081 primary_color=club.primary_color,
4082 secondary_color=club.secondary_color,
4083 )
4084 if not updated_club:
4085 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found")
4086 logger.info(f"Updated club: {updated_club['name']}")
4087 return updated_club
4088 except HTTPException:
4089 raise
4090 except Exception as e:
4091 logger.error(f"Error updating club: {e!s}", exc_info=True)
4092 raise HTTPException(status_code=500, detail=str(e)) from e
4095@app.post("/api/clubs/{club_id}/logo")
4096async def upload_club_logo(
4097 club_id: int,
4098 file: UploadFile = File(...),
4099 current_user: dict[str, Any] = Depends(require_admin),
4100):
4101 """Upload a logo image for a club (admin only).
4103 Uploads the image to Supabase Storage and returns the public URL.
4104 Accepted formats: PNG, JPG/JPEG. Max size: 2MB.
4105 """
4106 # Validate file type
4107 allowed_types = ["image/png", "image/jpeg", "image/jpg"]
4108 if file.content_type not in allowed_types:
4109 raise HTTPException(
4110 status_code=400,
4111 detail=f"Invalid file type. Allowed types: PNG, JPG. Got: {file.content_type}",
4112 )
4114 # Read file content
4115 content = await file.read()
4117 # Validate file size (2MB max)
4118 max_size = 2 * 1024 * 1024 # 2MB
4119 if len(content) > max_size:
4120 raise HTTPException(
4121 status_code=400,
4122 detail=f"File too large. Maximum size is 2MB. Got: {len(content) / 1024 / 1024:.2f}MB",
4123 )
4125 # Determine file extension
4126 ext = "png" if file.content_type == "image/png" else "jpg"
4127 file_path = f"{club_id}.{ext}"
4129 try:
4130 # Get the Supabase client from the DAO
4131 storage = match_dao.client.storage
4133 # Upload base image to club-logos bucket (upsert to overwrite existing)
4134 storage.from_("club-logos").upload(
4135 file_path, content, file_options={"content-type": file.content_type, "upsert": "true"}
4136 )
4138 # Generate and upload size variants (_sm=64px, _md=128px) for PNGs
4139 if ext == "png":
4140 from io import BytesIO
4142 from PIL import Image as PILImage
4144 base_img = PILImage.open(BytesIO(content))
4145 for suffix, px in [("_sm", 64), ("_md", 128)]:
4146 variant = base_img.resize((px, px), PILImage.LANCZOS)
4147 buf = BytesIO()
4148 variant.save(buf, "PNG")
4149 variant_path = f"{club_id}{suffix}.png"
4150 storage.from_("club-logos").upload(
4151 variant_path,
4152 buf.getvalue(),
4153 file_options={"content-type": "image/png", "upsert": "true"},
4154 )
4155 logger.info(f"Uploaded size variants for club {club_id}")
4157 # Get public URL (points to base image)
4158 public_url = storage.from_("club-logos").get_public_url(file_path)
4160 # Update the club with the new logo URL
4161 updated_club = club_dao.update_club(club_id=club_id, logo_url=public_url)
4163 if not updated_club:
4164 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found")
4166 logger.info(f"Uploaded logo for club {club_id}: {public_url}")
4167 return {"logo_url": public_url, "club": updated_club}
4169 except HTTPException:
4170 raise
4171 except Exception as e:
4172 logger.error(f"Error uploading club logo: {e!s}", exc_info=True)
4173 raise HTTPException(status_code=500, detail=f"Failed to upload logo: {e!s}") from e
4176@app.delete("/api/clubs/{club_id}")
4177async def delete_club(club_id: int, current_user: dict[str, Any] = Depends(require_admin)):
4178 """Delete a club (admin only).
4180 Note: This will fail if there are teams still associated with this club.
4181 """
4182 try:
4183 success = club_dao.delete_club(club_id)
4184 if success:
4185 logger.info(f"Deleted club with id {club_id}")
4186 return {"message": "Club deleted successfully"}
4187 else:
4188 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found")
4189 except HTTPException:
4190 raise
4191 except Exception as e:
4192 error_str = str(e)
4193 logger.error(f"Error deleting club: {error_str}", exc_info=True)
4195 # Check if it's a foreign key constraint violation
4196 if "foreign key constraint" in error_str.lower() or "violates" in error_str.lower():
4197 raise HTTPException(
4198 status_code=409,
4199 detail="Cannot delete club because it has teams associated with it. Remove team associations first.",
4200 ) from e
4202 raise HTTPException(status_code=500, detail="An unexpected error occurred while deleting the club.") from e
4205# Team Mappings CRUD
4206@app.post("/api/team-mappings")
4207async def create_team_mapping(
4208 mapping: TeamMappingCreate,
4209 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4210):
4211 """Create a team mapping (admin or club_manager for their club's teams)."""
4212 try:
4213 user_role = current_user.get("role")
4214 user_club_id = current_user.get("club_id")
4216 # Club managers can only add mappings to their own club's teams
4217 if user_role == "club_manager":
4218 team = team_dao.get_team_by_id(mapping.team_id)
4219 if not team or team.get("club_id") != user_club_id:
4220 raise HTTPException(status_code=403, detail="You can only manage teams in your club")
4222 result = team_dao.create_team_mapping(mapping.team_id, mapping.age_group_id, mapping.division_id)
4223 return result
4224 except HTTPException:
4225 raise
4226 except Exception as e:
4227 logger.error(f"Error creating team mapping: {e!s}", exc_info=True)
4228 raise HTTPException(status_code=500, detail=str(e)) from e
4231@app.delete("/api/team-mappings/{team_id}/{age_group_id}/{division_id}")
4232async def delete_team_mapping(
4233 team_id: int,
4234 age_group_id: int,
4235 division_id: int,
4236 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4237):
4238 """Delete a team mapping (admin or club_manager for their club's teams)."""
4239 try:
4240 user_role = current_user.get("role")
4241 user_club_id = current_user.get("club_id")
4243 # Club managers can only delete mappings from their own club's teams
4244 if user_role == "club_manager":
4245 team = team_dao.get_team_by_id(team_id)
4246 if not team or team.get("club_id") != user_club_id:
4247 raise HTTPException(status_code=403, detail="You can only manage teams in your club")
4249 result = team_dao.delete_team_mapping(team_id, age_group_id, division_id)
4250 if not result:
4251 raise HTTPException(status_code=404, detail="Team mapping not found")
4252 return {"message": "Team mapping deleted successfully"}
4253 except HTTPException:
4254 raise
4255 except Exception as e:
4256 logger.error(f"Error deleting team mapping: {e!s}", exc_info=True)
4257 raise HTTPException(status_code=500, detail=str(e)) from e
4260# === Match-Scraper Integration Endpoints ===
4263@app.post("/api/matches/submit")
4264async def submit_match_async(
4265 match_data: MatchSubmissionData,
4266 current_user: dict[str, Any] = Depends(require_match_management_permission),
4267):
4268 """
4269 Submit match data for async processing via Celery.
4271 This endpoint queues match data to Celery workers for:
4272 - Validation
4273 - Team lookup
4274 - Duplicate detection
4275 - Database insertion
4277 Returns a task ID for tracking the processing status.
4279 Requires: admin, team-manager, or service account with manage_matches permission
4280 """
4281 try:
4282 # Import Celery task
4283 from celery_tasks.match_tasks import process_match_data
4285 # Log the submission
4286 user_identifier = current_user.get("username") or current_user.get("service_name", "unknown")
4287 logger.info(f"POST /api/matches/submit - User: {user_identifier}")
4288 logger.info(f"POST /api/matches/submit - Match: {match_data.home_team} vs {match_data.away_team}")
4290 # Convert Pydantic model to dict for Celery
4291 match_dict = match_data.model_dump()
4293 # Queue the task to Celery
4294 task = process_match_data.delay(match_dict)
4296 logger.info(f"Queued match processing task: {task.id}")
4298 return {
4299 "success": True,
4300 "message": "Match data queued for processing",
4301 "task_id": task.id,
4302 "status_url": f"/api/matches/task/{task.id}",
4303 "match": {
4304 "home_team": match_data.home_team,
4305 "away_team": match_data.away_team,
4306 "match_date": match_data.match_date,
4307 },
4308 }
4310 except Exception as e:
4311 logger.error(f"Error submitting match for async processing: {e}", exc_info=True)
4312 raise HTTPException(status_code=500, detail=f"Failed to queue match: {e!s}") from e
4315@app.get("/api/matches/task/{task_id}")
4316async def get_task_status(task_id: str, current_user: dict[str, Any] = Depends(require_match_management_permission)):
4317 """
4318 Get the status of a queued match processing task.
4320 Returns:
4321 - state: PENDING, STARTED, SUCCESS, FAILURE, RETRY
4322 - result: Task result if completed successfully
4323 - error: Error message if failed
4324 """
4325 try:
4326 from celery.result import AsyncResult
4328 task = AsyncResult(task_id)
4330 response = {
4331 "task_id": task_id,
4332 "state": task.state,
4333 "ready": task.ready(),
4334 }
4336 if task.ready():
4337 if task.successful():
4338 response["result"] = task.result
4339 response["message"] = "Task completed successfully"
4340 else:
4341 response["error"] = str(task.info)
4342 response["message"] = "Task failed"
4343 else:
4344 response["message"] = "Task is still processing"
4346 return response
4348 except Exception as e:
4349 logger.error(f"Error retrieving task status: {e}", exc_info=True)
4350 raise HTTPException(status_code=500, detail=f"Failed to get task status: {e!s}") from e
4353@app.post("/api/match-scraper/matches")
4354async def add_or_update_scraped_match(
4355 request: Request,
4356 match: EnhancedMatch,
4357 external_match_id: str,
4358 current_user: dict[str, Any] = Depends(require_match_management_permission),
4359):
4360 """Add or update a match from match-scraper with intelligent duplicate handling."""
4361 try:
4362 logger.info(f"POST /api/match-scraper/matches - External Match ID: {external_match_id}")
4363 logger.info(f"POST /api/match-scraper/matches - Match data: {match.model_dump()}")
4365 # Validate division_id for League matches
4366 match_type = match_type_dao.get_match_type_by_id(match.match_type_id)
4367 if match_type and match_type.get("name") == "League" and match.division_id is None:
4368 raise HTTPException(status_code=422, detail="division_id is required for League matches")
4370 # Check if match already exists by external_match_id
4371 existing_match_response = await check_match(
4372 date=match.match_date,
4373 homeTeam=str(match.home_team_id),
4374 awayTeam=str(match.away_team_id),
4375 season_id=match.season_id,
4376 age_group_id=match.age_group_id,
4377 match_type_id=match.match_type_id,
4378 division_id=match.division_id,
4379 external_match_id=external_match_id,
4380 )
4382 if existing_match_response["exists"]:
4383 existing_match_id = existing_match_response["match_id"]
4384 logger.info(f"Updating existing match {existing_match_id} with external_match_id {external_match_id}")
4386 # Update existing match
4387 updated_match = match_dao.update_match(
4388 match_id=existing_match_id,
4389 home_team_id=match.home_team_id,
4390 away_team_id=match.away_team_id,
4391 match_date=match.match_date,
4392 home_score=match.home_score,
4393 away_score=match.away_score,
4394 season_id=match.season_id,
4395 age_group_id=match.age_group_id,
4396 match_type_id=match.match_type_id,
4397 division_id=match.division_id,
4398 updated_by=current_user.get("user_id"), # Track scraper updates
4399 )
4401 if updated_match:
4402 return {
4403 "message": "Match updated successfully",
4404 "action": "updated",
4405 "match_id": existing_match_id,
4406 "external_match_id": external_match_id,
4407 }
4408 else:
4409 raise HTTPException(status_code=500, detail="Failed to update match")
4410 else:
4411 logger.info(f"Creating new match with external_match_id {external_match_id}")
4413 # Create new match with external_match_id
4414 success = match_dao.add_match_with_external_id(
4415 home_team_id=match.home_team_id,
4416 away_team_id=match.away_team_id,
4417 match_date=match.match_date,
4418 home_score=match.home_score,
4419 away_score=match.away_score,
4420 season_id=match.season_id,
4421 age_group_id=match.age_group_id,
4422 match_type_id=match.match_type_id,
4423 division_id=match.division_id,
4424 external_match_id=external_match_id,
4425 created_by=current_user.get("user_id"), # Track scraper creation
4426 source="match-scraper", # Mark as scraped data
4427 )
4429 if success:
4430 return {
4431 "message": "Match created successfully",
4432 "action": "created",
4433 "external_match_id": external_match_id,
4434 }
4435 else:
4436 raise HTTPException(status_code=500, detail="Failed to create match")
4438 except Exception as e:
4439 logger.error(f"Error in match-scraper match endpoint: {e!s}", exc_info=True)
4440 raise HTTPException(status_code=500, detail=str(e)) from e
4443# === Backward Compatibility Endpoints ===
4446@app.get("/api/check-match")
4447async def check_match(
4448 current_user: dict[str, Any] = Depends(get_current_user_required),
4449 date: str = Query(..., description="Match date"),
4450 homeTeam: str = Query(..., description="Home team name"),
4451 awayTeam: str = Query(..., description="Away team name"),
4452 season_id: int | None = None,
4453 age_group_id: int | None = None,
4454 match_type_id: int | None = None,
4455 division_id: int | None = None,
4456 external_match_id: str | None = None,
4457):
4458 """Enhanced match existence check with comprehensive duplicate detection."""
4459 try:
4460 # First check by external_match_id if provided (for external systems like match-scraper)
4461 if external_match_id:
4462 matches = match_dao.get_all_matches()
4463 for match in matches:
4464 if match.get("external_match_id") == external_match_id:
4465 return {
4466 "exists": True,
4467 "match_id": match.get("id"),
4468 "match": match,
4469 "match_type": "external_match_id",
4470 }
4472 # Check for duplicate based on comprehensive match context
4473 matches = match_dao.get_all_matches()
4474 for match in matches:
4475 # Basic match: date and teams
4476 basic_match = (
4477 str(match.get("match_date")) == date
4478 and str(match.get("home_team_id")) == homeTeam
4479 and str(match.get("away_team_id")) == awayTeam
4480 )
4482 if basic_match:
4483 # Enhanced match: include season, age group, match type, division if provided
4484 enhanced_match = True
4486 if season_id and match.get("season_id") != season_id:
4487 enhanced_match = False
4488 if age_group_id and match.get("age_group_id") != age_group_id:
4489 enhanced_match = False
4490 if match_type_id and match.get("match_type_id") != match_type_id:
4491 enhanced_match = False
4492 if division_id and match.get("division_id") != division_id:
4493 enhanced_match = False
4495 return {
4496 "exists": True,
4497 "match_id": match.get("id"),
4498 "match": match,
4499 "match_type": "enhanced_context" if enhanced_match else "basic_context",
4500 "enhanced_match": enhanced_match,
4501 }
4503 return {"exists": False}
4504 except Exception as e:
4505 logger.error(f"Error checking match: {e!s}", exc_info=True)
4506 raise HTTPException(status_code=500, detail=str(e)) from e
4509# === Team Roster & Player Profile Endpoints ===
4512@app.get("/api/teams/{team_id}/players")
4513async def get_team_players(team_id: int, current_user: dict[str, Any] = Depends(get_current_user_required)):
4514 """
4515 Get all players on a team for the team roster page.
4517 Players can view rosters for any team within their club.
4518 This allows browsing teammates across different age groups.
4520 Returns player data needed for player cards:
4521 - id, display_name, player_number, positions
4522 - photo fields (photo_1_url, etc.)
4523 - customization fields (overlay_style, colors)
4524 - social media handles
4525 """
4526 try:
4527 # Get the requested team's club_id
4528 requested_team = team_dao.get_team_by_id(team_id)
4529 if not requested_team:
4530 raise HTTPException(status_code=404, detail="Team not found")
4531 requested_club_id = requested_team.get("club_id")
4533 # Get user's club_id from their profile's team_id, club_id, or player_team_history
4534 user_club_ids = set()
4536 # Check user's direct club_id (for club managers/fans)
4537 if current_user.get("club_id"):
4538 user_club_ids.add(current_user["club_id"])
4540 # Check user's team's club_id (for team players/managers/fans)
4541 if current_user.get("team_id"):
4542 user_team = team_dao.get_team_by_id(current_user["team_id"])
4543 if user_team and user_team.get("club_id"):
4544 user_club_ids.add(user_team["club_id"])
4546 # Also check player_team_history for current team assignments.
4547 # Players added via roster manager have entries here but not in user_profiles.team_id.
4548 if not user_club_ids:
4549 current_teams = player_dao.get_all_current_player_teams(current_user["user_id"])
4550 for ct in current_teams:
4551 team_data = ct.get("team")
4552 if team_data:
4553 club = team_data.get("club")
4554 if club and club.get("id"):
4555 user_club_ids.add(club["id"])
4557 # Authorization: user must belong to a team in the same club
4558 if requested_club_id not in user_club_ids:
4559 raise HTTPException(status_code=403, detail="You can only view rosters for teams in your club")
4561 # Get team info with relationships
4562 team = team_dao.get_team_with_details(team_id)
4563 if not team:
4564 raise HTTPException(status_code=404, detail="Team not found")
4566 # Get players
4567 players = player_dao.get_team_players(team_id)
4569 return {"success": True, "team": team, "players": players}
4571 except HTTPException:
4572 raise
4573 except Exception as e:
4574 logger.error(f"Error getting team players: {e}", exc_info=True)
4575 raise HTTPException(status_code=500, detail="Failed to get team players") from e
4578# === Roster Management Endpoints (new players table) ===
4581@app.get("/api/teams/{team_id}/roster")
4582async def get_team_roster(
4583 team_id: int,
4584 season_id: int = Query(..., description="Season ID"),
4585 current_user: dict[str, Any] = Depends(get_current_user_required),
4586):
4587 """
4588 Get team roster from the players table.
4590 Returns roster entries with jersey numbers, names, and account status.
4591 Managers can view any team's roster for stat entry purposes.
4592 """
4593 try:
4594 # Verify team exists
4595 team = team_dao.get_team_by_id(team_id)
4596 if not team:
4597 raise HTTPException(status_code=404, detail="Team not found")
4599 # Get roster from players table
4600 roster = roster_dao.get_team_roster(team_id, season_id)
4602 return {"success": True, "team_id": team_id, "season_id": season_id, "roster": roster}
4604 except HTTPException:
4605 raise
4606 except Exception as e:
4607 logger.error(f"Error getting team roster: {e}", exc_info=True)
4608 raise HTTPException(status_code=500, detail="Failed to get team roster") from e
4611@app.post("/api/teams/{team_id}/roster")
4612async def create_roster_entry(
4613 team_id: int,
4614 player: RosterPlayerCreate,
4615 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4616):
4617 """
4618 Add a single player to the team roster.
4620 Requires admin or team_manager role.
4621 Jersey number must be unique within the team/season.
4622 """
4623 try:
4624 # Verify team exists
4625 team = team_dao.get_team_by_id(team_id)
4626 if not team:
4627 raise HTTPException(status_code=404, detail="Team not found")
4629 # Check if jersey number is already taken
4630 existing = roster_dao.get_player_by_jersey(team_id, player.season_id, player.jersey_number)
4631 if existing:
4632 raise HTTPException(status_code=409, detail=f"Jersey number {player.jersey_number} is already taken")
4634 # Create roster entry
4635 created = roster_dao.create_player(
4636 team_id=team_id,
4637 season_id=player.season_id,
4638 jersey_number=player.jersey_number,
4639 first_name=player.first_name,
4640 last_name=player.last_name,
4641 positions=player.positions,
4642 created_by=current_user["user_id"],
4643 )
4645 if not created:
4646 raise HTTPException(status_code=500, detail="Failed to create roster entry")
4648 return {"success": True, "player": created}
4650 except HTTPException:
4651 raise
4652 except Exception as e:
4653 logger.error(f"Error creating roster entry: {e}", exc_info=True)
4654 raise HTTPException(status_code=500, detail="Failed to create roster entry") from e
4657@app.post("/api/teams/{team_id}/roster/bulk")
4658async def bulk_create_roster(
4659 team_id: int,
4660 data: BulkRosterCreate,
4661 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4662):
4663 """
4664 Bulk create roster entries.
4666 Requires admin or team_manager role.
4667 Skips duplicates (existing jersey numbers).
4668 """
4669 try:
4670 # Verify team exists
4671 team = team_dao.get_team_by_id(team_id)
4672 if not team:
4673 raise HTTPException(status_code=404, detail="Team not found")
4675 # Get existing roster to filter duplicates
4676 existing_roster = roster_dao.get_team_roster(team_id, data.season_id)
4677 existing_numbers = {p["jersey_number"] for p in existing_roster}
4679 # Filter out duplicates
4680 new_players = [p.model_dump() for p in data.players if p.jersey_number not in existing_numbers]
4682 if not new_players:
4683 return {
4684 "success": True,
4685 "created": [],
4686 "skipped": len(data.players),
4687 "message": "All jersey numbers already exist",
4688 }
4690 # Bulk create
4691 created = roster_dao.bulk_create_players(
4692 team_id=team_id,
4693 season_id=data.season_id,
4694 players=new_players,
4695 created_by=current_user["user_id"],
4696 )
4698 return {
4699 "success": True,
4700 "created": created,
4701 "created_count": len(created),
4702 "skipped_count": len(data.players) - len(new_players),
4703 }
4705 except HTTPException:
4706 raise
4707 except Exception as e:
4708 logger.error(f"Error bulk creating roster: {e}", exc_info=True)
4709 raise HTTPException(status_code=500, detail="Failed to bulk create roster") from e
4712@app.put("/api/teams/{team_id}/roster/{player_id}")
4713async def update_roster_entry(
4714 team_id: int,
4715 player_id: int,
4716 data: RosterPlayerUpdate,
4717 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4718):
4719 """
4720 Update a roster entry's name or positions.
4722 Requires admin or team_manager role.
4723 """
4724 try:
4725 # Verify player exists and belongs to team
4726 player = roster_dao.get_player_by_id(player_id)
4727 if not player:
4728 raise HTTPException(status_code=404, detail="Player not found")
4729 if player["team_id"] != team_id:
4730 raise HTTPException(status_code=404, detail="Player not on this team")
4732 # Update
4733 updated = roster_dao.update_player(
4734 player_id=player_id,
4735 first_name=data.first_name,
4736 last_name=data.last_name,
4737 positions=data.positions,
4738 )
4740 if not updated:
4741 raise HTTPException(status_code=500, detail="Failed to update roster entry")
4743 return {"success": True, "player": updated}
4745 except HTTPException:
4746 raise
4747 except Exception as e:
4748 logger.error(f"Error updating roster entry: {e}", exc_info=True)
4749 raise HTTPException(status_code=500, detail="Failed to update roster entry") from e
4752@app.put("/api/teams/{team_id}/roster/{player_id}/number")
4753async def update_jersey_number(
4754 team_id: int,
4755 player_id: int,
4756 data: JerseyNumberUpdate,
4757 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4758):
4759 """
4760 Change a player's jersey number.
4762 Requires admin or team_manager role.
4763 New number must not already be taken.
4764 """
4765 try:
4766 # Verify player exists and belongs to team
4767 player = roster_dao.get_player_by_id(player_id)
4768 if not player:
4769 raise HTTPException(status_code=404, detail="Player not found")
4770 if player["team_id"] != team_id:
4771 raise HTTPException(status_code=404, detail="Player not on this team")
4773 # Check if new number is available
4774 existing = roster_dao.get_player_by_jersey(team_id, player["season_id"], data.new_number)
4775 if existing and existing["id"] != player_id:
4776 raise HTTPException(status_code=409, detail=f"Jersey number {data.new_number} is already taken")
4778 # Update number
4779 updated = roster_dao.update_jersey_number(player_id, data.new_number)
4781 if not updated:
4782 raise HTTPException(status_code=500, detail="Failed to update jersey number")
4784 return {"success": True, "player": updated}
4786 except HTTPException:
4787 raise
4788 except Exception as e:
4789 logger.error(f"Error updating jersey number: {e}", exc_info=True)
4790 raise HTTPException(status_code=500, detail="Failed to update jersey number") from e
4793@app.post("/api/teams/{team_id}/roster/renumber")
4794async def bulk_renumber_roster(
4795 team_id: int,
4796 data: BulkRenumberRequest,
4797 season_id: int = Query(..., description="Season ID"),
4798 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4799):
4800 """
4801 Bulk renumber players (for roster reshuffles).
4803 Requires admin or team_manager role.
4804 Handles swaps without constraint violations.
4805 """
4806 try:
4807 # Verify team exists
4808 team = team_dao.get_team_by_id(team_id)
4809 if not team:
4810 raise HTTPException(status_code=404, detail="Team not found")
4812 # Verify all players exist and belong to team
4813 for change in data.changes:
4814 player = roster_dao.get_player_by_id(change.player_id)
4815 if not player:
4816 raise HTTPException(status_code=404, detail=f"Player {change.player_id} not found")
4817 if player["team_id"] != team_id:
4818 raise HTTPException(status_code=400, detail=f"Player {change.player_id} not on this team")
4820 # Perform bulk renumber
4821 success = roster_dao.bulk_renumber(
4822 team_id=team_id, season_id=season_id, changes=[c.model_dump() for c in data.changes]
4823 )
4825 if not success:
4826 raise HTTPException(status_code=500, detail="Failed to renumber roster")
4828 # Return updated roster
4829 roster = roster_dao.get_team_roster(team_id, season_id)
4831 return {"success": True, "roster": roster}
4833 except HTTPException:
4834 raise
4835 except Exception as e:
4836 logger.error(f"Error renumbering roster: {e}", exc_info=True)
4837 raise HTTPException(status_code=500, detail="Failed to renumber roster") from e
4840@app.delete("/api/teams/{team_id}/roster/{player_id}")
4841async def delete_roster_entry(
4842 team_id: int,
4843 player_id: int,
4844 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
4845):
4846 """
4847 Remove a player from the roster (soft delete).
4849 Requires admin or team_manager role.
4850 """
4851 try:
4852 # Verify player exists and belongs to team
4853 player = roster_dao.get_player_by_id(player_id)
4854 if not player:
4855 raise HTTPException(status_code=404, detail="Player not found")
4856 if player["team_id"] != team_id:
4857 raise HTTPException(status_code=404, detail="Player not on this team")
4859 # Soft delete
4860 success = roster_dao.delete_player(player_id)
4862 if not success:
4863 raise HTTPException(status_code=500, detail="Failed to delete roster entry")
4865 return {"success": True, "message": "Player removed from roster"}
4867 except HTTPException:
4868 raise
4869 except Exception as e:
4870 logger.error(f"Error deleting roster entry: {e}", exc_info=True)
4871 raise HTTPException(status_code=500, detail="Failed to delete roster entry") from e
4874# =============================================================================
4875# Player Stats Endpoints
4876# =============================================================================
4879@app.get("/api/roster/{player_id}/stats")
4880async def get_player_stats(
4881 player_id: int,
4882 season_id: int = Query(..., description="Season ID for stats"),
4883):
4884 """
4885 Get aggregated stats for a roster player in a season.
4887 Returns games_played, games_started, total_minutes, total_goals.
4888 Public endpoint - stats are visible to everyone.
4889 """
4890 try:
4891 # Verify player exists
4892 player = roster_dao.get_player_by_id(player_id)
4893 if not player:
4894 raise HTTPException(status_code=404, detail="Player not found")
4896 # Get season stats
4897 stats = player_stats_dao.get_player_season_stats(player_id, season_id)
4899 return {
4900 "player_id": player_id,
4901 "jersey_number": player.get("jersey_number"),
4902 "display_name": player.get("display_name"),
4903 "season_id": season_id,
4904 "stats": stats
4905 or {
4906 "games_played": 0,
4907 "games_started": 0,
4908 "total_minutes": 0,
4909 "total_goals": 0,
4910 },
4911 }
4913 except HTTPException:
4914 raise
4915 except Exception as e:
4916 logger.error(f"Error getting player stats: {e}", exc_info=True)
4917 raise HTTPException(status_code=500, detail="Failed to get player stats") from e
4920@app.get("/api/teams/{team_id}/stats")
4921async def get_team_stats(
4922 team_id: int,
4923 season_id: int = Query(..., description="Season ID for stats"),
4924):
4925 """
4926 Get aggregated stats for all players on a team in a season.
4928 Returns list of players with their stats, sorted by goals (descending).
4929 Public endpoint - stats are visible to everyone.
4930 """
4931 try:
4932 # Verify team exists
4933 team = team_dao.get_team_by_id(team_id)
4934 if not team:
4935 raise HTTPException(status_code=404, detail="Team not found")
4937 # Get team stats
4938 stats = player_stats_dao.get_team_stats(team_id, season_id)
4940 return {
4941 "team_id": team_id,
4942 "team_name": team.get("name"),
4943 "season_id": season_id,
4944 "players": stats,
4945 }
4947 except HTTPException:
4948 raise
4949 except Exception as e:
4950 logger.error(f"Error getting team stats: {e}", exc_info=True)
4951 raise HTTPException(status_code=500, detail="Failed to get team stats") from e
4954@app.get("/api/me/player-stats")
4955async def get_my_player_stats(
4956 season_id: int = Query(..., description="Season ID for stats"),
4957 current_user: dict[str, Any] = Depends(get_current_user_required),
4958):
4959 """
4960 Get the current user's individual player stats for a season.
4962 Looks up the player record linked to the user's profile
4963 and returns their aggregated stats for the specified season.
4965 Returns:
4966 - player_id: The roster entry ID
4967 - jersey_number: Player's jersey number
4968 - season_id: The season these stats are for
4969 - stats: games_played, games_started, total_minutes, total_goals
4970 - linked: Whether the user has a linked player record
4971 """
4972 try:
4973 user_id = current_user["user_id"]
4975 # Find player record linked to this user
4976 player = roster_dao.get_player_by_user_profile_id(user_id)
4978 if not player:
4979 # User has no linked player record - return empty stats
4980 return {
4981 "player_id": None,
4982 "jersey_number": None,
4983 "display_name": None,
4984 "season_id": season_id,
4985 "stats": {
4986 "games_played": 0,
4987 "games_started": 0,
4988 "total_minutes": 0,
4989 "total_goals": 0,
4990 },
4991 "linked": False,
4992 }
4994 player_id = player["id"]
4996 # Get season stats
4997 stats = player_stats_dao.get_player_season_stats(player_id, season_id)
4999 return {
5000 "player_id": player_id,
5001 "jersey_number": player.get("jersey_number"),
5002 "display_name": player.get("display_name"),
5003 "season_id": season_id,
5004 "stats": stats
5005 or {
5006 "games_played": 0,
5007 "games_started": 0,
5008 "total_minutes": 0,
5009 "total_goals": 0,
5010 },
5011 "linked": True,
5012 }
5014 except HTTPException:
5015 raise
5016 except Exception as e:
5017 logger.error(f"Error getting my player stats: {e}", exc_info=True)
5018 raise HTTPException(status_code=500, detail="Failed to get player stats") from e
5021@app.get("/api/players/{user_id}/profile")
5022async def get_player_profile(user_id: str, current_user: dict[str, Any] = Depends(get_current_user_required)):
5023 """
5024 Get a specific player's full profile for the player detail view.
5026 Players can view profiles of anyone in their club.
5027 This allows browsing teammates across different age groups.
5029 Returns full profile data including:
5030 - Profile info (display_name, number, positions)
5031 - Photos and customization
5032 - Social media handles
5033 - Team info
5034 - Recent games (player's team matches)
5035 """
5036 try:
5037 # Get the target player's profile first
5038 target_profile = player_dao.get_user_profile_with_relationships(user_id)
5039 if not target_profile:
5040 raise HTTPException(status_code=404, detail="Player not found")
5042 # Allow viewing own profile
5043 if user_id == current_user["user_id"]:
5044 pass # No authorization check needed
5045 else:
5046 # Get target player's teams and club IDs
5047 target_teams = player_dao.get_all_current_player_teams(user_id)
5048 target_club_ids = set()
5049 for team_entry in target_teams:
5050 team_data = team_entry.get("team", {})
5051 club_data = team_data.get("club", {})
5052 if club_data and club_data.get("id"):
5053 target_club_ids.add(club_data["id"])
5055 # Get current user's teams and club IDs
5056 user_teams = player_dao.get_all_current_player_teams(current_user["user_id"])
5057 user_club_ids = set()
5058 for team_entry in user_teams:
5059 team_data = team_entry.get("team", {})
5060 club_data = team_data.get("club", {})
5061 if club_data and club_data.get("id"):
5062 user_club_ids.add(club_data["id"])
5064 # Authorization: must share at least one club
5065 if not (user_club_ids & target_club_ids):
5066 raise HTTPException(status_code=403, detail="You can only view profiles of players in your club")
5068 target_team_id = target_profile.get("team_id")
5070 # Get recent games for the player's team (if they have a team)
5071 recent_games = []
5072 if target_team_id:
5073 try:
5074 # Get matches for the team (already sorted by date desc, take first 5)
5075 matches = match_dao.get_matches_by_team(target_team_id)[:5]
5076 for match in matches:
5077 recent_games.append(
5078 {
5079 "id": match.get("id"),
5080 "match_date": match.get("match_date"),
5081 "home_team": match.get("home_team"),
5082 "away_team": match.get("away_team"),
5083 "home_score": match.get("home_score"),
5084 "away_score": match.get("away_score"),
5085 "status": match.get("status"),
5086 }
5087 )
5088 except Exception as e:
5089 logger.warning(f"Could not fetch recent games: {e}")
5091 return {
5092 "success": True,
5093 "player": {
5094 "id": target_profile.get("id"),
5095 "display_name": target_profile.get("display_name"),
5096 "player_number": target_profile.get("player_number"),
5097 "positions": target_profile.get("positions"),
5098 # Photo fields
5099 "photo_1_url": target_profile.get("photo_1_url"),
5100 "photo_2_url": target_profile.get("photo_2_url"),
5101 "photo_3_url": target_profile.get("photo_3_url"),
5102 "profile_photo_slot": target_profile.get("profile_photo_slot"),
5103 # Customization
5104 "overlay_style": target_profile.get("overlay_style"),
5105 "primary_color": target_profile.get("primary_color"),
5106 "text_color": target_profile.get("text_color"),
5107 "accent_color": target_profile.get("accent_color"),
5108 # Social media
5109 "instagram_handle": target_profile.get("instagram_handle"),
5110 "snapchat_handle": target_profile.get("snapchat_handle"),
5111 "tiktok_handle": target_profile.get("tiktok_handle"),
5112 # Team info
5113 "team": target_profile.get("team"),
5114 "club": target_profile.get("club"),
5115 },
5116 "recent_games": recent_games,
5117 }
5119 except HTTPException:
5120 raise
5121 except Exception as e:
5122 logger.error(f"Error getting player profile: {e}", exc_info=True)
5123 raise HTTPException(status_code=500, detail="Failed to get player profile") from e
5126# =============================================================================
5127# Playoff Bracket Endpoints
5128# =============================================================================
5131@app.get("/api/playoffs/bracket")
5132async def get_playoff_bracket(
5133 current_user: dict[str, Any] = Depends(get_current_user_required),
5134 league_id: int = Query(..., description="League ID"),
5135 season_id: int = Query(..., description="Season ID"),
5136 age_group_id: int = Query(..., description="Age group ID"),
5137):
5138 """Get playoff bracket for a league/season/age group."""
5139 try:
5140 bracket = playoff_dao.get_bracket(league_id, season_id, age_group_id)
5141 return bracket
5142 except Exception as e:
5143 logger.error(f"Error fetching playoff bracket: {e!s}", exc_info=True)
5144 raise HTTPException(status_code=500, detail=str(e)) from e
5147@app.post("/api/playoffs/advance")
5148async def advance_playoff_winner_by_manager(
5149 request: AdvanceWinnerRequest,
5150 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
5151):
5152 """Advance the winner of a completed bracket slot to the next round.
5154 Team managers can advance when their team is the winner.
5155 Club managers can advance when any team in their club is the winner.
5156 Admins can advance any slot.
5157 """
5158 try:
5159 # Get slot with match data to verify permissions
5160 slot_response = (
5161 match_dao.client.table("playoff_bracket_slots")
5162 .select("*, match:matches(id, home_team_id, away_team_id, home_score, away_score, match_status)")
5163 .eq("id", request.slot_id)
5164 .execute()
5165 )
5166 if not slot_response.data:
5167 raise HTTPException(status_code=404, detail=f"Slot {request.slot_id} not found")
5169 slot = slot_response.data[0]
5170 match = slot.get("match")
5171 if not match:
5172 raise HTTPException(status_code=400, detail=f"Slot {request.slot_id} has no linked match")
5174 home_team_id = match.get("home_team_id")
5175 away_team_id = match.get("away_team_id")
5177 # Check if user can edit this match (team manager for their team, club manager for their club)
5178 if not auth_manager.can_edit_match(current_user, home_team_id, away_team_id):
5179 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
5181 # For non-admins, verify their team is the winner (not just a participant)
5182 if current_user.get("role") != "admin":
5183 if match.get("match_status") not in ("completed", "forfeit"):
5184 raise HTTPException(status_code=400, detail="Match is not completed yet")
5185 if match.get("home_score") is None or match.get("away_score") is None:
5186 raise HTTPException(status_code=400, detail="Match has no scores")
5187 if match["home_score"] == match["away_score"]:
5188 raise HTTPException(status_code=400, detail="Match is tied - admin must resolve")
5190 # Determine the winner
5191 winner_team_id = home_team_id if match["home_score"] > match["away_score"] else away_team_id
5193 # Check if user's team is the winner
5194 user_team_id = current_user.get("team_id")
5195 user_club_id = current_user.get("club_id")
5196 role = current_user.get("role")
5198 user_is_winner = False
5199 if role == "team-manager" and user_team_id == winner_team_id:
5200 user_is_winner = True
5201 elif role == "club_manager" and user_club_id:
5202 # Check if winner team belongs to user's club
5203 winner_club_id = auth_manager._get_team_club_id(winner_team_id)
5204 if winner_club_id == user_club_id:
5205 user_is_winner = True
5207 if not user_is_winner:
5208 raise HTTPException(status_code=403, detail="Only the winning team's manager can advance the winner")
5210 # All checks passed, advance the winner
5211 result = playoff_dao.advance_winner(request.slot_id)
5212 return result
5213 except HTTPException:
5214 raise
5215 except ValueError as e:
5216 raise HTTPException(status_code=400, detail=str(e)) from e
5217 except Exception as e:
5218 logger.error(f"Error advancing playoff winner: {e!s}", exc_info=True)
5219 raise HTTPException(status_code=500, detail=str(e)) from e
5222@app.post("/api/playoffs/forfeit")
5223async def forfeit_playoff_match_by_manager(
5224 request: ForfeitMatchRequest,
5225 current_user: dict[str, Any] = Depends(require_team_manager_or_admin),
5226):
5227 """Declare a forfeit on a playoff match.
5229 Team managers can forfeit their own team.
5230 Club managers can forfeit teams in their club.
5231 Admins can forfeit any team.
5232 """
5233 try:
5234 # Get slot with match data to verify permissions
5235 slot_response = (
5236 match_dao.client.table("playoff_bracket_slots")
5237 .select("*, match:matches(id, home_team_id, away_team_id, match_status)")
5238 .eq("id", request.slot_id)
5239 .execute()
5240 )
5241 if not slot_response.data:
5242 raise HTTPException(status_code=404, detail=f"Slot {request.slot_id} not found")
5244 slot = slot_response.data[0]
5245 match = slot.get("match")
5246 if not match:
5247 raise HTTPException(status_code=400, detail=f"Slot {request.slot_id} has no linked match")
5249 home_team_id = match.get("home_team_id")
5250 away_team_id = match.get("away_team_id")
5252 # Check if user can edit this match
5253 if not auth_manager.can_edit_match(current_user, home_team_id, away_team_id):
5254 raise HTTPException(status_code=403, detail="You don't have permission to manage this match")
5256 # For non-admins, verify the forfeit_team_id is their own team
5257 if current_user.get("role") != "admin":
5258 user_team_id = current_user.get("team_id")
5259 user_club_id = current_user.get("club_id")
5260 role = current_user.get("role")
5262 can_forfeit = False
5263 if role == "team-manager" and user_team_id == request.forfeit_team_id:
5264 can_forfeit = True
5265 elif role == "club_manager" and user_club_id:
5266 forfeit_club_id = auth_manager._get_team_club_id(request.forfeit_team_id)
5267 if forfeit_club_id == user_club_id:
5268 can_forfeit = True
5270 if not can_forfeit:
5271 raise HTTPException(status_code=403, detail="You can only forfeit your own team")
5273 result = playoff_dao.forfeit_match(request.slot_id, request.forfeit_team_id)
5274 return result or {"message": "Forfeit recorded (final match)"}
5275 except HTTPException:
5276 raise
5277 except ValueError as e:
5278 raise HTTPException(status_code=400, detail=str(e)) from e
5279 except Exception as e:
5280 logger.error(f"Error forfeiting playoff match: {e!s}", exc_info=True)
5281 raise HTTPException(status_code=500, detail=str(e)) from e
5284@app.post("/api/admin/playoffs/forfeit")
5285async def forfeit_playoff_match(
5286 request: ForfeitMatchRequest,
5287 current_user: dict[str, Any] = Depends(require_admin),
5288):
5289 """Declare a forfeit on a playoff match (admin only)."""
5290 try:
5291 result = playoff_dao.forfeit_match(request.slot_id, request.forfeit_team_id)
5292 return result or {"message": "Forfeit recorded (final match)"}
5293 except ValueError as e:
5294 raise HTTPException(status_code=400, detail=str(e)) from e
5295 except Exception as e:
5296 logger.error(f"Error forfeiting playoff match: {e!s}", exc_info=True)
5297 raise HTTPException(status_code=500, detail=str(e)) from e
5300@app.post("/api/admin/playoffs/generate")
5301async def generate_playoff_bracket(
5302 request: GenerateBracketRequest,
5303 current_user: dict[str, Any] = Depends(require_admin),
5304):
5305 """Generate configurable playoff brackets from current standings (admin only).
5307 Creates bracket slots and QF matches for each configured tier.
5308 Tier names and start date are configurable.
5309 """
5310 try:
5311 # Get standings for both divisions
5312 standings_a = match_dao.get_league_table(
5313 season_id=request.season_id,
5314 age_group_id=request.age_group_id,
5315 division_id=request.division_a_id,
5316 match_type="League",
5317 )
5318 standings_b = match_dao.get_league_table(
5319 season_id=request.season_id,
5320 age_group_id=request.age_group_id,
5321 division_id=request.division_b_id,
5322 match_type="League",
5323 )
5325 # Convert tier configs to dict format for DAO
5326 tiers = [
5327 {
5328 "name": tier.name,
5329 "start_position": tier.start_position,
5330 "end_position": tier.end_position,
5331 }
5332 for tier in request.tiers
5333 ]
5335 bracket = playoff_dao.generate_bracket(
5336 league_id=request.league_id,
5337 season_id=request.season_id,
5338 age_group_id=request.age_group_id,
5339 standings_a=standings_a,
5340 standings_b=standings_b,
5341 division_a_id=request.division_a_id,
5342 division_b_id=request.division_b_id,
5343 start_date=request.start_date,
5344 tiers=tiers,
5345 )
5346 return bracket
5347 except ValueError as e:
5348 raise HTTPException(status_code=400, detail=str(e)) from e
5349 except Exception as e:
5350 logger.error(f"Error generating playoff bracket: {e!s}", exc_info=True)
5351 raise HTTPException(status_code=500, detail=str(e)) from e
5354@app.post("/api/admin/playoffs/advance")
5355async def advance_playoff_winner(
5356 request: AdvanceWinnerRequest,
5357 current_user: dict[str, Any] = Depends(require_admin),
5358):
5359 """Advance the winner of a completed bracket slot to the next round (admin only)."""
5360 try:
5361 result = playoff_dao.advance_winner(request.slot_id)
5362 return result
5363 except ValueError as e:
5364 raise HTTPException(status_code=400, detail=str(e)) from e
5365 except Exception as e:
5366 logger.error(f"Error advancing playoff winner: {e!s}", exc_info=True)
5367 raise HTTPException(status_code=500, detail=str(e)) from e
5370@app.delete("/api/admin/playoffs/bracket")
5371async def delete_playoff_bracket(
5372 current_user: dict[str, Any] = Depends(require_admin),
5373 league_id: int = Query(..., description="League ID"),
5374 season_id: int = Query(..., description="Season ID"),
5375 age_group_id: int = Query(..., description="Age group ID"),
5376):
5377 """Delete an entire playoff bracket and its matches (admin only)."""
5378 try:
5379 deleted = playoff_dao.delete_bracket(league_id, season_id, age_group_id)
5380 if deleted == 0:
5381 raise HTTPException(
5382 status_code=404,
5383 detail="No bracket found for this league/season/age group",
5384 )
5385 return {"message": "Bracket deleted successfully", "slots_deleted": deleted}
5386 except HTTPException:
5387 raise
5388 except Exception as e:
5389 logger.error(f"Error deleting playoff bracket: {e!s}", exc_info=True)
5390 raise HTTPException(status_code=500, detail=str(e)) from e
5393# =============================================================================
5394# Goals Management Endpoints (Admin)
5395# =============================================================================
5398@app.get("/api/admin/goals")
5399async def get_goal_events(
5400 current_user: dict[str, Any] = Depends(require_match_management_permission),
5401 season_id: int | None = Query(None, description="Filter by season"),
5402 age_group_id: int | None = Query(None, description="Filter by age group"),
5403 match_type_id: int | None = Query(None, description="Filter by match type"),
5404 team_id: int | None = Query(None, description="Filter by scoring team"),
5405 limit: int = Query(100, le=500, description="Maximum results"),
5406 offset: int = Query(0, ge=0, description="Pagination offset"),
5407):
5408 """List goal events with match context for admin management."""
5409 try:
5410 goals = match_event_dao.get_goal_events(
5411 season_id=season_id,
5412 age_group_id=age_group_id,
5413 match_type_id=match_type_id,
5414 team_id=team_id,
5415 limit=limit,
5416 offset=offset,
5417 )
5418 return goals
5419 except Exception as e:
5420 logger.error(f"Error getting goal events: {e!s}", exc_info=True)
5421 raise HTTPException(status_code=500, detail=str(e)) from e
5424@app.patch("/api/admin/goals/{event_id}")
5425async def update_goal_event(
5426 event_id: int,
5427 update: GoalEventUpdate,
5428 current_user: dict[str, Any] = Depends(require_match_management_permission),
5429):
5430 """Update a goal event's time or player (admin corrections).
5432 If player_id changes, adjusts both old and new player stats.
5433 """
5434 try:
5435 # Get the existing event
5436 event = match_event_dao.get_event_by_id(event_id)
5437 if not event:
5438 raise HTTPException(status_code=404, detail="Event not found")
5439 if event.get("event_type") != "goal":
5440 raise HTTPException(status_code=400, detail="Event is not a goal")
5441 if event.get("is_deleted"):
5442 raise HTTPException(status_code=400, detail="Cannot update a deleted event")
5444 match_id = event["match_id"]
5446 # If player_id is changing, adjust player stats
5447 old_player_id = event.get("player_id")
5448 new_player_id = update.player_id
5449 if new_player_id is not None and old_player_id != new_player_id:
5450 # Decrement old player's goals
5451 if old_player_id:
5452 player_stats_dao.decrement_goals(old_player_id, match_id)
5453 # Increment new player's goals
5454 player_stats_dao.increment_goals(new_player_id, match_id)
5456 # Build update kwargs (only pass non-None fields)
5457 update_kwargs = {}
5458 if update.match_minute is not None:
5459 update_kwargs["match_minute"] = update.match_minute
5460 if update.extra_time is not None:
5461 update_kwargs["extra_time"] = update.extra_time
5462 if update.player_name is not None:
5463 update_kwargs["player_name"] = update.player_name
5464 if update.player_id is not None:
5465 update_kwargs["player_id"] = update.player_id
5467 updated = match_event_dao.update_event(event_id, **update_kwargs)
5468 if not updated:
5469 raise HTTPException(status_code=500, detail="Failed to update event")
5471 return updated
5472 except HTTPException:
5473 raise
5474 except Exception as e:
5475 logger.error(f"Error updating goal event: {e!s}", exc_info=True)
5476 raise HTTPException(status_code=500, detail=str(e)) from e
5479# =============================================================================
5480# Cache Management Endpoints (Admin Only)
5481# =============================================================================
5484@app.get("/api/admin/cache")
5485async def get_cache_stats(current_user: dict[str, Any] = Depends(require_admin)):
5486 """Get cache statistics and keys grouped by type (admin only)."""
5487 from dao.base_dao import get_redis_client
5489 redis_client = get_redis_client()
5490 if not redis_client:
5491 return {
5492 "enabled": False,
5493 "message": "Cache is disabled or Redis unavailable",
5494 "groups": {},
5495 }
5497 try:
5498 # Get all cache keys
5499 all_keys = list(redis_client.scan_iter(match="mt:dao:*", count=1000))
5501 # Group by type
5502 groups = {}
5503 for key in all_keys:
5504 # Key format: mt:dao:TYPE:...
5505 parts = key.split(":")
5506 cache_type = parts[2] if len(parts) >= 3 else "other"
5508 if cache_type not in groups:
5509 groups[cache_type] = {"count": 0, "keys": []}
5510 groups[cache_type]["count"] += 1
5511 groups[cache_type]["keys"].append(key)
5513 return {
5514 "enabled": True,
5515 "total_keys": len(all_keys),
5516 "groups": groups,
5517 }
5518 except Exception as e:
5519 logger.error(f"Error getting cache stats: {e}", exc_info=True)
5520 raise HTTPException(status_code=500, detail=str(e)) from e
5523@app.delete("/api/admin/cache")
5524async def clear_all_cache(current_user: dict[str, Any] = Depends(require_admin)):
5525 """Clear all DAO cache entries (admin only)."""
5526 from dao.base_dao import clear_cache
5528 try:
5529 deleted = clear_cache("mt:dao:*")
5530 logger.info(f"Admin {current_user.get('username')} cleared all cache: {deleted} keys")
5531 return {"message": "Cache cleared", "deleted": deleted}
5532 except Exception as e:
5533 logger.error(f"Error clearing cache: {e}", exc_info=True)
5534 raise HTTPException(status_code=500, detail=str(e)) from e
5537@app.delete("/api/admin/cache/{cache_type}")
5538async def clear_cache_by_type(
5539 cache_type: str,
5540 current_user: dict[str, Any] = Depends(require_admin),
5541):
5542 """Clear cache entries for a specific type (admin only).
5544 Valid types: playoffs, matches, players, clubs, teams, standings, etc.
5545 """
5546 from dao.base_dao import clear_cache
5548 # Validate cache type to prevent arbitrary pattern injection
5549 valid_types = ["playoffs", "matches", "players", "clubs", "teams", "standings", "rosters"]
5550 if cache_type not in valid_types:
5551 raise HTTPException(
5552 status_code=400,
5553 detail=f"Invalid cache type. Valid types: {', '.join(valid_types)}",
5554 )
5556 try:
5557 pattern = f"mt:dao:{cache_type}:*"
5558 deleted = clear_cache(pattern)
5559 logger.info(f"Admin {current_user.get('username')} cleared {cache_type} cache: {deleted} keys")
5560 return {"message": f"{cache_type} cache cleared", "deleted": deleted}
5561 except Exception as e:
5562 logger.error(f"Error clearing {cache_type} cache: {e}", exc_info=True)
5563 raise HTTPException(status_code=500, detail=str(e)) from e
5566# =============================================================================
5567# User Login Activity Endpoints (Admin Only)
5568# =============================================================================
5571@app.get("/api/admin/users")
5572async def get_all_users(current_user: dict[str, Any] = Depends(require_admin)):
5573 """Get all users with their last login time (admin only)."""
5574 try:
5575 profiles_resp = auth_service_client.table("user_profiles").select(
5576 "id, username, display_name, role, email, team_id, club_id, created_at"
5577 ).order("username").execute()
5578 profiles = profiles_resp.data or []
5580 # Get last login time for each user
5581 if profiles:
5582 user_ids = [p["id"] for p in profiles]
5583 # Fetch most recent login event per user_id
5584 events_resp = auth_service_client.table("login_events").select(
5585 "user_id, success, created_at"
5586 ).in_("user_id", user_ids).order("created_at", desc=True).execute()
5587 events = events_resp.data or []
5589 # Build a map of user_id -> last login info
5590 last_login_map: dict[str, dict] = {}
5591 for ev in events:
5592 uid = ev["user_id"]
5593 if uid not in last_login_map:
5594 last_login_map[uid] = {
5595 "last_login_at": ev["created_at"],
5596 "last_login_success": ev["success"],
5597 }
5599 for profile in profiles:
5600 info = last_login_map.get(profile["id"], {})
5601 profile["last_login_at"] = info.get("last_login_at")
5602 profile["last_login_success"] = info.get("last_login_success")
5604 return {"users": profiles, "total": len(profiles)}
5605 except Exception as e:
5606 logger.error(f"Error fetching users: {e}", exc_info=True)
5607 raise HTTPException(status_code=500, detail=str(e)) from e
5610@app.get("/api/admin/users/login-events")
5611async def get_login_events(
5612 limit: int = 100,
5613 offset: int = 0,
5614 username: str | None = None,
5615 success: bool | None = None,
5616 current_user: dict[str, Any] = Depends(require_admin),
5617):
5618 """Get login events with optional filters (admin only)."""
5619 try:
5620 query = auth_service_client.table("login_events").select(
5621 "id, user_id, username, client_ip, success, failure_reason, role, created_at",
5622 count="exact",
5623 ).order("created_at", desc=True).range(offset, offset + limit - 1)
5625 if username:
5626 query = query.ilike("username", f"%{username}%")
5627 if success is not None:
5628 query = query.eq("success", success)
5630 resp = query.execute()
5631 return {
5632 "events": resp.data or [],
5633 "total": resp.count or 0,
5634 "limit": limit,
5635 "offset": offset,
5636 }
5637 except Exception as e:
5638 logger.error(f"Error fetching login events: {e}", exc_info=True)
5639 raise HTTPException(status_code=500, detail=str(e)) from e
5642# Health check
5643@app.get("/health")
5644async def health_check():
5645 """Basic health check endpoint."""
5646 return {"status": "healthy", "version": "2.0.0", "schema": "enhanced"}
5649@app.get("/health/full")
5650async def full_health_check():
5651 """Comprehensive health check including database connectivity."""
5652 health_status = {
5653 "status": "healthy",
5654 "version": "2.0.0",
5655 "schema": "enhanced",
5656 "timestamp": datetime.utcnow().isoformat(),
5657 "checks": {},
5658 }
5660 overall_healthy = True
5662 # Check basic API
5663 health_status["checks"]["api"] = {"status": "healthy", "message": "API is responding"}
5665 # Check database connectivity
5666 try:
5667 # Simple database connectivity test
5668 test_response = season_dao.get_all_age_groups()
5669 if isinstance(test_response, list):
5670 health_status["checks"]["database"] = {
5671 "status": "healthy",
5672 "message": "Database connection successful",
5673 "age_groups_count": len(test_response),
5674 }
5675 else:
5676 raise Exception("Unexpected response from database")
5678 except Exception as e:
5679 overall_healthy = False
5680 health_status["checks"]["database"] = {
5681 "status": "unhealthy",
5682 "message": f"Database connection failed: {e!s}",
5683 }
5685 # Check reference data availability
5686 try:
5687 seasons = season_dao.get_all_seasons()
5688 match_types = match_type_dao.get_all_match_types()
5690 health_status["checks"]["reference_data"] = {
5691 "status": "healthy",
5692 "message": "Reference data available",
5693 "seasons_count": len(seasons) if isinstance(seasons, list) else 0,
5694 "match_types_count": len(match_types) if isinstance(match_types, list) else 0,
5695 }
5697 # Check if we have essential reference data
5698 if not seasons or not match_types:
5699 health_status["checks"]["reference_data"]["status"] = "warning"
5700 health_status["checks"]["reference_data"]["message"] = "Some reference data missing"
5702 except Exception as e:
5703 health_status["checks"]["reference_data"] = {
5704 "status": "unhealthy",
5705 "message": f"Reference data check failed: {e!s}",
5706 }
5708 # Check authentication system
5709 try:
5710 if auth_manager and hasattr(auth_manager, "supabase"):
5711 health_status["checks"]["auth"] = {
5712 "status": "healthy",
5713 "message": "Authentication system operational",
5714 }
5715 else:
5716 health_status["checks"]["auth"] = {
5717 "status": "warning",
5718 "message": "Authentication manager not properly initialized",
5719 }
5720 except Exception as e:
5721 health_status["checks"]["auth"] = {
5722 "status": "unhealthy",
5723 "message": f"Authentication system error: {e!s}",
5724 }
5726 # Set overall status
5727 check_statuses = [check.get("status", "unknown") for check in health_status["checks"].values()]
5729 if "unhealthy" in check_statuses:
5730 health_status["status"] = "unhealthy"
5731 overall_healthy = False
5732 elif "warning" in check_statuses:
5733 health_status["status"] = "degraded"
5735 # Log health check outcome
5736 logger.info(
5737 "health_check_performed",
5738 status=health_status["status"],
5739 database_healthy="database" in health_status["checks"]
5740 and health_status["checks"]["database"]["status"] == "healthy",
5741 auth_healthy="auth" in health_status["checks"] and health_status["checks"]["auth"]["status"] == "healthy",
5742 )
5744 # Return appropriate HTTP status code
5745 if overall_healthy:
5746 return health_status
5747 else:
5748 from fastapi import HTTPException
5750 raise HTTPException(status_code=503, detail=health_status)
5753# === Agent Endpoints ===
5756@app.get("/api/agent/match-summary")
5757async def get_agent_match_summary(
5758 season: str = Query(..., description="Season name, e.g. '2025-26'"),
5759 score_from: str | None = Query(None, description="needs_score window start (YYYY-MM-DD)"),
5760 score_to: str | None = Query(None, description="needs_score window end (YYYY-MM-DD)"),
5761 current_user: dict[str, Any] = Depends(require_match_management_permission),
5762):
5763 """Get match summary for agent decision-making.
5765 Returns match counts grouped by age group, league, and division
5766 with status breakdowns to help the agent decide what to scrape.
5768 Optional score_from/score_to params narrow the needs_score calculation
5769 to a specific date window (e.g. last weekend only).
5770 """
5771 try:
5772 targets = match_dao.get_match_summary(season, score_from=score_from, score_to=score_to)
5773 return {
5774 "season": season,
5775 "generated_at": datetime.now(UTC).isoformat(),
5776 "targets": targets,
5777 }
5778 except Exception as e:
5779 logger.error("agent_match_summary_failed", error=str(e), season=season)
5780 raise HTTPException(status_code=500, detail=str(e)) from e
5783@app.get("/api/agent/matches")
5784async def get_agent_matches(
5785 team: str = Query(..., description="MT canonical team name, e.g. 'IFA'"),
5786 age_group: str = Query(..., description="e.g. 'U14'"),
5787 league: str = Query(..., description="e.g. 'Homegrown'"),
5788 division: str = Query(..., description="e.g. 'Northeast'"),
5789 season: str = Query(..., description="e.g. '2025-2026'"),
5790 start_date: str | None = Query(None, description="Filter matches on or after this date (YYYY-MM-DD)"),
5791 end_date: str | None = Query(None, description="Filter matches on or before this date (YYYY-MM-DD)"),
5792 current_user: dict[str, Any] = Depends(require_match_management_permission),
5793):
5794 """Get individual match records for a team — used by the audit agent for comparison.
5796 Returns matches involving the specified team (home or away) in the given
5797 age-group/league/division/season. Optional start_date/end_date narrow the
5798 results to a specific segment (e.g. spring only) to avoid false extra_in_mt
5799 findings from matches in a different season segment.
5800 """
5801 try:
5802 matches = match_dao.get_agent_matches(
5803 team=team,
5804 age_group=age_group,
5805 league=league,
5806 division=division,
5807 season=season,
5808 start_date=start_date,
5809 end_date=end_date,
5810 )
5811 return {"matches": matches}
5812 except Exception as e:
5813 logger.error("agent_matches_failed", error=str(e), team=team, season=season)
5814 raise HTTPException(status_code=500, detail=str(e)) from e
5817@app.patch("/api/agent/matches/cancel")
5818async def cancel_agent_match(
5819 payload: dict[str, Any],
5820 current_user: dict[str, Any] = Depends(require_match_management_permission),
5821):
5822 """Mark a match as cancelled based on natural key.
5824 Called by the audit processor when an extra_in_mt match is confirmed gone
5825 after the 7-day threshold. Sets match_status='cancelled' to hide it from
5826 the planner and future audit comparisons. Refuses to cancel scored matches.
5827 """
5828 required = ("home_team", "away_team", "match_date", "age_group", "league", "division", "season")
5829 missing = [f for f in required if not payload.get(f)]
5830 if missing:
5831 raise HTTPException(status_code=422, detail=f"Missing required fields: {missing}")
5833 try:
5834 found = match_dao.cancel_match(
5835 home_team=payload["home_team"],
5836 away_team=payload["away_team"],
5837 match_date=payload["match_date"],
5838 age_group=payload["age_group"],
5839 league=payload["league"],
5840 division=payload["division"],
5841 season=payload["season"],
5842 )
5843 except Exception as e:
5844 match_label = f"{payload.get('home_team')} vs {payload.get('away_team')}"
5845 logger.error("cancel_match_failed", error=str(e), match=match_label)
5846 raise HTTPException(status_code=500, detail=str(e)) from e
5848 if not found:
5849 raise HTTPException(status_code=404, detail="Match not found or already has a score")
5851 return {
5852 "status": "cancelled",
5853 "match": f"{payload['home_team']} vs {payload['away_team']} {payload['match_date']}",
5854 }
5857@app.get("/api/agent/audit/next-team")
5858async def get_audit_next_team(
5859 season: str = Query(..., description="e.g. '2025-2026'"),
5860 division: str = Query(..., description="e.g. 'Northeast'"),
5861 league: str = Query(..., description="e.g. 'Homegrown'"),
5862 current_user: dict[str, Any] = Depends(require_match_management_permission),
5863):
5864 """Return the next team to audit (least recently audited).
5866 Returns HTTP 204 when all teams were audited within the last 7 days.
5867 """
5868 try:
5869 team = audit_dao.get_next_team(season=season, division=division, league=league)
5870 except Exception as e:
5871 logger.error("audit_next_team_failed", error=str(e), season=season)
5872 raise HTTPException(status_code=500, detail=str(e)) from e
5874 if team is None:
5875 return Response(status_code=204)
5876 return team
5879@app.get("/api/agent/audit/teams")
5880async def get_audit_teams(
5881 season: str = Query(..., description="e.g. '2025-2026'"),
5882 division: str = Query(..., description="e.g. 'Northeast'"),
5883 league: str = Query(..., description="e.g. 'Homegrown'"),
5884 current_user: dict[str, Any] = Depends(require_match_management_permission),
5885):
5886 """Return full team list with audit scheduling state. Used for metrics/reporting."""
5887 try:
5888 teams = audit_dao.get_audit_teams(season=season, division=division, league=league)
5889 return {"teams": teams}
5890 except Exception as e:
5891 logger.error("audit_teams_failed", error=str(e), season=season)
5892 raise HTTPException(status_code=500, detail=str(e)) from e
5895@app.post("/api/agent/audit/events")
5896async def post_audit_events(
5897 payload: dict[str, Any],
5898 current_user: dict[str, Any] = Depends(require_match_management_permission),
5899):
5900 """Submit audit findings for a completed team audit.
5902 Updates audit_teams.last_audited_at. Stores an audit_events row only when
5903 findings is non-empty. Returns 200 with empty findings if audit was clean.
5904 """
5905 required = ("event_id", "team", "age_group", "league", "division", "season")
5906 missing = [f for f in required if not payload.get(f)]
5907 if missing:
5908 raise HTTPException(status_code=422, detail=f"Missing required fields: {missing}")
5910 try:
5911 audit_dao.submit_audit_event(payload)
5912 except Exception as e:
5913 logger.error("audit_post_events_failed", error=str(e), event_id=payload.get("event_id"))
5914 raise HTTPException(status_code=500, detail=str(e)) from e
5916 findings = payload.get("findings", [])
5917 return {
5918 "event_id": payload["event_id"],
5919 "team": payload["team"],
5920 "age_group": payload["age_group"],
5921 "findings_stored": len(findings),
5922 "status": "findings" if findings else "clean",
5923 }
5926@app.get("/api/agent/audit/events")
5927async def get_audit_events(
5928 season: str = Query(..., description="e.g. '2025-2026'"),
5929 status: str = Query("pending", description="pending | processed | ignored"),
5930 team: str | None = Query(None),
5931 age_group: str | None = Query(None),
5932 current_user: dict[str, Any] = Depends(require_match_management_permission),
5933):
5934 """List audit events. Defaults to pending events for the processor to consume."""
5935 try:
5936 events = audit_dao.get_events(season=season, status=status, team=team, age_group=age_group)
5937 return {"events": events}
5938 except Exception as e:
5939 logger.error("audit_get_events_failed", error=str(e), season=season)
5940 raise HTTPException(status_code=500, detail=str(e)) from e
5943@app.patch("/api/agent/audit/events/{event_id}")
5944async def patch_audit_event(
5945 event_id: str,
5946 payload: dict[str, Any],
5947 current_user: dict[str, Any] = Depends(require_match_management_permission),
5948):
5949 """Update the status of an audit event after the processor has handled it."""
5950 new_status = payload.get("status")
5951 if not new_status:
5952 raise HTTPException(status_code=422, detail="'status' field required")
5954 try:
5955 audit_dao.update_event_status(
5956 event_id=event_id,
5957 status=new_status,
5958 processed_at=payload.get("processed_at"),
5959 )
5960 except Exception as e:
5961 logger.error("audit_patch_event_failed", error=str(e), event_id=event_id)
5962 raise HTTPException(status_code=500, detail=str(e)) from e
5964 return {"event_id": event_id, "status": new_status}
5967@app.get("/api/agent/audit/summary")
5968async def get_audit_summary(
5969 season: str = Query(..., description="e.g. '2025-2026'"),
5970 division: str = Query(..., description="e.g. 'Northeast'"),
5971 league: str = Query(..., description="e.g. 'Homegrown'"),
5972 current_user: dict[str, Any] = Depends(require_match_management_permission),
5973):
5974 """Audit coverage metrics: teams audited this week, findings breakdown, overdue teams."""
5975 try:
5976 summary = audit_dao.get_audit_summary(season=season, division=division, league=league)
5977 return summary
5978 except Exception as e:
5979 logger.error("audit_summary_failed", error=str(e), season=season)
5980 raise HTTPException(status_code=500, detail=str(e)) from e
5983# =============================================================================
5984# Tournament Endpoints
5985# =============================================================================
5988class TournamentCreate(BaseModel):
5989 name: str
5990 start_date: str
5991 end_date: str | None = None
5992 location: str | None = None
5993 description: str | None = None
5994 age_group_ids: list[int] = []
5995 is_active: bool = True
5998class TournamentUpdate(BaseModel):
5999 name: str | None = None
6000 start_date: str | None = None
6001 end_date: str | None = None
6002 location: str | None = None
6003 description: str | None = None
6004 age_group_ids: list[int] | None = None
6005 is_active: bool | None = None
6008class TournamentMatchCreate(BaseModel):
6009 our_team_id: int
6010 opponent_name: str
6011 match_date: str
6012 age_group_id: int
6013 season_id: int
6014 is_home: bool = True
6015 home_score: int | None = None
6016 away_score: int | None = None
6017 home_penalty_score: int | None = None
6018 away_penalty_score: int | None = None
6019 match_status: str = "scheduled"
6020 tournament_group: str | None = None
6021 tournament_round: str | None = None
6022 scheduled_kickoff: str | None = None
6025class TournamentMatchUpdate(BaseModel):
6026 home_score: int | None = None
6027 away_score: int | None = None
6028 home_penalty_score: int | None = None
6029 away_penalty_score: int | None = None
6030 match_status: str | None = None
6031 tournament_group: str | None = None
6032 tournament_round: str | None = None
6033 scheduled_kickoff: str | None = None
6034 match_date: str | None = None
6035 swap_home_away: bool = False
6038@app.get("/api/tournaments")
6039async def get_tournaments():
6040 """List all active tournaments (public)."""
6041 try:
6042 return tournament_dao.get_active_tournaments()
6043 except Exception as e:
6044 raise HTTPException(status_code=500, detail=str(e)) from e
6047@app.get("/api/tournaments/{tournament_id}")
6048async def get_tournament(tournament_id: int):
6049 """Get tournament detail with matches (public)."""
6050 try:
6051 tournament = tournament_dao.get_tournament_by_id(tournament_id)
6052 if not tournament:
6053 raise HTTPException(status_code=404, detail="Tournament not found")
6054 return tournament
6055 except HTTPException:
6056 raise
6057 except Exception as e:
6058 raise HTTPException(status_code=500, detail=str(e)) from e
6061@app.get("/api/admin/teams/lookup")
6062async def admin_team_lookup(
6063 name: str,
6064 current_user: dict[str, Any] = Depends(require_admin),
6065):
6066 """Look up a team by name and return exact + similar matches without creating anything.
6068 Used by the load-tournament-matches skill to confirm team resolution before POSTing matches.
6069 Returns:
6070 { exact: team | null, similar: [team, ...] }
6071 """
6072 try:
6073 return tournament_dao.lookup_teams_by_name(name)
6074 except Exception as e:
6075 raise HTTPException(status_code=500, detail=str(e)) from e
6078@app.get("/api/admin/tournaments")
6079async def admin_get_tournaments(
6080 current_user: dict[str, Any] = Depends(require_admin),
6081):
6082 """List all tournaments including inactive ones (admin)."""
6083 try:
6084 return tournament_dao.get_all_tournaments()
6085 except Exception as e:
6086 raise HTTPException(status_code=500, detail=str(e)) from e
6089@app.post("/api/admin/tournaments", status_code=201)
6090async def admin_create_tournament(
6091 payload: TournamentCreate,
6092 current_user: dict[str, Any] = Depends(require_admin),
6093):
6094 """Create a new tournament (admin)."""
6095 try:
6096 return tournament_dao.create_tournament(
6097 name=payload.name,
6098 start_date=payload.start_date,
6099 end_date=payload.end_date,
6100 location=payload.location,
6101 description=payload.description,
6102 age_group_ids=payload.age_group_ids,
6103 is_active=payload.is_active,
6104 )
6105 except Exception as e:
6106 raise HTTPException(status_code=500, detail=str(e)) from e
6109@app.put("/api/admin/tournaments/{tournament_id}")
6110async def admin_update_tournament(
6111 tournament_id: int,
6112 payload: TournamentUpdate,
6113 current_user: dict[str, Any] = Depends(require_admin),
6114):
6115 """Update tournament metadata (admin)."""
6116 try:
6117 updated = tournament_dao.update_tournament(
6118 tournament_id=tournament_id,
6119 name=payload.name,
6120 start_date=payload.start_date,
6121 end_date=payload.end_date,
6122 location=payload.location,
6123 description=payload.description,
6124 age_group_ids=payload.age_group_ids,
6125 is_active=payload.is_active,
6126 )
6127 if not updated:
6128 raise HTTPException(status_code=404, detail="Tournament not found")
6129 return updated
6130 except HTTPException:
6131 raise
6132 except Exception as e:
6133 raise HTTPException(status_code=500, detail=str(e)) from e
6136@app.delete("/api/admin/tournaments/{tournament_id}", status_code=204)
6137async def admin_delete_tournament(
6138 tournament_id: int,
6139 current_user: dict[str, Any] = Depends(require_admin),
6140):
6141 """Delete a tournament (admin). Matches are unlinked, not deleted."""
6142 try:
6143 tournament_dao.delete_tournament(tournament_id)
6144 except Exception as e:
6145 raise HTTPException(status_code=500, detail=str(e)) from e
6148@app.post("/api/admin/tournaments/{tournament_id}/matches", status_code=201)
6149async def admin_create_tournament_match(
6150 tournament_id: int,
6151 payload: TournamentMatchCreate,
6152 current_user: dict[str, Any] = Depends(require_admin),
6153):
6154 """Add a match to a tournament (admin)."""
6155 try:
6156 return tournament_dao.create_tournament_match(
6157 tournament_id=tournament_id,
6158 our_team_id=payload.our_team_id,
6159 opponent_name=payload.opponent_name,
6160 match_date=payload.match_date,
6161 age_group_id=payload.age_group_id,
6162 season_id=payload.season_id,
6163 is_home=payload.is_home,
6164 home_score=payload.home_score,
6165 away_score=payload.away_score,
6166 home_penalty_score=payload.home_penalty_score,
6167 away_penalty_score=payload.away_penalty_score,
6168 match_status=payload.match_status,
6169 tournament_group=payload.tournament_group,
6170 tournament_round=payload.tournament_round,
6171 scheduled_kickoff=payload.scheduled_kickoff,
6172 )
6173 except ValueError as e:
6174 raise HTTPException(status_code=422, detail=str(e)) from e
6175 except Exception as e:
6176 raise HTTPException(status_code=500, detail=str(e)) from e
6179@app.put("/api/admin/tournaments/{tournament_id}/matches/{match_id}")
6180async def admin_update_tournament_match(
6181 tournament_id: int,
6182 match_id: int,
6183 payload: TournamentMatchUpdate,
6184 current_user: dict[str, Any] = Depends(require_admin),
6185):
6186 """Update score, status, or context on a tournament match (admin)."""
6187 try:
6188 updated = tournament_dao.update_tournament_match(
6189 match_id=match_id,
6190 home_score=payload.home_score,
6191 away_score=payload.away_score,
6192 home_penalty_score=payload.home_penalty_score,
6193 away_penalty_score=payload.away_penalty_score,
6194 match_status=payload.match_status,
6195 tournament_group=payload.tournament_group,
6196 tournament_round=payload.tournament_round,
6197 scheduled_kickoff=payload.scheduled_kickoff,
6198 match_date=payload.match_date,
6199 swap_home_away=payload.swap_home_away,
6200 )
6201 if not updated:
6202 raise HTTPException(status_code=404, detail="Match not found")
6203 return updated
6204 except HTTPException:
6205 raise
6206 except ValueError as e:
6207 raise HTTPException(status_code=422, detail=str(e)) from e
6208 except Exception as e:
6209 raise HTTPException(status_code=500, detail=str(e)) from e
6212@app.delete("/api/admin/tournaments/{tournament_id}/matches/{match_id}", status_code=204)
6213async def admin_delete_tournament_match(
6214 tournament_id: int,
6215 match_id: int,
6216 current_user: dict[str, Any] = Depends(require_admin),
6217):
6218 """Remove a match from a tournament (admin). Deletes the match record."""
6219 try:
6220 tournament_dao.delete_tournament_match(match_id)
6221 except Exception as e:
6222 raise HTTPException(status_code=500, detail=str(e)) from e
6225if __name__ == "__main__":
6226 import uvicorn
6228 uvicorn.run(app, host="0.0.0.0", port=8000, access_log=False)