Coverage for app.py: 12.52%

2754 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-15 13:02 +0000

1import asyncio 

2import os 

3from datetime import UTC, datetime 

4from typing import Any 

5 

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 

11 

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 

76 

77# Legacy flag kept for backwards compatibility so existing envs keep working. 

78DISABLE_SECURITY = os.getenv("DISABLE_SECURITY", "false").lower() == "true" 

79 

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 

96 

97 

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() 

103 

104 # Determine which environment to use 

105 app_env = os.getenv("APP_ENV", "local") # Default to local 

106 

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) 

115 

116 

117load_environment() 

118 

119# Configure structured logging with JSON output for Loki 

120from logging_config import get_logger, setup_logging 

121 

122setup_logging(service_name="backend") 

123logger = get_logger(__name__) 

124 

125app = FastAPI(title="Enhanced Sports League API", version="2.0.0") 

126 

127# Setup Prometheus metrics - exposes /metrics endpoint for Grafana 

128from metrics_config import setup_metrics 

129 

130setup_metrics(app) 

131logger.info("prometheus_metrics_enabled", endpoint="/metrics") 

132 

133# Configure Rate Limiting 

134# TODO: Fix middleware order issue 

135# limiter = create_rate_limit_middleware(app) 

136 

137# Add CSRF Protection Middleware 

138# TODO: Fix middleware order issue 

139# app.middleware("http")(csrf_middleware) 

140 

141 

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 ] 

151 

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 ] 

158 

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()] 

162 

163 # Get environment-specific origins 

164 environment = os.getenv("ENVIRONMENT", "development") 

165 

166 # All production domains point to the same namespace (missing-table-dev) 

167 all_cloud_origins = production_origins 

168 

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 

176 

177 

178origins = get_cors_origins() 

179 

180app.add_middleware( 

181 CORSMiddleware, 

182 allow_origins=origins, 

183 allow_credentials=True, 

184 allow_methods=["*"], 

185 allow_headers=["*"], 

186) 

187 

188# Add trace middleware for distributed logging (session_id, request_id) 

189app.add_middleware(TraceMiddleware) 

190 

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") 

202 

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) 

219 

220 

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 

225 

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) 

231 

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) 

238 

239 

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" 

248 

249 

250# === Include API Routers === 

251app.include_router(invites_router) 

252app.include_router(invite_requests_router) 

253app.include_router(channel_requests_router) 

254 

255# Version endpoint 

256import contextlib 

257 

258from endpoints.version import router as version_router 

259 

260app.include_router(version_router) 

261 

262# === Authentication Endpoints === 

263 

264 

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. 

267 

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 

272 

273 try: 

274 logger.info(f"Updating existing user {user_data.username} role via invite code") 

275 

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 

287 

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

293 

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") 

303 

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) 

312 

313 # Redeem the invitation 

314 invite_service = InviteService(db_conn_holder_obj.client) 

315 invite_service.redeem_invitation(user_data.invite_code, user_id) 

316 

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) 

319 

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 

331 

332 

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 

338 

339 client_ip = get_client_ip(request) 

340 audit_logger = logger.bind(flow="auth_signup", username=user_data.username, client_ip=client_ip) 

341 

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") 

350 

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 ) 

358 

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") 

368 

369 if invite_info: 

370 logger.info(f"Valid invite code for {invite_info['invite_type']}: {user_data.invite_code}") 

371 

372 # Convert username to internal email for Supabase Auth 

373 internal_email = username_to_internal_email(user_data.username) 

374 

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 ) 

390 

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 } 

402 

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") 

416 

417 # Insert user profile 

418 player_dao.create_or_update_user_profile(profile_data) 

419 

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") 

424 

425 audit_logger.info( 

426 "auth_signup_success", 

427 user_id=response.user.id, 

428 used_invite=bool(user_data.invite_code), 

429 ) 

430 

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}." 

438 

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") 

443 

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 

455 

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") 

475 

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) 

484 

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) 

488 

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) 

498 

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 

502 

503 

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 

509 

510 client_ip = get_client_ip(request) 

511 auth_logger = logger.bind(flow="auth_login", username=user_data.username, client_ip=client_ip) 

512 

513 try: 

514 # Convert username to internal email for Supabase Auth 

515 internal_email = username_to_internal_email(user_data.username) 

516 

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}) 

520 

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 

526 

527 auth_logger.info("auth_login_success", user_id=response.user.id) 

528 

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}") 

540 

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") 

571 

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 

588 

589 

590@app.post("/api/auth/forgot-password") 

591async def forgot_password(request: Request, body: ForgotPasswordRequest): 

592 """ 

593 Initiate password reset flow. 

594 

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) 

602 

603 _GENERIC_RESPONSE = {"message": "If an account exists, a reset link has been sent."} 

604 

605 try: 

606 user = player_dao.get_user_for_password_reset(body.identifier) 

607 

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 

612 

613 user_id: str = user["id"] 

614 username: str = user.get("username", body.identifier) 

615 email_on_file: str | None = user.get("email") 

616 

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} 

621 

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 

632 

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 

642 

643 pw_logger.info("forgot_password_email_sent", user_id=user_id) 

644 return _GENERIC_RESPONSE 

645 

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 

652 

653 

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) 

662 

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.") 

666 

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 

675 

676 

677@app.get("/api/auth/username-available/{username}") 

678async def check_username_availability(username: str): 

679 """ 

