"""RTSport Coach Dashboard API — Status & Messages endpoints. Provides 5 endpoints for the Coach Dashboard: 1. GET /api/v1/dashboard/coach/status — Team-wide injury/recovery analytics 2. GET /api/v1/dashboard/coach/athlete/{id}/timeline — Athlete case milestones 3. GET /api/v1/messages/coach — Conversation threads 4. GET /api/v1/messages/coach/{athlete_id} — Message thread history 5. POST /api/v1/messages/coach/{athlete_id} — Send a message Uses Bearer JWT from the existing HoffDesk auth pattern. Mock data features Preble High School athletes. """ import uuid import time import logging from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Header, HTTPException, Query from pydantic import BaseModel logger = logging.getLogger("rtsport.coach_api") router = APIRouter(tags=["rtsport-coach"]) # ────────────────────────────────────────────────────────────────────────────── # Mock data — Preble High School Football athletes # ────────────────────────────────────────────────────────────────────────────── ATHLETES = { "ath_001": { "id": "ath_001", "name": "Jake Larson", "sport": "Football", "school_id": "schl_001", "status": "out", "position": "QB", "grade": 12, "cases": [ { "id": "case_001", "body_part": "Ankle - Right", "opened": "2026-04-15T00:00:00Z", "status": "active", "phase": "active_rehab", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-04-15"}, {"name": "Active Rehab", "completed": False, "date": None}, {"name": "Return to Play", "completed": False, "date": None}, {"name": "Full Clearance", "completed": False, "date": None}, ], } ], }, "ath_002": { "id": "ath_002", "name": "Marcus Johnson", "sport": "Football", "school_id": "schl_001", "status": "out", "position": "LB", "grade": 11, "cases": [ { "id": "case_002", "body_part": "Knee - Left (ACL)", "opened": "2026-04-01T00:00:00Z", "status": "active", "phase": "initial_assessment", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-04-01"}, {"name": "Active Rehab", "completed": False, "date": None}, {"name": "Return to Play", "completed": False, "date": None}, {"name": "Full Clearance", "completed": False, "date": None}, ], } ], }, "ath_003": { "id": "ath_003", "name": "Tyler Wilson", "sport": "Football", "school_id": "schl_001", "status": "modified", "position": "WR", "grade": 10, "cases": [ { "id": "case_003", "body_part": "Knee", "opened": "2026-05-05T12:00:00Z", "status": "active", "phase": "return_to_play", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-05-05"}, {"name": "Active Rehab", "completed": True, "date": "2026-05-10"}, {"name": "Return to Play", "completed": False, "date": None}, {"name": "Full Clearance", "completed": False, "date": None}, ], } ], }, "ath_004": { "id": "ath_004", "name": "Derek Lee", "sport": "Football", "school_id": "schl_001", "status": "modified", "position": "RB", "grade": 11, "cases": [ { "id": "case_004", "body_part": "Shoulder - Left", "opened": "2026-05-01T00:00:00Z", "status": "active", "phase": "return_to_play", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-05-01"}, {"name": "Active Rehab", "completed": True, "date": "2026-05-10"}, {"name": "Return to Play", "completed": False, "date": None}, {"name": "Full Clearance", "completed": False, "date": None}, ], } ], }, "ath_005": { "id": "ath_005", "name": "Cameron Davis", "sport": "Football", "school_id": "schl_001", "status": "out", "position": "OL", "grade": 12, "cases": [ { "id": "case_005", "body_part": "Hamstring - Right", "opened": "2026-04-28T00:00:00Z", "status": "active", "phase": "initial_assessment", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-04-28"}, {"name": "Active Rehab", "completed": False, "date": None}, {"name": "Return to Play", "completed": False, "date": None}, {"name": "Full Clearance", "completed": False, "date": None}, ], } ], }, "ath_006": { "id": "ath_006", "name": "Ethan Brooks", "sport": "Football", "school_id": "schl_001", "status": "cleared", "position": "DB", "grade": 11, "cases": [ { "id": "case_006", "body_part": "Concussion", "opened": "2026-03-20T00:00:00Z", "status": "closed", "phase": "full_clearance", "milestones": [ {"name": "Initial Assessment", "completed": True, "date": "2026-03-20"}, {"name": "Active Rehab", "completed": True, "date": "2026-03-28"}, {"name": "Return to Play", "completed": True, "date": "2026-04-05"}, {"name": "Full Clearance", "completed": True, "date": "2026-04-12"}, ], } ], }, } # Map phase → estimated days remaining (rough duration estimates) PHASE_DAYS = { "initial_assessment": 21, "active_rehab": 14, "return_to_play": 3, "full_clearance": 0, } TOTAL_ROSTER_FOOTBALL = 50 # Recent activity log (most recent first) RECENT_ACTIVITY = [ { "type": "status_change", "athlete_name": "Jake Larson", "from": "modified", "to": "out", "timestamp": "2026-05-06T00:00:00Z", }, { "type": "new_case", "athlete_name": "Tyler Wilson", "body_part": "Knee", "timestamp": "2026-05-05T12:00:00Z", }, { "type": "status_change", "athlete_name": "Derek Lee", "from": "cleared", "to": "modified", "timestamp": "2026-05-03T08:00:00Z", }, { "type": "new_case", "athlete_name": "Cameron Davis", "body_part": "Hamstring - Right", "timestamp": "2026-04-28T14:00:00Z", }, { "type": "clearance", "athlete_name": "Ethan Brooks", "body_part": "Concussion", "timestamp": "2026-04-12T10:00:00Z", }, ] # ────────────────────────────────────────────────────────────────────────────── # In-memory message store # ────────────────────────────────────────────────────────────────────────────── MESSAGES: dict[str, list[dict]] = { "ath_001": [ { "id": "msg_001", "sender": "coach", "text": "Any update on the ankle? Heard he's been doing PT.", "timestamp": "2026-05-05T20:00:00Z", "status": "read", }, { "id": "msg_002", "sender": "at", "text": "He's progressing well. Should be back next week. ROM is nearly full.", "timestamp": "2026-05-05T20:15:00Z", "status": "read", }, { "id": "msg_003", "sender": "at", "text": "Update: Jake started light jogging today. No pain reported.", "timestamp": "2026-05-05T22:00:00Z", "status": "delivered", }, ], "ath_002": [ { "id": "msg_004", "sender": "at", "text": "Marcus has his MRI scheduled for Thursday. I'll share results as soon as they come in.", "timestamp": "2026-05-04T14:30:00Z", "status": "read", }, { "id": "msg_005", "sender": "coach", "text": "Thanks, keep me posted. Need to know if we should plan for him being out for the season opener.", "timestamp": "2026-05-04T15:00:00Z", "status": "read", }, ], "ath_003": [ { "id": "msg_006", "sender": "at", "text": "Tyler's knee is responding well to treatment. He should be cleared for modified practice this week.", "timestamp": "2026-05-05T16:00:00Z", "status": "unread", }, { "id": "msg_007", "sender": "at", "text": "He'll need a brace for contact drills. I'll fit him tomorrow.", "timestamp": "2026-05-05T16:05:00Z", "status": "unread", }, ], "ath_004": [ { "id": "msg_008", "sender": "at", "text": "Derek is almost ready. Shoulder strength test came back at 90%. One more week of modified work.", "timestamp": "2026-05-05T10:00:00Z", "status": "read", }, { "id": "msg_009", "sender": "coach", "text": "Good to hear. Can he do non-contact drills this Friday?", "timestamp": "2026-05-05T10:30:00Z", "status": "read", }, { "id": "msg_010", "sender": "at", "text": "Yes, cleared for non-contact. I'll send the modified practice protocol.", "timestamp": "2026-05-05T10:45:00Z", "status": "read", }, ], "ath_005": [ { "id": "msg_011", "sender": "at", "text": "Cam's hamstring is still tight. Keeping him in assessment phase. No timeline yet.", "timestamp": "2026-05-05T09:00:00Z", "status": "unread", }, ], } # Track per-athlete unread counts for conversation list UNREAD_COUNTS: dict[str, int] = { "ath_001": 0, # coach already read latest "ath_002": 0, "ath_003": 2, "ath_004": 0, "ath_005": 1, } # AT names by sport AT_NAMES: dict[str, str] = { "Football": "Sarah (AT)", "Basketball": "Mike (AT)", "Soccer": "Emily (AT)", } # ────────────────────────────────────────────────────────────────────────────── # Pydantic models # ────────────────────────────────────────────────────────────────────────────── class SendMessageRequest(BaseModel): text: str # ────────────────────────────────────────────────────────────────────────────── # JWT auth helper # ────────────────────────────────────────────────────────────────────────────── def _verify_coach_token(authorization: str | None) -> str: """Validate Bearer JWT and return the email. Currently accepts any token that exists in the mock auth table. This will be hardened when unified auth is wired in. """ if not authorization: raise HTTPException(status_code=401, detail="Missing Authorization header") if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Invalid Authorization scheme") token = authorization[7:] # Mock token validation — accept any non-empty token for demo if not token or len(token) < 8: raise HTTPException(status_code=401, detail="Invalid token") # For mock purposes, derive the email from token (matches frontend login flow) # In production this would decode the JWT return "coach@preble.k12.wi.us" # ────────────────────────────────────────────────────────────────────────────── # Helper functions # ────────────────────────────────────────────────────────────────────────────── def _compute_status_summary(sport: str, school_id: str) -> dict: """Compute out/modified/cleared counts from athlete data.""" sport_athletes = [ a for a in ATHLETES.values() if a["sport"] == sport and a["school_id"] == school_id ] out_count = sum(1 for a in sport_athletes if a["status"] == "out") mod_count = sum(1 for a in sport_athletes if a["status"] == "modified") cleared_count = sum(1 for a in sport_athletes if a["status"] == "cleared") # Estimate total roster = cleared + reported cases + buffer for healthy total = max(out_count + mod_count + cleared_count, TOTAL_ROSTER_FOOTBALL) return { "out": out_count, "modified": mod_count, "cleared": total - out_count - mod_count, } def _compute_injury_breakdown(sport: str, school_id: str) -> list[dict]: """Aggregate body parts for active cases.""" breakdown: dict[str, int] = {} for a in ATHLETES.values(): if a["sport"] != sport or a["school_id"] != school_id: continue for case in a["cases"]: if case["status"] != "active": continue bp = case["body_part"].split(" - ")[0] # Normalize "Knee - Left" → "Knee" breakdown[bp] = breakdown.get(bp, 0) + 1 return sorted( [{"body_part": bp, "count": c} for bp, c in breakdown.items()], key=lambda x: x["count"], reverse=True, ) def _compute_recovery_timeline(sport: str, school_id: str) -> list[dict]: """Build recovery timeline for athletes with active cases.""" timeline = [] for a in ATHLETES.values(): if a["sport"] != sport or a["school_id"] != school_id: continue for case in a["cases"]: if case["status"] != "active": continue phase = case.get("phase", "initial_assessment") days_remaining = PHASE_DAYS.get(phase, 14) timeline.append({ "athlete_name": a["name"], "status": a["status"], "phase": phase, "days_remaining": days_remaining, }) return sorted(timeline, key=lambda x: x["days_remaining"]) def _compute_conversations(sport: str, school_id: str) -> list[dict]: """Build conversation list from message store.""" sport_athlete_ids = { aid: a["name"] for aid, a in ATHLETES.items() if a["sport"] == sport and a["school_id"] == school_id } convos = [] for aid, name in sport_athlete_ids.items(): msgs = MESSAGES.get(aid, []) if not msgs: continue last = msgs[-1] at_name = AT_NAMES.get(sport, "AT Staff") convos.append({ "athlete_name": name, "athlete_id": aid, "last_message": last["text"], "last_message_time": last["timestamp"], "unread_count": UNREAD_COUNTS.get(aid, 0), "at_name": at_name, }) # Sort by most recent message first convos.sort(key=lambda c: c["last_message_time"], reverse=True) return convos # ────────────────────────────────────────────────────────────────────────────── # Endpoints # ────────────────────────────────────────────────────────────────────────────── @router.get("/dashboard/coach/status") async def get_coach_status( school_id: str = Query(..., description="School ID (e.g. schl_001)"), sport: str = Query("Football", description="Sport name"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/dashboard/coach/status?school_id=X&sport=Football Returns status summary, injury breakdown, recovery timeline, and recent activity. """ coach_email = _verify_coach_token(authorization) logger.info(f"Coach {coach_email} fetching status for {sport} @ {school_id}") return { "sport": sport, "total_athletes": TOTAL_ROSTER_FOOTBALL, "status_summary": _compute_status_summary(sport, school_id), "injury_breakdown": _compute_injury_breakdown(sport, school_id), "recovery_timeline": _compute_recovery_timeline(sport, school_id), "recent_activity": RECENT_ACTIVITY[:10], } @router.get("/dashboard/coach/athlete/{athlete_id}/timeline") async def get_athlete_timeline( athlete_id: str, authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/dashboard/coach/athlete/{athlete_id}/timeline Returns case milestones for a specific athlete. """ _verify_coach_token(authorization) athlete = ATHLETES.get(athlete_id) if not athlete: raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found") return { "athlete_name": athlete["name"], "cases": athlete["cases"], } @router.get("/messages/coach") async def get_coach_messages( school_id: str = Query(..., description="School ID"), sport: str = Query("Football", description="Sport name"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/messages/coach?school_id=X&sport=Football Returns conversation threads with unread counts. """ _verify_coach_token(authorization) convos = _compute_conversations(sport, school_id) return {"conversations": convos} @router.get("/messages/coach/{athlete_id}") async def get_message_thread( athlete_id: str, before: str | None = Query(None, description="ISO-8601 timestamp for pagination"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/messages/coach/{athlete_id}?before=X Returns message history for an athlete thread. Supports ?before= for cursor-based pagination. """ _verify_coach_token(authorization) athlete = ATHLETES.get(athlete_id) if not athlete: raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found") msgs = MESSAGES.get(athlete_id, []) if before: # Filter messages before the given timestamp msgs = [m for m in msgs if m["timestamp"] < before] return { "athlete_name": athlete["name"], "messages": msgs, } @router.post("/messages/coach/{athlete_id}", status_code=201) async def send_message( athlete_id: str, body: SendMessageRequest, authorization: str | None = Header(None, alias="Authorization"), ): """POST /api/v1/messages/coach/{athlete_id} Sends a message from the coach to the athlete's AT. Body: {"text": "..."} Returns 201 Created with the message object. """ _verify_coach_token(authorization) athlete = ATHLETES.get(athlete_id) if not athlete: raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found") if not body.text or not body.text.strip(): raise HTTPException(status_code=400, detail="Message text cannot be empty") now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") new_msg = { "id": f"msg_{uuid.uuid4().hex[:8]}", "sender": "coach", "text": body.text.strip(), "timestamp": now, "status": "sent", } if athlete_id not in MESSAGES: MESSAGES[athlete_id] = [] MESSAGES[athlete_id].append(new_msg) return new_msg