| """ |
| Admin API endpoints (Team/Organization Management). |
| """ |
| from uuid import uuid4 |
| from datetime import datetime |
| from typing import Optional, List |
| from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File, BackgroundTasks |
|
|
| from app.dependencies import ( |
| get_supabase, |
| get_current_user, |
| get_current_user_with_db, |
| require_team_account, |
| require_organization_admin, |
| require_staff_member, |
| require_linked_account |
| ) |
| from app.services.supabase_client import SupabaseService |
| from app.models.user import User, AccountType |
| from app.models.player import Player, PlayerCreate, PlayerUpdate |
| from app.models.schedule import Schedule, ScheduleCreate, ScheduleUpdate |
| from app.models.match import Match, MatchCreate, MatchUpdate |
| from app.models.notification import Notification, NotificationCreate, NotificationListResponse |
| from app.models.team import Organization, OrganizationUpdate |
| from app.models.stats import MatchStatUploadResponse, StatsConfirmRequest |
| from app.services.stats_extraction_service import extract_stats_from_file_background |
| from app.services.player_linking_service import auto_link_players_to_roster |
|
|
| router = APIRouter() |
|
|
| @router.put("/organization", response_model=Organization) |
| async def update_organization( |
| org_data: OrganizationUpdate, |
| current_user: dict = Depends(require_organization_admin), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Update details of the current user's organization. |
| """ |
| |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| raise HTTPException(status_code=404, detail="Organization not found") |
| |
| org_id = orgs[0]["id"] |
| update_dict = org_data.model_dump(exclude_unset=True) |
| |
| if not update_dict: |
| raise HTTPException(status_code=400, detail="No fields to update") |
| |
| updated = await supabase.update("organizations", org_id, update_dict) |
| return updated |
|
|
| |
| |
| |
|
|
| @router.put("/users/{user_id}/role") |
| async def update_user_role( |
| user_id: str, |
| role_data: dict, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Update a user's role (e.g. within the team context, or maybe account_type). |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| user_update = {"role": role_data.get("role")} |
| updated = await supabase.update("users", user_id, user_update) |
| return updated |
|
|
|
|
| @router.get("/users") |
| async def get_users( |
| role: Optional[str] = None, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Get users (players) managed by the admin/team owner. |
| """ |
| |
| org_id = current_user.get("organization_id") |
| if not org_id and current_user.get("account_type") == AccountType.TEAM.value: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return [] |
| org_id = orgs[0]["id"] |
| |
| if not org_id: |
| return [] |
|
|
| |
| players = await supabase.select("players", filters={"organization_id": str(org_id)}) |
| |
| |
| linked_user_ids = [str(p["user_id"]) for p in players if p.get("user_id")] |
| personal_profiles = {} |
| |
| if linked_user_ids: |
| try: |
| |
| all_profiles = await supabase.select_in("players", "user_id", linked_user_ids) |
| |
| for prof in all_profiles: |
| if not prof.get("organization_id"): |
| personal_profiles[str(prof["user_id"])] = prof |
| except Exception as e: |
| print(f"Warning: Failed to fetch personal profiles for roster fallback: {e}") |
|
|
| |
| for p in players: |
| user_id_str = str(p.get("user_id")) if p.get("user_id") else None |
| personal = personal_profiles.get(user_id_str) if user_id_str else None |
| |
| |
| def get_val(field, default=None): |
| v = p.get(field) |
| |
| if v is not None and v != "": |
| return v |
| |
| if personal and personal.get(field) is not None and personal.get(field) != "": |
| return personal[field] |
| return default |
|
|
| p["jersey_number"] = get_val("jersey_number", None) |
| p["position"] = get_val("position", "Not set") |
| p["status"] = get_val("status", "active") |
| |
| p_ppg = p.get("ppg") |
| p["ppg"] = float(p_ppg) if p_ppg is not None and p_ppg != "" else 0.0 |
| |
| return players |
|
|
| @router.post("/players") |
| async def create_player( |
| player_data: PlayerCreate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Add a new player to the team roster. |
| """ |
| org_id = current_user.get("organization_id") |
| if not org_id and current_user.get("account_type") == AccountType.TEAM.value: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| raise HTTPException(status_code=400, detail="No organization found for this account") |
| org_id = orgs[0]["id"] |
| |
| if not org_id: |
| raise HTTPException(status_code=403, detail="Account not linked to an organization") |
| player_dict = player_data.model_dump() |
| player_dict["organization_id"] = str(org_id) |
| player_dict["id"] = str(uuid4()) |
| player_dict["created_at"] = datetime.now().isoformat() |
| |
| |
| if player_dict.get("date_of_birth"): |
| player_dict["date_of_birth"] = player_dict["date_of_birth"].isoformat() |
| |
| saved = await supabase.insert("players", player_dict) |
| return saved |
|
|
| @router.put("/players/{player_id}") |
| async def update_player( |
| player_id: str, |
| player_data: PlayerUpdate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Update a player's profile. |
| """ |
| |
| player = await supabase.select_one("players", player_id) |
| if not player: |
| raise HTTPException(status_code=404, detail="Player not found") |
| |
| has_access = False |
| if current_user.get("account_type") == AccountType.TEAM.value: |
| if player.get("organization_id"): |
| org = await supabase.select_one("organizations", str(player["organization_id"])) |
| has_access = org and str(org.get("owner_id")) == str(current_user["id"]) |
| elif current_user.get("account_type") == AccountType.COACH.value: |
| coach_org = current_user.get("organization_id") |
| if coach_org and str(player.get("organization_id")) == str(coach_org): |
| has_access = True |
| |
| if not has_access: |
| raise HTTPException(status_code=403, detail="Access denied") |
| |
| update_dict = player_data.model_dump(exclude_unset=True) |
| if "date_of_birth" in update_dict and update_dict["date_of_birth"]: |
| update_dict["date_of_birth"] = update_dict["date_of_birth"].isoformat() |
| |
| updated = await supabase.update("players", player_id, update_dict) |
| return updated |
|
|
| @router.get("/players") |
| async def get_roster( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Get team roster (alias for players). |
| """ |
| return await get_users(role="player", current_user=current_user, supabase=supabase) |
|
|
| @router.patch("/players/{player_id}/status") |
| async def update_player_status( |
| player_id: str, |
| status_data: dict, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Update player status (active/injured/etc). |
| Note: status field might need to be added to players table or handled via metadata. |
| """ |
| |
| player = await supabase.select_one("players", player_id) |
| if not player: |
| raise HTTPException(status_code=404, detail="Player not found") |
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
| |
| if not org_id or str(player.get("organization_id")) != str(org_id): |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| try: |
| updated = await supabase.update("players", player_id, {"status": status_data.get("status")}) |
| return updated |
| except Exception: |
| return player |
|
|
|
|
| @router.delete("/players/{player_id}") |
| async def delete_player( |
| player_id: str, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Remove a player from the organization's roster. |
| This deletes the roster entry and unlinks the user account if linked. |
| """ |
| |
| player = await supabase.select_one("players", player_id) |
| if not player: |
| raise HTTPException(status_code=404, detail="Player not found") |
| |
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| raise HTTPException(status_code=404, detail="Organization not found") |
| org_id = orgs[0]["id"] |
| |
| |
| if player.get("organization_id") != org_id: |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| |
| linked_user_id = player.get("user_id") |
| if linked_user_id: |
| try: |
| await supabase.update("users", str(linked_user_id), {"organization_id": None}) |
| except Exception as e: |
| print(f"Warning: Failed to unlink user {linked_user_id}: {e}") |
|
|
| |
| await supabase.delete("players", player_id) |
| |
| return {"message": "Player removed from roster and unlinked successfully"} |
|
|
| @router.post("/players/{player_id}/link") |
| async def link_player_account( |
| player_id: str, |
| link_data: dict, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Link a roster player to an actual user account by email. |
| If player_id is "new", it will search for the user and create a roster entry. |
| """ |
| email = link_data.get("email") |
| if not email: |
| raise HTTPException(status_code=400, detail="Email is required") |
|
|
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| try: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| |
| if current_user.get("account_type") == AccountType.TEAM.value: |
| print(f"Auto-creating shell organization for team user {current_user['id']}") |
| new_org = await supabase.insert("organizations", { |
| "id": str(uuid4()), |
| "owner_id": current_user["id"], |
| "name": current_user.get("name") or "My Team" |
| }) |
| org_id = new_org["id"] |
| |
| await supabase.update("users", current_user["id"], {"organization_id": org_id}) |
| else: |
| raise HTTPException(status_code=404, detail="Organization not found. You must create an organization profile first.") |
| else: |
| org_id = orgs[0]["id"] |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"Error fetching/creating organization for linking: {e}") |
| raise HTTPException(status_code=500, detail="Error retrieving organization data") |
|
|
| |
| try: |
| users = await supabase.select("users", filters={"email": email}) |
| if not users: |
| raise HTTPException(status_code=404, detail=f"No account found with email {email}. The player must sign up first.") |
| target_user = users[0] |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"Error searching for user by email {email}: {e}") |
| raise HTTPException(status_code=500, detail="Error searching for player account") |
| |
| |
| current_linked_org = target_user.get("organization_id") |
| if current_linked_org and str(current_linked_org) != str(org_id): |
| raise HTTPException(status_code=400, detail="This user is already linked to another organization") |
|
|
| |
| org_name = "your team" |
| if org_id: |
| try: |
| org_record = await supabase.select_one("organizations", str(org_id)) |
| if org_record: |
| org_name = org_record.get("name", "your team") |
| except Exception: |
| pass |
|
|
| try: |
| if player_id == "new": |
| |
| existing_roster = await supabase.select("players", filters={ |
| "organization_id": str(org_id), |
| "user_id": str(target_user["id"]) |
| }) |
| if existing_roster: |
| raise HTTPException(status_code=400, detail="This player is already in your roster") |
| |
| |
| personal_profiles = await supabase.select("players", filters={"user_id": str(target_user["id"])}) |
| |
| personal_profile = next((p for p in personal_profiles if not p.get("organization_id")), None) |
|
|
| |
| new_player_id = str(uuid4()) |
| new_player_record = { |
| "id": new_player_id, |
| "organization_id": str(org_id), |
| "user_id": str(target_user["id"]), |
| "name": target_user.get("full_name") or target_user.get("email") or "New Player", |
| "created_at": datetime.now().isoformat() |
| } |
|
|
| |
| if personal_profile: |
| fields_to_copy = [ |
| "jersey_number", "position", "height_cm", "weight_kg", |
| "date_of_birth", "avatar_url", "phone", "address", |
| "experience_years", "bio", "status" |
| ] |
| for field in fields_to_copy: |
| if personal_profile.get(field) is not None: |
| new_player_record[field] = personal_profile[field] |
|
|
| await supabase.insert("players", new_player_record) |
| player_id = new_player_id |
| else: |
| |
| player = await supabase.select_one("players", player_id) |
| if not player: |
| raise HTTPException(status_code=404, detail="Player not found") |
| if str(player.get("organization_id")) != str(org_id): |
| raise HTTPException(status_code=403, detail="Access denied") |
| |
| |
| await supabase.update("players", player_id, {"user_id": target_user["id"]}) |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"Error updating/creating roster entry: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to update team roster: {e}") |
| |
| |
| try: |
| await supabase.update("users", target_user["id"], {"organization_id": str(org_id)}) |
| except Exception as e: |
| print(f"Error updating user organization link: {e}") |
| |
| |
| |
| try: |
| await supabase.insert("notifications", { |
| "id": str(uuid4()), |
| "recipient_id": target_user["id"], |
| "title": "Team Link", |
| "message": f"You have been added to the team roster of {org_name}.", |
| "type": "team_invite", |
| "read": False, |
| "created_at": datetime.now().isoformat() |
| }) |
| except Exception as e: |
| print(f"Failed to create link notification: {e}") |
|
|
| return {"message": "Account linked successfully", "user": {"id": target_user["id"], "name": target_user.get("full_name") or target_user.get("email")}} |
|
|
| @router.get("/staff") |
| async def get_staff( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Get coaching staff linked to the organization. |
| """ |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return [] |
| org_id = orgs[0]["id"] |
| |
| staff = await supabase.select("users", filters={ |
| "organization_id": org_id, |
| "account_type": "coach" |
| }) |
| return staff |
|
|
|
|
|
|
| @router.post("/staff-link") |
| @router.post("/staff-link/") |
| async def link_staff_member( |
| link_data: dict, |
| current_user: dict = Depends(require_team_account), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Link a coach account to the organization and assign a role. |
| """ |
| email = link_data.get("email") |
| role = link_data.get("role", "Coach") |
| |
| print(f"DEBUG: Staff linking attempt for {email} by admin {current_user.get('email')}") |
| |
| if not email: |
| raise HTTPException(status_code=400, detail="Email is required") |
|
|
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| try: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| |
| if current_user.get("account_type") == AccountType.TEAM.value: |
| print(f"Auto-creating shell organization for team user {current_user['id']} (staff link)") |
| new_org = await supabase.insert("organizations", { |
| "id": str(uuid4()), |
| "owner_id": current_user["id"], |
| "name": current_user.get("name") or "My Team" |
| }) |
| org_id = new_org["id"] |
| |
| await supabase.update("users", current_user["id"], {"organization_id": org_id}) |
| else: |
| raise HTTPException(status_code=404, detail="Organization not found") |
| else: |
| org_id = orgs[0]["id"] |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"Error fetching/creating organization for staff linking: {e}") |
| raise HTTPException(status_code=500, detail="Error retrieving organization data") |
| |
| |
| search_email = email.strip().lower() |
| users = await supabase.select("users", filters={"email": search_email}) |
| |
| if not users: |
| |
| users = await supabase.select("users", filters={"email": email.strip()}) |
| |
| if not users: |
| raise HTTPException(status_code=404, detail=f"No account found with email {email}. The coach must sign up first.") |
| |
| target_user = users[0] |
| |
| if target_user["account_type"] not in ["coach", "personal"]: |
| raise HTTPException(status_code=400, detail=f"This account is a {target_user['account_type']} account. Staff members must have a Coach or Personal account.") |
|
|
| if target_user.get("organization_id"): |
| if str(target_user.get("organization_id")) == str(org_id): |
| raise HTTPException(status_code=400, detail="This coach is already linked to your organization.") |
| else: |
| raise HTTPException(status_code=400, detail="This coach is already linked to another organization.") |
|
|
| |
| await supabase.update("users", target_user["id"], { |
| "organization_id": org_id, |
| "staff_role": role |
| }) |
| |
| |
| try: |
| await supabase.insert("notifications", { |
| "id": str(uuid4()), |
| "recipient_id": target_user["id"], |
| "title": "Staff Invitation", |
| "message": f"You have been added as a {role} to the organization.", |
| "type": "team_invite", |
| "read": False, |
| "created_at": datetime.now().isoformat() |
| }) |
| except Exception: |
| pass |
|
|
| return {"message": "Staff member linked successfully", "user": {"id": target_user["id"], "role": role}} |
|
|
| @router.delete("/staff/{user_id}") |
| async def remove_staff_member( |
| user_id: str, |
| current_user: dict = Depends(require_organization_admin), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Remove a staff member from the organization. |
| """ |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| raise HTTPException(status_code=404, detail="Organization not found") |
| org_id = orgs[0]["id"] |
|
|
| |
| target_user = await supabase.select_one("users", user_id) |
| if not target_user or target_user.get("organization_id") != org_id: |
| raise HTTPException(status_code=403, detail="Access denied or user not found") |
|
|
| |
| await supabase.update("users", user_id, { |
| "organization_id": None, |
| "staff_role": None |
| }) |
| |
| return {"message": "Staff member removed successfully"} |
|
|
| |
| |
| |
|
|
| @router.get("/schedule") |
| async def get_schedule( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Get team schedule.""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return [] |
| org_id = orgs[0]["id"] |
| |
| schedules = await supabase.select("schedules", filters={"organization_id": org_id}) |
| return schedules |
|
|
| @router.post("/schedule") |
| async def create_schedule_event( |
| event_data: ScheduleCreate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Create a schedule event (Coach only).""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| raise HTTPException(status_code=403, detail="You must be linked to an organization") |
| |
| event_dict = event_data.model_dump() |
| event_dict["organization_id"] = str(org_id) |
| event_dict["created_by"] = current_user["id"] |
| event_dict["id"] = str(uuid4()) |
| |
| event_dict["start_time"] = event_dict["start_time"].isoformat() |
| event_dict["end_time"] = event_dict["end_time"].isoformat() |
| |
| saved = await supabase.insert("schedules", event_dict) |
| return saved |
|
|
| @router.put("/schedule/{event_id}") |
| async def update_schedule_event( |
| event_id: str, |
| event_data: ScheduleUpdate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Update a schedule event (Coach only).""" |
| |
| event = await supabase.select_one("schedules", event_id) |
| if not event or event.get("organization_id") != current_user.get("organization_id"): |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| update_dict = event_data.model_dump(exclude_unset=True) |
| if not update_dict: |
| raise HTTPException(status_code=400, detail="No fields to update") |
| |
| if "start_time" in update_dict: |
| update_dict["start_time"] = update_dict["start_time"].isoformat() |
| if "end_time" in update_dict: |
| update_dict["end_time"] = update_dict["end_time"].isoformat() |
| |
| updated = await supabase.update("schedules", event_id, update_dict) |
| return updated |
|
|
| @router.delete("/schedule/{event_id}") |
| async def delete_schedule_event( |
| event_id: str, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Delete a schedule event (Coach only).""" |
| event = await supabase.select_one("schedules", event_id) |
| if not event or event.get("organization_id") != current_user.get("organization_id"): |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| await supabase.delete("schedules", event_id) |
| return {"message": "Event deleted"} |
| |
| |
| |
|
|
| @router.get("/matches") |
| async def get_matches( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Get matches for the current user's organization (Owner or Staff).""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return [] |
| org_id = orgs[0]["id"] |
| |
| matches = await supabase.select("matches", filters={"organization_id": org_id}) |
| return matches |
|
|
| @router.post("/matches") |
| async def create_match( |
| match_data: MatchCreate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Create a match (Coach only).""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| raise HTTPException(status_code=403, detail="You must be linked to an organization") |
| |
| match_dict = match_data.model_dump() |
| match_dict["id"] = str(uuid4()) |
| match_dict["organization_id"] = str(org_id) |
| match_dict["date"] = match_dict["date"].isoformat() |
| |
| saved = await supabase.insert("matches", match_dict) |
| return saved |
|
|
| @router.put("/matches/{match_id}") |
| async def update_match( |
| match_id: str, |
| match_data: MatchUpdate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Update a match (Coach only).""" |
| match = await supabase.select_one("matches", match_id) |
| if not match or match.get("organization_id") != current_user.get("organization_id"): |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| update_dict = match_data.model_dump(exclude_unset=True) |
| if "date" in update_dict: |
| update_dict["date"] = update_dict["date"].isoformat() |
| |
| updated = await supabase.update("matches", match_id, update_dict) |
| return updated |
|
|
| @router.delete("/matches/{match_id}") |
| async def delete_match( |
| match_id: str, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Delete a match (Coach only).""" |
| match = await supabase.select_one("matches", match_id) |
| if not match or match.get("organization_id") != current_user.get("organization_id"): |
| raise HTTPException(status_code=403, detail="Access denied") |
|
|
| await supabase.delete("matches", match_id) |
| return {"message": "Match deleted"} |
|
|
|
|
| @router.get("/matches/{match_id}/player-stats") |
| async def get_match_player_stats( |
| match_id: str, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Get all player stats for a specific match, enriched with player name/jersey info.""" |
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return [] |
| org_id = orgs[0]["id"] |
|
|
| stats = await supabase.select("match_player_stats", filters={ |
| "match_id": match_id, |
| "organization_id": org_id |
| }) |
|
|
| |
| player_ids = list({s["player_profile_id"] for s in stats if s.get("player_profile_id")}) |
| players = [] |
| if player_ids: |
| players = await supabase.select_in("players", "id", player_ids) |
| player_map = {p["id"]: p for p in players} |
|
|
| for s in stats: |
| pid = s.get("player_profile_id") |
| p = player_map.get(pid, {}) |
| s["player_name"] = p.get("name", "Unknown") |
| s["player_jersey"] = p.get("jersey_number") |
| s["player_position"] = p.get("position") |
|
|
| match = await supabase.select_one("matches", match_id) |
| return {"match": match, "stats": stats} |
|
|
| |
| |
| |
|
|
| @router.get("/notifications") |
| async def get_notifications( |
| current_user: dict = Depends(get_current_user_with_db), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| try: |
| user_id = current_user["id"] |
| |
| import uuid |
| try: |
| uuid.UUID(str(user_id)) |
| except ValueError: |
| |
| return [] |
|
|
| notifs = await supabase.select("notifications", filters={"recipient_id": str(user_id)}, order_by="created_at") |
| return notifs |
| except Exception as e: |
| print(f"Error in get_notifications: {e}") |
| return [] |
|
|
| @router.post("/notifications") |
| async def create_notification( |
| notif_data: NotificationCreate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| notif_dict = notif_data.model_dump() |
| notif_dict["id"] = str(uuid4()) |
| saved = await supabase.insert("notifications", notif_dict) |
| return saved |
|
|
| @router.put("/notifications/{notification_id}/mark-as-read") |
| @router.put("/notifications/{notification_id}/read") |
| async def mark_notification_read( |
| notification_id: str, |
| current_user: dict = Depends(get_current_user_with_db), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| updated = await supabase.update("notifications", notification_id, {"read": True}) |
| return updated |
|
|
| @router.put("/notifications/read-all") |
| async def mark_all_notifications_read( |
| current_user: dict = Depends(get_current_user_with_db), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| |
| notifs = await supabase.select("notifications", filters={"recipient_id": current_user["id"], "read": False}) |
| for n in notifs: |
| await supabase.update("notifications", n["id"], {"read": True}) |
| return {"message": "All notifications marked as read"} |
|
|
| @router.put("/notifications/{notification_id}") |
| async def update_notification( |
| notification_id: str, |
| notification_data: NotificationCreate, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| update_dict = notification_data.model_dump(exclude_unset=True) |
| updated = await supabase.update("notifications", notification_id, update_dict) |
| return updated |
|
|
| @router.delete("/notifications/{notification_id}") |
| async def delete_notification( |
| notification_id: str, |
| current_user: dict = Depends(get_current_user_with_db), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| await supabase.delete("notifications", notification_id) |
| return {"message": "Notification deleted"} |
|
|
|
|
| @router.get("/stats") |
| async def get_stats( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if not orgs: |
| return {"total_players": 0, "total_matches": 0, "videos_analyzed": 0, "win_rate": 0, "organization_id": None} |
| org_id = orgs[0]["id"] |
| players = await supabase.select("players", filters={"organization_id": org_id}) |
| matches = await supabase.select("matches", filters={"organization_id": org_id}) |
| videos = await supabase.select("videos", filters={"organization_id": org_id}) |
| |
| |
| played_matches = [m for m in matches if m.get("score_us") is not None and m.get("score_them") is not None] |
| wins = len([m for m in played_matches if (m.get("score_us") or 0) > (m.get("score_them") or 0)]) |
| win_rate = round((wins / len(played_matches) * 100), 1) if played_matches else 0 |
| |
| |
| performance_data = [] |
| |
| for i in range(7): |
| performance_data.append({ |
| "date": f"Day {i+1}", |
| "players": len(players), |
| "performance": win_rate or (70 + i), |
| "games": len(played_matches) |
| }) |
|
|
| return { |
| "total_players": len(players), |
| "total_matches": len(matches), |
| "videos_analyzed": len(videos), |
| "upcoming_matches": len([m for m in matches if datetime.fromisoformat(m["date"].replace("Z", "+00:00")).replace(tzinfo=None) > datetime.utcnow()]), |
| "win_rate": win_rate, |
| "performance_data": performance_data, |
| "organization_id": org_id |
| } |
|
|
| |
| |
| |
|
|
| @router.get("/profile") |
| async def get_profile( |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| |
| user_info = current_user |
| org_id = current_user.get("organization_id") |
| |
| |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| |
| |
| if not orgs and org_id: |
| org_record = await supabase.select_one("organizations", str(org_id)) |
| if org_record: |
| orgs = [org_record] |
| |
| return {"user": user_info, "organization": orgs[0] if orgs else None} |
|
|
| @router.put("/profile") |
| async def update_profile( |
| profile_data: dict, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| |
| if "user" in profile_data: |
| user_payload = {} |
| for k, v in profile_data["user"].items(): |
| if k in ["id", "created_at", "updated_at", "email", "account_type"]: |
| continue |
| user_payload[k] = v |
| if user_payload: |
| await supabase.update("users", current_user["id"], user_payload) |
| if "organization" in profile_data: |
| org_id = current_user.get("organization_id") |
| orgs = [] |
| if org_id: |
| org_record = await supabase.select_one("organizations", str(org_id)) |
| if org_record: |
| orgs = [org_record] |
| else: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| |
| if orgs: |
| |
| org_payload = {} |
| for k, v in (profile_data["organization"] or {}).items(): |
| |
| if k in ["id", "owner_id", "created_at", "updated_at"]: |
| continue |
| org_payload[k] = v |
|
|
| if org_payload: |
| print(f"DEBUG update_profile: updating org {orgs[0]['id']} with payload: {org_payload}") |
| try: |
| await supabase.update("organizations", orgs[0]["id"], org_payload) |
| except Exception as e: |
| print(f"ERROR updating organization: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to update organization: {str(e)}") |
| else: |
| |
| org_payload = { |
| "owner_id": current_user["id"], |
| "name": profile_data["organization"].get("name", "New Team") |
| } |
| for k, v in (profile_data["organization"] or {}).items(): |
| if k in ["id", "owner_id", "created_at", "updated_at"]: |
| continue |
| org_payload[k] = v |
| |
| print(f"DEBUG update_profile: creating new org for user {current_user['id']} with payload: {org_payload}") |
| try: |
| new_org = await supabase.insert("organizations", org_payload) |
| |
| await supabase.update("users", current_user["id"], {"organization_id": new_org["id"]}) |
| except Exception as e: |
| print(f"ERROR creating organization: {e}") |
| raise HTTPException(status_code=500, detail=f"Failed to create organization: {str(e)}") |
| return {"message": "Profile updated"} |
|
|
| @router.get("/security/settings") |
| async def get_security_settings(current_user: dict = Depends(get_current_user_with_db)): |
| |
| return {"two_factor_enabled": False, "login_alerts": True} |
|
|
| @router.put("/security/settings") |
| async def update_security_settings(settings: dict, current_user: dict = Depends(get_current_user_with_db)): |
| |
| return settings |
|
|
| @router.get("/security/logs") |
| async def get_security_logs(current_user: dict = Depends(get_current_user_with_db)): |
| |
| return [ |
| {"id": 1, "action": "Login", "ip": "127.0.0.1", "timestamp": datetime.now().isoformat()}, |
| ] |
|
|
| |
| |
| |
|
|
| @router.post("/matches/{match_id}/stats-upload") |
| async def upload_match_stats( |
| match_id: str, |
| background_tasks: BackgroundTasks, |
| file: UploadFile = File(...), |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Upload a box score PDF or image, save to storage, schedule extraction.""" |
| |
| match = await supabase.select_one("matches", match_id) |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
| |
| if not match or match.get("organization_id") != org_id: |
| raise HTTPException(status_code=403, detail="Access denied") |
| |
| org_id = match["organization_id"] |
| upload_id = str(uuid4()) |
| |
| |
| file_bytes = await file.read() |
| is_pdf = file.content_type == "application/pdf" |
| file_ext = "pdf" if is_pdf else "jpg" |
| storage_path = f"org_{org_id}/match_{match_id}/{upload_id}.{file_ext}" |
| |
| try: |
| url = await supabase.upload_file("match-stats", storage_path, file_bytes, file.content_type) |
| except Exception as e: |
| print(f"Warning: file upload skip or error: {e}") |
| url = f"/local/{storage_path}" |
| |
| |
| upload_data = { |
| "id": upload_id, |
| "match_id": match_id, |
| "organization_id": org_id, |
| "uploaded_by": current_user["id"], |
| "storage_path": storage_path, |
| "file_type": "pdf" if is_pdf else "image", |
| "extract_status": "queued", |
| "created_at": datetime.now().isoformat(), |
| "updated_at": datetime.now().isoformat() |
| } |
| await supabase.insert("match_stat_uploads", upload_data) |
| |
| |
| background_tasks.add_task(extract_stats_from_file_background, upload_id, org_id) |
| |
| return {"upload_id": upload_id, "status": "queued"} |
|
|
| @router.get("/stats-upload/{upload_id}", response_model=MatchStatUploadResponse) |
| async def get_stats_upload( |
| upload_id: str, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Poll for extraction status and JSON preview.""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
|
|
| upload = await supabase.select_one("match_stat_uploads", upload_id) |
| if not upload or upload.get("organization_id") != org_id: |
| raise HTTPException(status_code=404, detail="Upload not found") |
| |
| return upload |
|
|
| @router.post("/stats-upload/{upload_id}/confirm") |
| async def confirm_stats_upload( |
| upload_id: str, |
| confirm_data: StatsConfirmRequest, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Confirm mapping and persist stats.""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
|
|
| upload = await supabase.select_one("match_stat_uploads", upload_id) |
| if not upload or upload.get("organization_id") != org_id: |
| raise HTTPException(status_code=404, detail="Upload not found") |
| |
| org_id = upload["organization_id"] |
| match_id = upload["match_id"] |
| |
| confirmed_json = confirm_data.extracted_json |
| |
| |
| if confirmed_json.final_score_for is not None: |
| await supabase.update("matches", match_id, { |
| "score_us": confirmed_json.final_score_for, |
| "score_them": confirmed_json.final_score_against |
| }) |
| |
| |
| stats_to_insert = [] |
| |
| import re |
| from app.services.stats_utils import parse_made_attempted |
| |
| |
| all_players_to_save = [] |
| if confirmed_json.starters: |
| for p in confirmed_json.starters: |
| p.is_starter = True |
| all_players_to_save.append(p) |
| if confirmed_json.bench: |
| for p in confirmed_json.bench: |
| p.is_starter = False |
| all_players_to_save.append(p) |
| |
| |
| if not all_players_to_save and confirmed_json.players: |
| all_players_to_save = confirmed_json.players |
|
|
| |
| stats_to_insert = [] |
| for player_row in all_players_to_save: |
| if not player_row.linked_player_profile_id: |
| continue |
| |
| |
| fg_m, fg_a = (player_row.fg_m, player_row.fg_a) if (player_row.fg_m or player_row.fg_a) else parse_made_attempted(player_row.fg) |
| tp_m, tp_a = (player_row.tp_m, player_row.tp_a) if (player_row.tp_m or player_row.tp_a) else parse_made_attempted(player_row.tp) |
| thp_m, thp_a = (player_row.thp_m, player_row.thp_a) if (player_row.thp_m or player_row.thp_a) else parse_made_attempted(player_row.thp) |
| ft_m, ft_a = (player_row.ft_m, player_row.ft_a) if (player_row.ft_m or player_row.ft_a) else parse_made_attempted(player_row.ft) |
| |
| stats_to_insert.append({ |
| "id": str(uuid4()), |
| "match_id": match_id, |
| "organization_id": org_id, |
| "player_profile_id": str(player_row.linked_player_profile_id), |
| "source": "official_upload", |
| "mins": player_row.mins, |
| "pts": player_row.pts, |
| "fgm": fg_m, |
| "fga": fg_a, |
| "tp_m": tp_m, |
| "tp_a": tp_a, |
| "thp_m": thp_m, |
| "thp_a": thp_a, |
| "ft_m": ft_m, |
| "ft_a": ft_a, |
| "off_reb": player_row.off, |
| "def_reb": player_row.def_reb, |
| "reb": player_row.reb, |
| "ast": player_row.ast, |
| "to_cnt": player_row.to_cnt, |
| "stl": player_row.stl, |
| "blk": player_row.blk, |
| "blkr": player_row.blkr, |
| "pf": player_row.pf, |
| "fls_on": player_row.fls_on, |
| "plus_minus": player_row.plus_minus or 0, |
| "index_rating": player_row.index or 0, |
| "row_confidence": player_row.row_confidence or 1.0, |
| "is_starter": player_row.is_starter, |
| "created_at": datetime.now().isoformat() |
| }) |
| |
| |
| for stat in stats_to_insert: |
| try: |
| |
| existing = await supabase.select("match_player_stats", filters={ |
| "match_id": stat["match_id"], |
| "player_profile_id": stat["player_profile_id"] |
| }) |
| if existing: |
| await supabase.update("match_player_stats", existing[0]["id"], stat) |
| else: |
| await supabase.insert("match_player_stats", stat) |
| except Exception as e: |
| print(f"Error persisting player stat row for {stat['player_profile_id']}: {e}") |
|
|
| |
| if confirmed_json.team_statistics: |
| try: |
| team_stats = confirmed_json.team_statistics.model_dump() |
| team_stats["match_id"] = match_id |
| team_stats["organization_id"] = org_id |
| |
| existing_team = await supabase.select("match_team_stats", filters={"match_id": match_id}) |
| if existing_team: |
| await supabase.update("match_team_stats", existing_team[0]["id"], team_stats) |
| else: |
| await supabase.insert("match_team_stats", team_stats) |
| except Exception as e: |
| print(f"Error persisting team stats: {e}") |
|
|
| |
| await supabase.update("match_stat_uploads", upload_id, { |
| "extract_status": "confirmed", |
| "extracted_json": confirmed_json.model_dump(by_alias=True) |
| }) |
| |
| return {"message": "Stats confirmed and saved"} |
|
|
| @router.post("/stats-upload/{upload_id}/retry") |
| async def retry_stats_upload( |
| upload_id: str, |
| background_tasks: BackgroundTasks, |
| current_user: dict = Depends(require_staff_member), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """Re-run extraction.""" |
| org_id = current_user.get("organization_id") |
| if not org_id: |
| orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
|
|
| upload = await supabase.select_one("match_stat_uploads", upload_id) |
| if not upload or upload.get("organization_id") != org_id: |
| raise HTTPException(status_code=404, detail="Upload not found") |
| |
| await supabase.update("match_stat_uploads", upload_id, {"extract_status": "queued"}) |
| background_tasks.add_task(extract_stats_from_file_background, upload_id, upload["organization_id"]) |
| |
| return {"message": "Retry queued"} |
|
|