680 Check if a username is available. 

681 

682 Returns: 

683 - available: boolean 

684 - suggestions: list of alternative usernames if taken 

685 """ 

686 import re 

687 

688 from auth import check_username_available 

689 

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 } 

697 

698 # Check availability 

699 available = await check_username_available(db_conn_holder_obj.client, username) 

700 

701 if available: 

702 return {"available": True, "message": f"Username '{username}' is available!"} 

703 else: 

704 # Generate suggestions 

705 import hashlib 

706 

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 ] 

713 

714 return { 

715 "available": False, 

716 "message": f"Username '{username}' is taken", 

717 "suggestions": suggestions, 

718 } 

719 

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 

723 

724 

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 } 

738 

739 

740class OAuthCallbackData(BaseModel): 

741 """OAuth callback data from frontend.""" 

742 

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 

748 

749 

750@app.post("/api/auth/oauth/callback") 

751async def oauth_callback(callback_data: OAuthCallbackData, request: Request): 

752 """ 

753 Handle OAuth callback from frontend. 

754 

755 After Supabase OAuth flow completes, the frontend sends the tokens here 

756 to verify and get/create the user profile. 

757 

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 

763 

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 ) 

772 

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 

777 

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() 

781 

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") 

785 

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 {} 

790 

791 oauth_logger = oauth_logger.bind(user_id=user_id, email=email) 

792 

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 

798 

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 

801 

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 ) 

808 

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) 

811 

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 ) 

823 

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 ) 

833 

834 oauth_logger.info("oauth_login_success", username=existing_profile.get("username")) 

835 return {"message": "Login successful", "user": existing_profile} 

836 

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) 

841 

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") 

845 

846 oauth_logger = oauth_logger.bind(invite_type=invite_info.get("invite_type")) 

847 

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") 

856 

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 ) 

869 

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") 

880 

881 # Generate a unique username from email 

882 base_username = email.split("@")[0].replace(".", "_").replace("-", "_")[:40] 

883 username = base_username 

884 

885 # Check if username exists and make unique if needed 

886 from auth import check_username_available 

887 

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 

895 

896 username = f"{base_username}_{str(uuid.uuid4())[:8]}" 

897 break 

898 

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 } 

911 

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() 

921 

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) 

924 

925 oauth_logger.info("oauth_signup_success", username=username, role=role) 

926 

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 } 

942 

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 

949 

950 

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") 

958 

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 } 

994 

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 

998 

999 

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 

1007 

1008 # Email update with validation 

1009 if profile_data.email is not None: 

1010 # Basic email format validation 

1011 import re 

1012 

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") 

1016 

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") 

1028 

1029 update_data["email"] = profile_data.email if profile_data.email.strip() else None 

1030 

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 

1037 

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 

1049 

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 

1055 

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 

1061 

1062 if update_data: 

1063 from datetime import datetime 

1064 

1065 update_data["updated_at"] = datetime.now(UTC).isoformat() 

1066 player_dao.update_user_profile(current_user["user_id"], update_data) 

1067 

1068 return {"message": "Profile updated successfully"} 

1069 

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 

1075 

1076 

1077# ============================================================================= 

1078# PLAYER PROFILE PHOTO ENDPOINTS 

1079# ============================================================================= 

1080 

1081MAX_PHOTO_SIZE = 500 * 1024 # 500KB 

1082ALLOWED_PHOTO_TYPES = ["image/jpeg", "image/png", "image/webp"] 

1083 

1084 

1085class StorageHelper: 

1086 """Helper for direct Supabase Storage API calls (bypasses RLS issues with client).""" 

1087 

1088 def __init__(self): 

1089 self.url = os.getenv("SUPABASE_URL") 

1090 self.service_key = os.getenv("SUPABASE_SERVICE_KEY") 

1091 

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() 

1109 

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} 

1128 

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}" 

1132 

1133 

1134storage_helper = StorageHelper() 

1135 

1136 

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 

1142 

1143 

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. 

1151 

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") 

1158 

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 ) 

1165 

1166 # Read file content 

1167 content = await file.read() 

1168 

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 ) 

1175 

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}" 

1181 

1182 try: 

1183 # Upload to player-photos bucket using direct HTTP API 

1184 storage_helper.upload("player-photos", file_path, content, file.content_type) 

1185 

1186 # Get public URL 

1187 public_url = storage_helper.get_public_url("player-photos", file_path) 

1188 

1189 # Update user profile with the photo URL 

1190 from datetime import datetime 

1191 

1192 photo_column = f"photo_{slot}_url" 

1193 update_data = {photo_column: public_url, "updated_at": datetime.now(UTC).isoformat()} 

1194 

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 

1199 

1200 player_dao.update_user_profile(user_id, update_data) 

1201 

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 } 

1209 

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 

1213 

1214 

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. 

1218 

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") 

1224 

1225 user_id = current_user["user_id"] 

1226 

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) 

1232 

1233 if not current_url: 

1234 raise HTTPException(status_code=404, detail=f"No photo in slot {slot}") 

1235 

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 

1243 

