"""RTSport Parent Dashboard API — 6 endpoints for parent-facing views. Endpoints: 1. GET /api/v1/dashboard/parent — Full parent home data (athlete + case + activity) 2. GET /api/v1/dashboard/parent/children — Children linked to parent 3. GET /api/v1/dashboard/parent/timeline — Chronological timeline (FERPA-filtered) 4. GET /api/v1/messages/parent — Conversations with unread counts 5. GET /api/v1/messages/parent/{contact_id} — Message thread history 6. POST /api/v1/messages/parent/{contact_id} — Send a message Uses Bearer JWT auth (same pattern as coach API). Shares in-memory message store with coach API via importing from rtsport_coach_api. FERPA-aware: timeline NEVER includes clinical_note type events. """ import uuid import logging from datetime import datetime, timezone from fastapi import APIRouter, Header, HTTPException, Query from pydantic import BaseModel logger = logging.getLogger("rtsport.parent_api") router = APIRouter(tags=["rtsport-parent"]) # ── Share coach API's message store ───────────────────────────────────── # Import the shared MESSAGES dict and ATHLETES from coach API so messages # sent by parent ↔ coach → AT are visible bidirectionally. try: from rtsport_coach_api import MESSAGES as _COACH_MESSAGES, ATHLETES as _COACH_ATHLETES MESSAGES = _COACH_MESSAGES ATHLETES = _COACH_ATHLETES except ImportError: # Fallback: stand-alone message store (won't cross-pollinate) MESSAGES: dict[str, list[dict]] = {} ATHLETES: dict[str, dict] = {} logger.info("Parent API linked to coach API message store") # ── Mock Parent Data ───────────────────────────────────────────────────── # Two children linked to a parent account: # Jake Larson (ath_001) — active ankle case # Emma Larson (ath_005) — healthy / fully cleared # # We reuse ath_001 from the coach ATHLETES dict and add ath_005. # ath_005 already exists in coach data as "Ethan Brooks" with id ath_006, # but we keep things simple by using the same IDs where possible. PARENT_CHILDREN = [ { "id": "ath_001", "first_name": "Jake", "last_name": "Larson", "sport": "Football", "team": "Varsity", "grade": 11, "jersey_number": 12, "current_status": "out", "has_active_case": True, }, { "id": "ath_005", "first_name": "Emma", "last_name": "Larson", "sport": "Soccer", "team": "JV", "grade": 9, "jersey_number": 7, "current_status": "cleared", "has_active_case": False, }, ] # ── Mock Case Data ─────────────────────────────────────────────────────── # Jake's active ankle case JAKE_CASE = { "id": "case_001", "status": "active", "body_part": "Ankle", "side": "Right", "injury_type": "Sprain", "severity": "moderate", "opened_date": "2026-04-15", "phase": "active_rehab", "phase_number": 2, "total_phases": 4, "estimated_return": "2026-05-20", "next_appointment": "2026-05-08T15:30:00Z", "restrictions": [ "No running", "Non-contact only", "Must wear ankle brace", ], "milestones": [ {"label": "Initial Assessment", "status": "done", "completed_date": "2026-04-15"}, {"label": "Active Rehab", "status": "current", "target_date": "2026-05-10"}, {"label": "Return to Play", "status": "pending", "target_date": "2026-05-20"}, {"label": "Full Clearance", "status": "pending", "target_date": "2026-05-25"}, ], "care_team": [ {"name": "Lisa Johnson, LAT", "role": "Athletic Trainer", "initials": "LJ", "email": "ljohnson@preble.k12.wi.us"}, {"name": "Coach Andrews", "role": "Head Coach — Football", "initials": "CA", "email": "andrews@preble.k12.wi.us"}, ], } # ── Activity Feed (at least 5 events, varied types) ───────────────────── ACTIVITY_FEED = [ { "type": "milestone", "title": "Advanced to Active Rehab", "date": "2026-05-01", "note": "Range of motion restored. Starting strengthening exercises.", }, { "type": "message", "title": "Message from Lisa Johnson, LAT", "date": "2026-04-28", "note": "Jake is progressing well. We'll re-evaluate next week.", }, { "type": "restriction", "title": "Restrictions Updated", "date": "2026-04-22", "note": "Non-contact practice only. Must wear ankle brace.", }, { "type": "assessment", "title": "Initial Assessment", "date": "2026-04-15", "note": "Right ankle sprain — Grade 2. Moderate swelling.", }, { "type": "clearance", "title": "Case Opened", "date": "2026-04-15", "note": "Injury reported during football practice. AT notified immediately.", }, ] # ── Timeline Events (FERPA-filtered: no clinical_notes) ───────────────── TIMELINE_EVENTS = [ { "type": "milestone", "title": "Advanced to Active Rehab", "date": "2026-05-01", "description": "Range of motion restored. Starting strengthening exercises — 3x/week.", "icon": "🏃", }, { "type": "message", "title": "AT Update", "date": "2026-04-28", "description": 'Lisa Johnson shared: "Jake is progressing well. We\'ll re-evaluate next week."', "icon": "💬", }, { "type": "restriction", "title": "Restrictions Updated", "date": "2026-04-22", "description": "Non-contact practice only. Must wear ankle brace during all activities.", "icon": "⚠️", }, { "type": "assessment", "title": "Initial Assessment", "date": "2026-04-15", "description": "Right ankle sprain — Grade 2. Moderate swelling. Immobilized, crutches for 3 days.", "icon": "🏥", }, { "type": "clearance", "title": "Case Opened", "date": "2026-04-15", "description": "Injury reported during football practice. AT notified immediately.", "icon": "📋", }, # This event would be filtered out by FERPA if type were clinical_note # Explicitly NOT including clinical_notes here — FERPA compliance. ] # Emma's healthy timeline (no case) HEALTHY_TIMELINE = [] # empty — no active case = no events # ── Pydantic Models ────────────────────────────────────────────────────── class SendMessageRequest(BaseModel): text: str # ── Parent Conversation Data ───────────────────────────────────────────── # Map athlete IDs to their parent-facing contact list. # Each contact is a care team member the parent can message. PARENT_CONTACTS = { "ath_001": [ { "contact_id": "care_lj_001", "contact_name": "Lisa Johnson, LAT", "contact_role": "Athletic Trainer", "contact_initials": "LJ", }, { "contact_id": "care_ca_001", "contact_name": "Coach Andrews", "contact_role": "Head Coach — Football", "contact_initials": "CA", }, ], "ath_005": [ { "contact_id": "care_cs_005", "contact_name": "Coach Stevens", "contact_role": "Head Coach — Soccer", "contact_initials": "CS", }, ], } # Parent-side messages — keyed by contact_id so each thread is per-contact, not per-athlete. # These link to the coach/AT message store where relevant. PARENT_MESSAGES: dict[str, list[dict]] = { "care_lj_001": [ { "id": "pmsg_001", "sender": "at", "text": "Hi, Jake's initial assessment is complete. Grade 2 right ankle sprain.", "timestamp": "2026-04-15T14:00:00Z", "status": "read", }, { "id": "pmsg_002", "sender": "parent", "text": "Thank you for letting me know. What's the recovery timeline?", "timestamp": "2026-04-15T14:30:00Z", "status": "read", }, { "id": "pmsg_003", "sender": "at", "text": "Jake is progressing well. We'll re-evaluate next week.", "timestamp": "2026-04-28T15:30:00Z", "status": "delivered", }, ], "care_ca_001": [ { "id": "pmsg_010", "sender": "parent", "text": "Hi Coach Andrews, just checking in on Jake's status for Friday's game.", "timestamp": "2026-04-20T09:00:00Z", "status": "read", }, { "id": "pmsg_011", "sender": "at", "text": "Jake will not be playing Friday. Still in active rehab. We'll keep you updated.", "timestamp": "2026-04-20T09:15:00Z", "status": "read", }, ], "care_cs_005": [], # Emma — no messages yet } PARENT_UNREAD_COUNTS: dict[str, int] = { "care_lj_001": 1, "care_ca_001": 0, "care_cs_005": 0, } # Build per-athlete conversation summary from PARENT_CONTACTS and messages def _compute_parent_conversations(athlete_id: str) -> list[dict]: """Get conversation summary for a parent, grouped by contact.""" contacts = PARENT_CONTACTS.get(athlete_id, []) conversations = [] for contact in contacts: cid = contact["contact_id"] msgs = PARENT_MESSAGES.get(cid, []) if msgs: last = msgs[-1] last_msg = last["text"] last_ts = last["timestamp"] else: last_msg = None last_ts = None conversations.append({ "contact_name": contact["contact_name"], "contact_role": contact["contact_role"], "contact_initials": contact["contact_initials"], "contact_id": cid, "last_message": last_msg, "last_message_time": last_ts, "unread_count": PARENT_UNREAD_COUNTS.get(cid, 0), }) return conversations # ── Auth ───────────────────────────────────────────────────────────────── def _verify_parent_token(authorization: str | None) -> str: """Validate Bearer JWT for parent. Same mock pattern as coach API — accepts any non-empty token for demo. """ 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:] if not token or len(token) < 8: raise HTTPException(status_code=401, detail="Invalid token") return "parent@preble.k12.wi.us" # ── Athlete Lookup ─────────────────────────────────────────────────────── def _get_child(athlete_id: str) -> dict: """Look up a child by ID; raise 404 if not found.""" for child in PARENT_CHILDREN: if child["id"] == athlete_id: return child raise HTTPException(status_code=404, detail=f"Athlete {athlete_id} not found") # ========================================================================= # ENDPOINT 1: GET /dashboard/parent # Full parent home data: athlete info, case, milestones, care team, feed # ========================================================================= @router.get("/dashboard/parent") async def get_parent_dashboard( school_id: str = Query(..., description="School ID (e.g. schl_001)"), athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/dashboard/parent?school_id=X&athlete_id=Y Returns athlete info, active case (if any), milestones, care team, restrictions, next appointment, and recent activity feed. If athlete has no active case, returns case=None and an empty activity_feed. """ parent_email = _verify_parent_token(authorization) child = _get_child(athlete_id) logger.info(f"Parent {parent_email} fetching dashboard for {child['first_name']} {child['last_name']}") # Build athlete info from child data athlete_info = { "id": child["id"], "first_name": child["first_name"], "last_name": child["last_name"], "sport": child["sport"], "team": child["team"], "grade": child["grade"], "jersey_number": child.get("jersey_number"), "current_status": child["current_status"], } # Return case data only if athlete has an active case (Jake = ath_001) case_data = JAKE_CASE if athlete_id == "ath_001" else None # Activity feed only for active cases feed = ACTIVITY_FEED if athlete_id == "ath_001" else [] return { "athlete": athlete_info, "case": case_data, "activity_feed": feed, } # ========================================================================= # ENDPOINT 2: GET /dashboard/parent/children # List children linked to the authenticated parent # ========================================================================= @router.get("/dashboard/parent/children") async def get_parent_children( school_id: str = Query(..., description="School ID (e.g. schl_001)"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/dashboard/parent/children?school_id=X Returns all children linked to the authenticated parent account. """ _verify_parent_token(authorization) return {"children": PARENT_CHILDREN} # ========================================================================= # ENDPOINT 3: GET /dashboard/parent/timeline # Chronological timeline events — FERPA-filtered (no clinical_notes) # ========================================================================= @router.get("/dashboard/parent/timeline") async def get_parent_timeline( school_id: str = Query(..., description="School ID (e.g. schl_001)"), athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/dashboard/parent/timeline?school_id=X&athlete_id=Y Returns chronological timeline events for the athlete's recovery journey. FERPA-compliant: events of type 'clinical_note' are NEVER included. Returns 200 with empty events array if the athlete has no active case. """ parent_email = _verify_parent_token(authorization) child = _get_child(athlete_id) logger.info(f"Parent {parent_email} fetching timeline for {child['first_name']} {child['last_name']}") if athlete_id == "ath_001": # Active case — return timeline events (all types except clinical_note) # Our mock data already excludes clinical_notes. events = TIMELINE_EVENTS case_info = { "id": "case_001", "phase": "active_rehab", "phase_number": 2, "total_phases": 4, } else: # Healthy athlete — no events events = [] case_info = None return { "athlete_name": f"{child['first_name']} {child['last_name']}", "case": case_info, "events": events, } # ========================================================================= # ENDPOINT 4: GET /messages/parent # Conversations with care team, unread counts per contact # ========================================================================= @router.get("/messages/parent") async def get_parent_messages( athlete_id: str = Query(..., description="Athlete ID (e.g. ath_001)"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/messages/parent?athlete_id=Y Returns conversation threads with each care team member for the given athlete. Each conversation includes the last message, timestamp, and unread count. """ _verify_parent_token(authorization) child = _get_child(athlete_id) conversations = _compute_parent_conversations(athlete_id) return { "athlete_name": f"{child['first_name']} {child['last_name']}", "conversations": conversations, } # ========================================================================= # ENDPOINT 5: GET /messages/parent/{contact_id} # Message thread history with a specific contact # ========================================================================= @router.get("/messages/parent/{contact_id}") async def get_parent_message_thread( contact_id: str, before: str | None = Query(None, description="ISO-8601 timestamp for cursor pagination"), authorization: str | None = Header(None, alias="Authorization"), ): """GET /api/v1/messages/parent/{contact_id}?before=X Returns message history for a parent ↔ care team thread. Supports optional ?before= for cursor-based pagination. """ _verify_parent_token(authorization) # Look up which contact this ID belongs to contact_name = None for athlete_contacts in PARENT_CONTACTS.values(): for c in athlete_contacts: if c["contact_id"] == contact_id: contact_name = c["contact_name"] break if contact_name: break if not contact_name: raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found") msgs = PARENT_MESSAGES.get(contact_id, []) if before: msgs = [m for m in msgs if m["timestamp"] < before] # Build the response: contact_name + messages from parent perspective # Map sender: "parent" means the parent wrote it, "at" means care team wrote it return { "contact_name": contact_name, "messages": msgs, } # ========================================================================= # ENDPOINT 6: POST /messages/parent/{contact_id} # Send a message to a care team member # ========================================================================= @router.post("/messages/parent/{contact_id}", status_code=201) async def send_parent_message( contact_id: str, body: SendMessageRequest, authorization: str | None = Header(None, alias="Authorization"), ): """POST /api/v1/messages/parent/{contact_id} Sends a message from the parent to a care team member. Body: {"text": "..."} Returns 201 Created with the new message object. Also mirrors the message into the coach API shared store (athlete-level), so coaches/ATs see parent messages in their own view (bidirectional). """ _verify_parent_token(authorization) # Verify contact exists contact_name = None linked_athlete_id = None for aid, athlete_contacts in PARENT_CONTACTS.items(): for c in athlete_contacts: if c["contact_id"] == contact_id: contact_name = c["contact_name"] linked_athlete_id = aid break if contact_name: break if not contact_name: raise HTTPException(status_code=404, detail=f"Contact {contact_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"pmsg_{uuid.uuid4().hex[:8]}", "sender": "parent", "text": body.text.strip(), "timestamp": now, "status": "sent", } # Store in parent message store if contact_id not in PARENT_MESSAGES: PARENT_MESSAGES[contact_id] = [] PARENT_MESSAGES[contact_id].append(new_msg) # Also mirror into the shared coach message store so AT/coach sees it. # Use the linked athlete's conversation thread in the coach store. if linked_athlete_id and linked_athlete_id in MESSAGES: mirrored_msg = dict(new_msg) mirrored_msg["sender"] = "parent" mirrored_msg["id"] = f"msg_{uuid.uuid4().hex[:8]}" MESSAGES[linked_athlete_id].append(mirrored_msg) return new_msg