BakoAI / app /api /admin.py
Okidi Norbert
Fix: resolve StaffLinkRequest NameError on startup
941c3ad
"""
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.
"""
# Find organization owned by current user
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
# ============================================
# USERS & PLAYERS MANAGEMENT
# ============================================
@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).
"""
# Verify ownership/permission
# Assuming this updates metadata or account_type?
# User roles in this system seem to be 'account_type'.
# If frontend means 'player role' (Forward, Guard), that's usually on player profile.
# adminAPI.js calls `updateUserRole`.
# If it's account_type (team/personal), that's critical.
# If it's a team role (Captain, Starter), that's not in Users table.
# Assuming it updates 'role' metadata for now.
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.
"""
# Determine organization_id
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 []
# Get players in the org
players = await supabase.select("players", filters={"organization_id": str(org_id)})
# Identify linked users to fetch their personal profiles for data fallback
linked_user_ids = [str(p["user_id"]) for p in players if p.get("user_id")]
personal_profiles = {}
if linked_user_ids:
try:
# Batch fetch all potential personal profiles for these users
all_profiles = await supabase.select_in("players", "user_id", linked_user_ids)
# Filter for true personal profiles (no organization_id)
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}")
# Ensure all fields for the roster are present, falling back to personal data if team data is blank
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
# Helper to get value with fallback
def get_val(field, default=None):
v = p.get(field)
# If team-specific value exists and isn't empty, use it
if v is not None and v != "":
return v
# Otherwise, try to use the player's personal profile value
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")
# PPG is typically calculated per organization/team context
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()
# Handle date_of_birth if present
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.
"""
# Verify access
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.
"""
# Check if player belongs to owner's org
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.
"""
# 1. Get player
player = await supabase.select_one("players", player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
# 2. Get organization id
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"]
# 3. Verify ownership
if player.get("organization_id") != org_id:
raise HTTPException(status_code=403, detail="Access denied")
# 4. Unlink user if associated
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}")
# 5. Delete roster entry
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")
# 1. Get organization
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:
# Auto-create shell organization for Team accounts to improve onboarding
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"]
# Also link user to this org
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")
# 2. Search for user by email
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")
# Check if already linked to another org
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")
# 3. Handle player roster entry
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":
# Check if already in roster
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")
# Search for an existing personal player profile for this user
personal_profiles = await supabase.select("players", filters={"user_id": str(target_user["id"])})
# Filter for the one without an org (personal)
personal_profile = next((p for p in personal_profiles if not p.get("organization_id")), None)
# Create new roster entry
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 they have a personal profile, copy the stats
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:
# Verify existing player belongs to owner's org
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")
# Update player profile with user_id
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}")
# 4. Update user profile with organization_id
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}")
# Not a fatal error if roster was linked, but still problematic
# 5. Create a notification
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")
# 1. Get organization
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:
# Auto-create shell organization for Team accounts
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"]
# Also link user to this org
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")
# 2. Search for user by email (case-insensitive handling)
search_email = email.strip().lower()
users = await supabase.select("users", filters={"email": search_email})
if not users:
# Try finding with exact casing just in case
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.")
# 3. Update user profile
await supabase.update("users", target_user["id"], {
"organization_id": org_id,
"staff_role": role
})
# 4. Create notification
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"]
# Verify user belongs to org
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")
# Unlink
await supabase.update("users", user_id, {
"organization_id": None,
"staff_role": None
})
return {"message": "Staff member removed successfully"}
# ============================================
# SCHEDULE MANAGEMENT
# ============================================
@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)."""
# Verify owner's org match
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"}
# ============================================
# MATCH MANAGEMENT
# ============================================
@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."""
# Verify org access
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
})
# Enrich each stat row with player name/jersey from the players table
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}
# ============================================
# NOTIFICATIONS & 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"]
# Basic validation for UUID if using Postgres
import uuid
try:
uuid.UUID(str(user_id))
except ValueError:
# If not a UUID (like dev-id-...), return empty list instead of crashing Postgres
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 [] # Return empty list on error to keep UI stable
@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),
):
# Mark all unread notifications for the current user as read
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),
):
# Mock aggregation or real counts
org_id = current_user.get("organization_id")
if not org_id:
# Fallback for owners who might not have org_id in token yet
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})
# Calculate win rate
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
# Generate some performance trend data for the frontend chart
performance_data = []
# Just a simple trend for the last 7 days or match entries
for i in range(7):
performance_data.append({
"date": f"Day {i+1}",
"players": len(players),
"performance": win_rate or (70 + i), # Fallback to a nice looking trend if no matches
"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
}
# ============================================
# SECURITY & PROFILE
# ============================================
@router.get("/profile")
async def get_profile(
current_user: dict = Depends(require_staff_member),
supabase: SupabaseService = Depends(get_supabase),
):
# Get user info and associated organization
user_info = current_user
org_id = current_user.get("organization_id")
# Try fetching as owner first
orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
# If not owner but has org_id (like a coach), fetch by 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),
):
# Update user or org
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:
# Avoid sending nested objects (which might not map to DB columns)
org_payload = {}
for k, v in (profile_data["organization"] or {}).items():
# Do not try to update primary key or generated fields
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:
# Create new organization
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)
# Also link user to this org
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)):
# Mock settings
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)):
# Mock update
return settings
@router.get("/security/logs")
async def get_security_logs(current_user: dict = Depends(get_current_user_with_db)):
# Mock logs
return [
{"id": 1, "action": "Login", "ip": "127.0.0.1", "timestamp": datetime.now().isoformat()},
]
# ============================================
# OFFICIAL STATS IMPORT
# ============================================
@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."""
# 1. Verify match ownership
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())
# 2. Upload file to storage
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}"
# 3. Create upload record
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)
# 4. Trigger extraction job
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
# Update match score
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
})
# Persist the rows
stats_to_insert = []
import re
from app.services.stats_utils import parse_made_attempted
# 1. Collect all players (starters + bench)
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)
# Fallback if starters/bench were not populated but players was
if not all_players_to_save and confirmed_json.players:
all_players_to_save = confirmed_json.players
# 2. Prepare stat rows for database
stats_to_insert = []
for player_row in all_players_to_save:
if not player_row.linked_player_profile_id:
continue
# Use already parsed values if available, otherwise try to parse from the ratio string
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()
})
# 3. Bulk insert/update player stats
for stat in stats_to_insert:
try:
# Check for existing stat row for this player in this match
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}")
# 4. Persist team statistics
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}")
# 5. Mark upload as confirmed
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"}