1244 # Update profile to remove URL 

1245 from datetime import datetime 

1246 

1247 update_data = {photo_column: None, "updated_at": datetime.now(UTC).isoformat()} 

1248 

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 

1257 

1258 player_dao.update_user_profile(user_id, update_data) 

1259 

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} 

1263 

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 

1269 

1270 

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. 

1276 

1277 The specified slot must have a photo uploaded. 

1278 """ 

1279 slot = slot_data.slot 

1280 user_id = current_user["user_id"] 

1281 

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") 

1286 

1287 if not photo_url: 

1288 raise HTTPException(status_code=400, detail=f"No photo in slot {slot}. Upload a photo first.") 

1289 

1290 # Update profile photo slot 

1291 from datetime import datetime 

1292 

1293 player_dao.update_user_profile( 

1294 user_id, {"profile_photo_slot": slot, "updated_at": datetime.now(UTC).isoformat()} 

1295 ) 

1296 

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} 

1300 

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 

1306 

1307 

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. 

1313 

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

1318 

1319 try: 

1320 from datetime import datetime 

1321 

1322 update_data = {"updated_at": datetime.now(UTC).isoformat()} 

1323 

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 

1356 

1357 if len(update_data) > 1: # More than just updated_at 

1358 player_dao.update_user_profile(user_id, update_data) 

1359 

1360 # Return updated profile 

1361 updated_profile = player_dao.get_user_profile_with_relationships(user_id) 

1362 return {"message": "Customization updated", "profile": updated_profile} 

1363 

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 

1367 

1368 

1369# === Player Team History Endpoints === 

1370 

1371 

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. 

1375 

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

1380 

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 

1387 

1388 

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). 

1394 

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

1399 

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 

1406 

1407 

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. 

1411 

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). 

1415 

1416 Returns: 

1417 List of current teams with club info for team selector UI. 

1418 """ 

1419 user_id = current_user["user_id"] 

1420 

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 

1427 

1428 

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. 

1435 

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

1441 

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 ) 

1452 

1453 if entry: 

1454 return {"success": True, "entry": entry} 

1455 else: 

1456 raise HTTPException(status_code=500, detail="Failed to create history entry") 

1457 

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 

1461 

1462 

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. 

1470 

1471 Players can only update their own history entries. 

1472 """ 

1473 user_id = current_user["user_id"] 

1474 

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") 

1482 

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 ) 

1490 

1491 if entry: 

1492 return {"success": True, "entry": entry} 

1493 else: 

1494 raise HTTPException(status_code=500, detail="Failed to update history entry") 

1495 

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 

1501 

1502 

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. 

1506 

1507 Players can only delete their own history entries. 

1508 """ 

1509 user_id = current_user["user_id"] 

1510 

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") 

1518 

1519 success = player_dao.delete_player_history_entry(history_id) 

1520 

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") 

1525 

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 

1531 

1532 

1533# === Admin Player Management Endpoints === 

1534 

1535 

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. 

1546 

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 

1555 

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 

1559 

1560 

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). 

1568 

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 ) 

1578 

1579 if result: 

1580 return {"success": True, "player": result} 

1581 else: 

1582 raise HTTPException(status_code=404, detail="Player not found") 

1583 

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 

1589 

1590 

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). 

1598 

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 ) 

1611 

1612 if not entry: 

1613 raise HTTPException(status_code=500, detail="Failed to create team assignment") 

1614 

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 

1628 

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) 

1640 

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"]}) 

1650 

1651 return {"success": True, "assignment": entry} 

1652 

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 

1658 

1659 

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). 

1666 

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) 

1671 

1672 if result: 

1673 return {"success": True, "assignment": result} 

1674 else: 

1675 raise HTTPException(status_code=404, detail="Assignment not found") 

1676 

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 

1682 

1683 

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() 

1689 

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 

1693 

1694 

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) 

1702 

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") 

1717 

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 

1722 

1723 

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 {} 

1730 

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 ] 

1741 

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 } 

1785 

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 

1789 

1790 

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 

1795 

1796 

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 

1802 

1803 update_data = {"role": role_data.role, "updated_at": datetime.now(UTC).isoformat()} 

1804 

1805 if role_data.team_id: 

1806 update_data["team_id"] = role_data.team_id 

1807 

1808 player_dao.update_user_profile(role_data.user_id, update_data) 

1809 

1810 return {"message": "User role updated successfully"} 

1811 

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 

1815 

1816 

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 

1824 

1825 try: 

1826 update_data = {} 

1827 

1828 # Validate and handle username update 

1829 if profile_data.username is not None: 

1830 username = profile_data.username.strip() 

1831 

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 ) 

1838 

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") 

1844 

1845 update_data["username"] = username.lower() 

1846 

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}") 

1863 

1864 # Handle display name update 

1865 if profile_data.display_name is not None: 

1866 update_data["display_name"] = profile_data.display_name.strip() 

1867 

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 

1876 

1877 if not update_data: 

1878 raise HTTPException(status_code=400, detail="No fields to update") 

1879 

1880 # Update user_profiles 

1881 player_dao.update_user_profile(profile_data.user_id, update_data) 

1882 

1883 return {"message": "User profile updated successfully"} 

1884 

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 

1890 

1891 

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). 

1899 

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") 

1907 

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}") 

1914 

1915 # Explicitly delete from user_profiles (no FK cascade exists) 

1916 auth_service_client.table("user_profiles").delete().eq("id", user_id).execute() 

1917 

1918 # Invalidate cached user list so subsequent queries reflect the deletion 

1919 from dao.base_dao import clear_cache 

1920 

1921 clear_cache("mt:dao:players:*") 

1922 

1923 logger.info(f"User {user_id} deleted by admin {current_user.get('user_id')}") 

1924 return {"message": "User deleted successfully"} 

1925 

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 

1931 

1932 

1933# === CSRF Token Endpoint === 

1934 

1935 

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) 

1940 

1941 

1942# === Reference Data Endpoints === 

1943 

1944 

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 

1958 

1959 

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 

1971 

1972 

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 

1986 

1987 

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 

1997 

1998 

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 

2008 

2009 

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 

2021 

2022 

2023# === Enhanced Team Endpoints === 

2024 

2025 

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. 

2039 

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) 

2048 

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") 

2055 

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 

2059 

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() 

2067 

2068 # Enrich teams with additional data if requested 

2069 if include_parent or include_game_count: 

2070 enriched_teams = [] 

2071 

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() 

2076 

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} 

2082 

2083 for team in teams: 

2084 team_data = {**team} 

2085 

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 

2092 

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 

2098 

2099 # Add game count if requested 

2100 if include_game_count: 

2101 team_data["game_count"] = game_counts.get(team["id"], 0) 

2102 

2103 enriched_teams.append(team_data) 

2104 

2105 return enriched_teams 

2106 

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 

2113 

2114 

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. 

2122 

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. 

2125 

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") 

2131 

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") 

2135 

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 

2145 

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) 

2163 

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 

2171 

2172 raise HTTPException(status_code=500, detail=error_str) from e 

2173 

2174 

2175# === Enhanced Match Endpoints === 

2176 

2177 

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 ) 

2201 

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 ] 

2218 

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 

2225 

2226 

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. 

2232 

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 

2242 

2243 

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. 

2254 

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 

2270 

2271 

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) 

2282 

2283 if not match: 

2284 raise HTTPException(status_code=404, detail=f"Match with ID {match_id} not found") 

2285 

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 

2294 

2295 

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()}") 

2308 

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") 

2313 

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 

2342 

2343 

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") 

2356 

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") 

2360 

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") 

2365 

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 

2389 

2390 

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). 

2398 

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") 

2407 

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") 

2411 

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") 

2417 

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)}") 

2423 

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 } 

2459 

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") 

2466 

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)}") 

2470 

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") 

2479 

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 

2485 

2486 

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") 

2495 

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") 

2499 

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 

2508 

2509 

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

2522 

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 ] 

2539 

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 

2544 

2545 

2546# === Live Match Endpoints === 

2547 

2548 

2549def calculate_match_minute(match: dict) -> tuple[int | None, int | None]: 

2550 """Calculate the current match minute based on timestamps. 

2551 

2552 Args: 

2553 match: Match dict with kickoff_time, halftime_start, second_half_start, half_duration 

2554 

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 

2561 

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 

2566 

2567 if not kickoff_time: 

2568 return None, None 

2569 

2570 now = datetime.now(UTC) 

2571 

2572 # Parse kickoff time 

2573 if isinstance(kickoff_time, str): 

2574 kickoff_time = datetime.fromisoformat(kickoff_time.replace("Z", "+00:00")) 

2575 

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 

2583 

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 

2589 

2590 # At halftime - return end of first half 

2591 if halftime_start: 

2592 return half_duration, None 

2593 

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 

2597 

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 

2602 

2603 

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. 

2610 

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") 

2617 

2618 # Get recent events 

2619 events = match_event_dao.get_events(match_id, limit=50) 

2620 match_state["recent_events"] = events 

2621 

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 

2628 

2629 

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). 

2637 

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") 

2645 

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") 

2649 

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 ) 

2662 

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") 

2670 

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 ) 

2685 

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 

2689 

2690 clear_cache("mt:dao:stats:*") 

2691 

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 

2698 

2699 

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. 

2707 

2708 Only accessible by admins, club managers, and team managers who can edit this match. 

2709 

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") 

2718 

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") 

2722 

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") 

2729 

2730 # Resolve player name - either from player_id or from the request 

2731 player_name = goal.player_name 

2732 player_id = goal.player_id 

2733 

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']}") 

2743 

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") 

2747 

2748 # Calculate new scores 

2749 home_score = current_match.get("home_score") or 0 

2750 away_score = current_match.get("away_score") or 0 

2751 

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") 

2758 

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") 

2764 

2765 # Calculate match minute for the goal 

2766 match_minute, extra_time = calculate_match_minute(current_match) 

2767 

2768 # Create goal event 

2769 goal_message = f"GOAL! {team_name} - {player_name}" 

2770 if goal.message: 

2771 goal_message += f" ({goal.message})" 

2772 

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 ) 

2785 

2786 # Update player stats if player_id is provided 

2787 if player_id: 

2788 player_stats_dao.increment_goals(player_id, match_id) 

2789 

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 

2796 

2797 

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. 

2805 

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") 

2813 

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") 

2816 

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") 

2819 

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") 

2826 

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" 

2829 

2830 card_message = f"{card_label}: {player_name}" 

2831 if card.message: 

2832 card_message += f" ({card.message})" 

2833 

2834 # Auto-calculate match minute 

2835 match_minute, extra_time = calculate_match_minute(current_match) 

2836 

2837 user_id = current_user.get("user_id") or current_user.get("id") 

2838 

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 ) 

2851 

2852 if not event: 

2853 raise HTTPException(status_code=500, detail="Failed to create card event") 

2854 

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() 

2863 

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 ) 

2871 

2872 return event 

2873 

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 

2879 

2880 

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. 

2888 

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") 

2896 

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 ) 

2906 

2907 if not event: 

2908 raise HTTPException(status_code=500, detail="Failed to post message") 

2909 

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 

2916 

2917 

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). 

2925 

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") 

2933 

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") 

2937 

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") 

2944 

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) 

2948 

2949 if not success: 

2950 raise HTTPException(status_code=500, detail="Failed to delete event") 

2951 

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 

2957 

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 

2962 

2963 match_dao.update_match_score(match_id, home_score, away_score, updated_by=user_id) 

2964 

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) 

2969 

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 

2976 

2977 

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. 

2986 

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 

2995 

2996 

2997# === Match Lineup Endpoints === 

2998 

2999 

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. 

3007 

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 

3017 

3018 

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. 

3027 

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") 

3034 

3035 # Convert Pydantic models to dicts for storage 

3036 positions_data = [{"player_id": p.player_id, "position": p.position} for p in lineup.positions] 

3037 

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 ) 

3046 

3047 if not saved_lineup: 

3048 raise HTTPException(status_code=500, detail="Failed to save lineup") 

3049 

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) 

3053 

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 ) 

3061 

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 

3068 

3069 

3070# === Post-Match Stats Endpoints === 

3071 

3072 

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. 

3075 

3076 Checks: 

3077 - Match is completed 

3078 - User can edit the match 

3079 - Team is a participant in the match 

3080 

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") 

3085 

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") 

3088 

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") 

3091 

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") 

3098 

3099 

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. 

3107 

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") 

3115 

3116 validate_post_match_access(current_user, current_match, goal.team_id) 

3117 

3118 # Resolve player - either from roster or free-text name 

3119 player_id = goal.player_id 

3120 player_name = goal.player_name 

3121 

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") 

3131 

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 ) 

3137 

3138 goal_message = f"GOAL! {team_name} - {player_name}" 

3139 if goal.message: 

3140 goal_message += f" ({goal.message})" 

3141 

3142 user_id = current_user.get("user_id") or current_user.get("id") 

3143 

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 ) 

3156 

3157 if not event: 

3158 raise HTTPException(status_code=500, detail="Failed to create goal event") 

3159 

3160 # Increment player goal stats (only if roster player) 

3161 if player_id: 

3162 player_stats_dao.increment_goals(player_id, match_id) 

3163 

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 ) 

3171 

3172 return event 

3173 

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 

3179 

3180 

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. 

3188 

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") 

3195 

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") 

3204 

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") 

3214 

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") 

3219 

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) 

3224 

3225 logger.info( 

3226 "post_match_goal_removed", 

3227 match_id=match_id, 

3228 event_id=event_id, 

3229 player_id=player_id, 

3230 ) 

3231 

3232 return {"detail": "Goal removed"} 

3233 

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 

3239 

3240 

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") 

3252 

3253 validate_post_match_access(current_user, current_match, sub.team_id) 

3254 

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") 

3261 

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") 

3267 

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']}") 

3270 

3271 sub_message = f"SUB: {player_in_name} on for {player_out_name}" 

3272 

3273 user_id = current_user.get("user_id") or current_user.get("id") 

3274 

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 ) 

3287 

3288 if not event: 

3289 raise HTTPException(status_code=500, detail="Failed to create substitution event") 

3290 

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 ) 

3298 

3299 return event 

3300 

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 

3306 

3307 

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") 

3319 

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") 

3327 

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") 

3336 

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") 

3341 

3342 logger.info( 

3343 "post_match_substitution_removed", 

3344 match_id=match_id, 

3345 event_id=event_id, 

3346 ) 

3347 

3348 return {"detail": "Substitution removed"} 

3349 

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 

3355 

3356 

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. 

3364 

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") 

3371 

3372 validate_post_match_access(current_user, current_match, card.team_id) 

3373 

3374 # Resolve player - either from roster or free-text name 

3375 player_id = card.player_id 

3376 player_name = card.player_name 

3377 

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") 

3387 

3388 card_label = "RED CARD" if card.card_type == "red_card" else "YELLOW CARD" 

3389 

3390 card_message = f"{card_label}: {player_name}" 

3391 if card.message: 

3392 card_message += f" ({card.message})" 

3393 

3394 user_id = current_user.get("user_id") or current_user.get("id") 

3395 

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 ) 

3408 

3409 if not event: 

3410 raise HTTPException(status_code=500, detail="Failed to create card event") 

3411 

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() 

3421 

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 ) 

3429 

3430 return event 

3431 

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 

3437 

3438 

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") 

3450 

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") 

3458 

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") 

3467 

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() 

3478 

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") 

3483 

3484 logger.info( 

3485 "post_match_card_removed", 

3486 match_id=match_id, 

3487 event_id=event_id, 

3488 ) 

3489 

3490 return {"detail": "Card removed"} 

3491 

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 

3497 

3498 

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") 

3510 

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") 

3513 

3514 stats = player_stats_dao.get_team_match_stats(match_id, team_id) 

3515 return {"stats": stats} 

3516 

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 

3522 

3523 

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") 

3536 

3537 validate_post_match_access(current_user, current_match, team_id) 

3538 

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 ] 

3550 

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") 

3554 

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 ) 

3561 

3562 # Return updated stats 

3563 stats = player_stats_dao.get_team_match_stats(match_id, team_id) 

3564 return {"stats": stats} 

3565 

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 

3571 

3572 

3573# === Enhanced League Table Endpoint === 

3574 

3575 

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

3596 

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 ) 

3603 

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 ) 

3612 

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 

3617 

3618 

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 ) 

3639 

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 ) 

3650 

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 

3655 

3656 

3657# === Admin CRUD Endpoints === 

3658 

3659 

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 

3670 

3671 

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 

3689 

3690 

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 

3704 

3705 

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 

3716 

3717 

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 

3731 

3732 

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 

3746 

3747 

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 

3758 

3759 

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 

3773 

3774 

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 

3785 

3786 

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 

3806 

3807 

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 

3822 

3823 

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 

3835 

3836 

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 

3856 

3857 

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 

3871 

3872 

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 

3893 

3894 

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 

3908 

3909 

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 

3928 

3929 

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 

3949 

3950 

3951# === Club Endpoints === 

3952 

3953 

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. 

3957 

3958 Args: 

3959 include_teams: If true, enriches clubs with their teams list (default: true) 

3960 

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") 

3969 

3970 if not include_teams: 

3971 # Return clubs without team details (faster for dropdowns) 

3972 return clubs 

3973 

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) 

3983 

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") 

3994 

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 

3999 

4000 

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 

4015 

4016 

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") 

4028 

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 

4035 

4036 

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) 

4055 

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 

4065 

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 

4068 

4069 

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 

4093 

4094 

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). 

4102 

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 ) 

4113 

4114 # Read file content 

4115 content = await file.read() 

4116 

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 ) 

4124 

4125 # Determine file extension 

4126 ext = "png" if file.content_type == "image/png" else "jpg" 

4127 file_path = f"{club_id}.{ext}" 

4128 

4129 try: 

4130 # Get the Supabase client from the DAO 

4131 storage = match_dao.client.storage 

4132 

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 ) 

4137 

4138 # Generate and upload size variants (_sm=64px, _md=128px) for PNGs 

4139 if ext == "png": 

4140 from io import BytesIO 

4141 

4142 from PIL import Image as PILImage 

4143 

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}") 

4156 

4157 # Get public URL (points to base image) 

4158 public_url = storage.from_("club-logos").get_public_url(file_path) 

4159 

4160 # Update the club with the new logo URL 

4161 updated_club = club_dao.update_club(club_id=club_id, logo_url=public_url) 

4162 

4163 if not updated_club: 

4164 raise HTTPException(status_code=404, detail=f"Club with id {club_id} not found") 

4165 

4166 logger.info(f"Uploaded logo for club {club_id}: {public_url}") 

4167 return {"logo_url": public_url, "club": updated_club} 

4168 

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 

4174 

4175 

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). 

4179 

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) 

4194 

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 

4201 

4202 raise HTTPException(status_code=500, detail="An unexpected error occurred while deleting the club.") from e 

4203 

4204 

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") 

4215 

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") 

4221 

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 

4229 

4230 

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") 

4242 

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") 

4248 

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 

4258 

4259 

4260# === Match-Scraper Integration Endpoints === 

4261 

4262 

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. 

4270 

4271 This endpoint queues match data to Celery workers for: 

4272 - Validation 

4273 - Team lookup 

4274 - Duplicate detection 

4275 - Database insertion 

4276 

4277 Returns a task ID for tracking the processing status. 

4278 

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 

4284 

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}") 

4289 

4290 # Convert Pydantic model to dict for Celery 

4291 match_dict = match_data.model_dump() 

4292 

4293 # Queue the task to Celery 

4294 task = process_match_data.delay(match_dict) 

4295 

4296 logger.info(f"Queued match processing task: {task.id}") 

4297 

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 } 

4309 

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 

4313 

4314 

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. 

4319 

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 

4327 

4328 task = AsyncResult(task_id) 

4329 

4330 response = { 

4331 "task_id": task_id, 

4332 "state": task.state, 

4333 "ready": task.ready(), 

4334 } 

4335 

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" 

4345 

4346 return response 

4347 

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 

4351 

4352 

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()}") 

4364 

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") 

4369 

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 ) 

4381 

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}") 

4385 

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 ) 

4400 

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}") 

4412 

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 ) 

4428 

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") 

4437 

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 

4441 

4442 

4443# === Backward Compatibility Endpoints === 

4444 

4445 

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 } 

4471 

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 ) 

4481 

4482 if basic_match: 

4483 # Enhanced match: include season, age group, match type, division if provided 

4484 enhanced_match = True 

4485 

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 

4494 

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 } 

4502 

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 

4507 

4508 

4509# === Team Roster & Player Profile Endpoints === 

4510 

4511 

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. 

4516 

4517 Players can view rosters for any team within their club. 

4518 This allows browsing teammates across different age groups. 

4519 

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") 

4532 

4533 # Get user's club_id from their profile's team_id, club_id, or player_team_history 

4534 user_club_ids = set() 

4535 

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"]) 

4539 

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"]) 

4545 

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"]) 

4556 

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") 

4560 

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") 

4565 

4566 # Get players 

4567 players = player_dao.get_team_players(team_id) 

4568 

4569 return {"success": True, "team": team, "players": players} 

4570 

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 

4576 

4577 

4578# === Roster Management Endpoints (new players table) === 

4579 

4580 

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. 

4589 

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") 

4598 

4599 # Get roster from players table 

4600 roster = roster_dao.get_team_roster(team_id, season_id) 

4601 

4602 return {"success": True, "team_id": team_id, "season_id": season_id, "roster": roster} 

4603 

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 

4609 

4610 

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. 

4619 

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") 

4628 

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") 

4633 

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 ) 

4644 

4645 if not created: 

4646 raise HTTPException(status_code=500, detail="Failed to create roster entry") 

4647 

4648 return {"success": True, "player": created} 

4649 

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 

4655 

4656 

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. 

4665 

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") 

4674 

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} 

4678 

4679 # Filter out duplicates 

4680 new_players = [p.model_dump() for p in data.players if p.jersey_number not in existing_numbers] 

4681 

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 } 

4689 

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 ) 

4697 

4698 return { 

4699 "success": True, 

4700 "created": created, 

4701 "created_count": len(created), 

4702 "skipped_count": len(data.players) - len(new_players), 

4703 } 

4704 

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 

4710 

4711 

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. 

4721 

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") 

4731 

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 ) 

4739 

4740 if not updated: 

4741 raise HTTPException(status_code=500, detail="Failed to update roster entry") 

4742 

4743 return {"success": True, "player": updated} 

4744 

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 

4750 

4751 

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. 

4761 

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") 

4772 

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") 

4777 

4778 # Update number 

4779 updated = roster_dao.update_jersey_number(player_id, data.new_number) 

4780 

4781 if not updated: 

4782 raise HTTPException(status_code=500, detail="Failed to update jersey number") 

4783 

4784 return {"success": True, "player": updated} 

4785 

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 

4791 

4792 

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). 

4802 

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") 

4811 

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") 

4819 

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 ) 

4824 

4825 if not success: 

4826 raise HTTPException(status_code=500, detail="Failed to renumber roster") 

4827 

4828 # Return updated roster 

4829 roster = roster_dao.get_team_roster(team_id, season_id) 

4830 

4831 return {"success": True, "roster": roster} 

4832 

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 

4838 

4839 

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). 

4848 

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") 

4858 

4859 # Soft delete 

4860 success = roster_dao.delete_player(player_id) 

4861 

4862 if not success: 

4863 raise HTTPException(status_code=500, detail="Failed to delete roster entry") 

4864 

4865 return {"success": True, "message": "Player removed from roster"} 

4866 

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 

4872 

4873 

4874# ============================================================================= 

4875# Player Stats Endpoints 

4876# ============================================================================= 

4877 

4878 

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. 

4886 

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") 

4895 

4896 # Get season stats 

4897 stats = player_stats_dao.get_player_season_stats(player_id, season_id) 

4898 

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 } 

4912 

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 

4918 

4919 

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. 

4927 

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") 

4936 

4937 # Get team stats 

4938 stats = player_stats_dao.get_team_stats(team_id, season_id) 

4939 

4940 return { 

4941 "team_id": team_id, 

4942 "team_name": team.get("name"), 

4943 "season_id": season_id, 

4944 "players": stats, 

4945 } 

4946 

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 

4952 

4953 

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. 

4961 

4962 Looks up the player record linked to the user's profile 

4963 and returns their aggregated stats for the specified season. 

4964 

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

4974 

4975 # Find player record linked to this user 

4976 player = roster_dao.get_player_by_user_profile_id(user_id) 

4977 

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 } 

4993 

4994 player_id = player["id"] 

4995 

4996 # Get season stats 

4997 stats = player_stats_dao.get_player_season_stats(player_id, season_id) 

4998 

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 } 

5013 

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 

5019 

5020 

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. 

5025 

5026 Players can view profiles of anyone in their club. 

5027 This allows browsing teammates across different age groups. 

5028 

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") 

5041 

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"]) 

5054 

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"]) 

5063 

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") 

5067 

5068 target_team_id = target_profile.get("team_id") 

5069 

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}") 

5090 

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 } 

5118 

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 

5124 

5125 

5126# ============================================================================= 

5127# Playoff Bracket Endpoints 

5128# ============================================================================= 

5129 

5130 

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 

5145 

5146 

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. 

5153 

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") 

5168 

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") 

5173 

5174 home_team_id = match.get("home_team_id") 

5175 away_team_id = match.get("away_team_id") 

5176 

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") 

5180 

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") 

5189 

5190 # Determine the winner 

5191 winner_team_id = home_team_id if match["home_score"] > match["away_score"] else away_team_id 

5192 

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") 

5197 

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 

5206 

5207 if not user_is_winner: 

5208 raise HTTPException(status_code=403, detail="Only the winning team's manager can advance the winner") 

5209 

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 

5220 

5221 

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. 

5228 

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") 

5243 

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") 

5248 

5249 home_team_id = match.get("home_team_id") 

5250 away_team_id = match.get("away_team_id") 

5251 

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") 

5255 

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") 

5261 

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 

5269 

5270 if not can_forfeit: 

5271 raise HTTPException(status_code=403, detail="You can only forfeit your own team") 

5272 

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 

5282 

5283 

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 

5298 

5299 

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). 

5306 

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 ) 

5324 

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 ] 

5334 

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 

5352 

5353 

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 

5368 

5369 

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 

5391 

5392 

5393# ============================================================================= 

5394# Goals Management Endpoints (Admin) 

5395# ============================================================================= 

5396 

5397 

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 

5422 

5423 

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). 

5431 

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") 

5443 

5444 match_id = event["match_id"] 

5445 

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) 

5455 

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 

5466 

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") 

5470 

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 

5477 

5478 

5479# ============================================================================= 

5480# Cache Management Endpoints (Admin Only) 

5481# ============================================================================= 

5482 

5483 

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 

5488 

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 } 

5496 

5497 try: 

5498 # Get all cache keys 

5499 all_keys = list(redis_client.scan_iter(match="mt:dao:*", count=1000)) 

5500 

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" 

5507 

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) 

5512 

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 

5521 

5522 

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 

5527 

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 

5535 

5536 

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). 

5543 

5544 Valid types: playoffs, matches, players, clubs, teams, standings, etc. 

5545 """ 

5546 from dao.base_dao import clear_cache 

5547 

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 ) 

5555 

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 

5564 

5565 

5566# ============================================================================= 

5567# User Login Activity Endpoints (Admin Only) 

5568# ============================================================================= 

5569 

5570 

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

5579 

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

5588 

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 } 

5598 

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") 

5603 

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 

5608 

5609 

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) 

5624 

5625 if username: 

5626 query = query.ilike("username", f"%{username}%") 

5627 if success is not None: 

5628 query = query.eq("success", success) 

5629 

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 

5640 

5641 

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"} 

5647 

5648 

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 } 

5659 

5660 overall_healthy = True 

5661 

5662 # Check basic API 

5663 health_status["checks"]["api"] = {"status": "healthy", "message": "API is responding"} 

5664 

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") 

5677 

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 } 

5684 

5685 # Check reference data availability 

5686 try: 

5687 seasons = season_dao.get_all_seasons() 

5688 match_types = match_type_dao.get_all_match_types() 

5689 

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 } 

5696 

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" 

5701 

5702 except Exception as e: 

5703 health_status["checks"]["reference_data"] = { 

5704 "status": "unhealthy", 

5705 "message": f"Reference data check failed: {e!s}", 

5706 } 

5707 

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 } 

5725 

5726 # Set overall status 

5727 check_statuses = [check.get("status", "unknown") for check in health_status["checks"].values()] 

5728 

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" 

5734 

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 ) 

5743 

5744 # Return appropriate HTTP status code 

5745 if overall_healthy: 

5746 return health_status 

5747 else: 

5748 from fastapi import HTTPException 

5749 

5750 raise HTTPException(status_code=503, detail=health_status) 

5751 

5752 

5753# === Agent Endpoints === 

5754 

5755 

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. 

5764 

5765 Returns match counts grouped by age group, league, and division 

5766 with status breakdowns to help the agent decide what to scrape. 

5767 

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 

5781 

5782 

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. 

5795 

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 

5815 

5816 

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. 

5823 

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}") 

5832 

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 

5847 

5848 if not found: 

5849 raise HTTPException(status_code=404, detail="Match not found or already has a score") 

5850 

5851 return { 

5852 "status": "cancelled", 

5853 "match": f"{payload['home_team']} vs {payload['away_team']} {payload['match_date']}", 

5854 } 

5855 

5856 

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). 

5865 

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 

5873 

5874 if team is None: 

5875 return Response(status_code=204) 

5876 return team 

5877 

5878 

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 

5893 

5894 

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. 

5901 

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}") 

5909 

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 

5915 

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 } 

5924 

5925 

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 

5941 

5942 

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") 

5953 

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 

5963 

5964 return {"event_id": event_id, "status": new_status} 

5965 

5966 

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 

5981 

5982 

5983# ============================================================================= 

5984# Tournament Endpoints 

5985# ============================================================================= 

5986 

5987 

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 

5996 

5997 

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 

6006 

6007 

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 

6023 

6024 

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 

6036 

6037 

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 

6045 

6046 

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 

6059 

6060 

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. 

6067 

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 

6076 

6077 

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 

6087 

6088 

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 

6107 

6108 

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 

6134 

6135 

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 

6146 

6147 

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 

6177 

6178 

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 

6210 

6211 

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 

6223 

6224 

6225if __name__ == "__main__": 

6226 import uvicorn 

6227 

6228 uvicorn.run(app, host="0.0.0.0", port=8000, access_log=False)