""" Invitations & People Router. POST /users/invite — school admin sends magic-link invitation GET /users/invitations — list invitations for the school DELETE /users/invitations/{id} — cancel a pending invitation POST /users/invitations/{id}/resend — re-send magic link GET /users/staff — list current staff members GET /users/students — list current student members """ import os from datetime import datetime, timezone, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, EmailStr from modules.logger_tool import initialise_logger from modules.auth.supabase_bearer import SupabaseBearer from modules.database.supabase.utils.client import SupabaseServiceRoleClient logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) router = APIRouter() VALID_ROLES = {"teacher", "student", "school_admin", "department_head"} STAFF_ROLES = {"teacher", "school_admin", "department_head"} def _sb() -> SupabaseServiceRoleClient: return SupabaseServiceRoleClient() def _resolve_institute_id(user_id: str) -> Optional[str]: try: sb = _sb() p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() return str((p.data or {}).get("school_id") or "") or None except Exception: return None def _require_institute(user_id: str) -> str: iid = _resolve_institute_id(user_id) if not iid: raise HTTPException(status_code=400, detail="User is not linked to a school") return iid def _require_school_admin(user_id: str, institute_id: str) -> None: try: sb = _sb() m = ( sb.supabase.table("institute_memberships") .select("role") .eq("profile_id", user_id) .eq("institute_id", institute_id) .single() .execute() ) role = (m.data or {}).get("role", "") if role not in ("school_admin", "department_head"): raise HTTPException(status_code=403, detail="School admin access required") except HTTPException: raise except Exception: raise HTTPException(status_code=403, detail="Could not verify school admin status") # ─── Request models ─────────────────────────────────────────────────────────── class InviteRequest(BaseModel): email: str role: str # teacher | student | school_admin | department_head metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc. # ─── Invite ─────────────────────────────────────────────────────────────────── @router.post("/invite") async def invite_user( body: InviteRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """ Create an invitation row and send a Supabase magic-link invite email. Idempotent: if a pending invitation already exists for the same email/school, it is returned (use /resend to refresh the magic link). """ user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) if body.role not in VALID_ROLES: raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}") email = body.email.strip().lower() sb = _sb() # Check for existing pending invitation existing = ( sb.supabase.table("invitations") .select("id,status,email,role,created_at,expires_at") .eq("institute_id", institute_id) .eq("email", email) .eq("status", "pending") .execute() .data or [] ) if existing: return { "status": "already_pending", "invitation": existing[0], "message": "A pending invitation already exists. Use /resend to refresh the magic link.", } # Insert invitation row expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() inv_data = { "institute_id": institute_id, "email": email, "role": body.role, "invited_by": user_id, "expires_at": expires_at, "status": "pending", "metadata": body.metadata or {}, } inv_res = sb.supabase.table("invitations").insert(inv_data).execute() if not inv_res.data: raise HTTPException(status_code=500, detail="Failed to create invitation record") invitation = inv_res.data[0] # Send magic link via Supabase Auth admin try: sb.supabase.auth.admin.invite_user_by_email( email, options={ "data": { "invitation_id": invitation["id"], "institute_id": institute_id, "role": body.role, **(body.metadata or {}), } }, ) magic_link_sent = True except Exception as e: logger.warning(f"Magic link send failed for {email}: {e}") magic_link_sent = False logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}") return { "status": "ok", "invitation": invitation, "magic_link_sent": magic_link_sent, } # ─── List invitations ───────────────────────────────────────────────────────── @router.get("/invitations") async def list_invitations( role: Optional[str] = Query(None), status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"), credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) sb = _sb() q = ( sb.supabase.table("invitations") .select("id,email,role,status,invited_by,expires_at,created_at,metadata") .eq("institute_id", institute_id) .order("created_at", desc=True) ) if role: q = q.eq("role", role) if status: q = q.eq("status", status) rows = q.execute().data or [] # Mark expired rows (status still 'pending' but past expires_at) now = datetime.now(timezone.utc) for row in rows: if row["status"] == "pending": try: exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00")) if exp < now: row["status"] = "expired" except Exception: pass return {"status": "ok", "invitations": rows, "total": len(rows)} # ─── Cancel invitation ──────────────────────────────────────────────────────── @router.delete("/invitations/{invitation_id}") async def cancel_invitation( invitation_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) sb = _sb() res = ( sb.supabase.table("invitations") .update({"status": "cancelled"}) .eq("id", invitation_id) .eq("institute_id", institute_id) .eq("status", "pending") .execute() ) if not res.data: raise HTTPException(status_code=404, detail="Pending invitation not found") return {"status": "ok", "invitation": res.data[0]} # ─── Resend invitation ──────────────────────────────────────────────────────── @router.post("/invitations/{invitation_id}/resend") async def resend_invitation( invitation_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """Re-trigger the magic link for a pending (or expired) invitation.""" user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) sb = _sb() inv = ( sb.supabase.table("invitations") .select("*") .eq("id", invitation_id) .eq("institute_id", institute_id) .single() .execute() ).data if not inv: raise HTTPException(status_code=404, detail="Invitation not found") if inv["status"] == "accepted": raise HTTPException(status_code=400, detail="Invitation already accepted") if inv["status"] == "cancelled": raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one") # Refresh expiry + status new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() sb.supabase.table("invitations").update({ "expires_at": new_expires, "status": "pending", }).eq("id", invitation_id).execute() # Re-send magic link try: sb.supabase.auth.admin.invite_user_by_email( inv["email"], options={ "data": { "invitation_id": invitation_id, "institute_id": institute_id, "role": inv["role"], **inv.get("metadata", {}), } }, ) magic_link_sent = True except Exception as e: logger.warning(f"Resend magic link failed for {inv['email']}: {e}") magic_link_sent = False return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires} # ─── Staff list ─────────────────────────────────────────────────────────────── @router.get("/staff") async def list_staff( credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """List all staff members (teachers, admins, department heads) in the school.""" user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) sb = _sb() members = ( sb.supabase.table("institute_memberships") .select("profile_id,role,joined_at") .eq("institute_id", institute_id) .in_("role", list(STAFF_ROLES)) .execute() .data or [] ) if not members: return {"status": "ok", "staff": [], "total": 0} profile_ids = [m["profile_id"] for m in members] profiles = ( sb.supabase.table("profiles") .select("id,email,username,display_name") .in_("id", profile_ids) .execute() .data or [] ) profile_map = {p["id"]: p for p in profiles} staff = [] for m in members: p = profile_map.get(m["profile_id"], {}) staff.append({ "profile_id": m["profile_id"], "email": p.get("email"), "username": p.get("username"), "display_name": p.get("display_name"), "role": m["role"], "joined_at": m["joined_at"], }) return {"status": "ok", "staff": staff, "total": len(staff)} # ─── Student list ───────────────────────────────────────────────────────────── @router.get("/students") async def list_students( credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """List all student members in the school.""" user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) _require_school_admin(user_id, institute_id) sb = _sb() members = ( sb.supabase.table("institute_memberships") .select("profile_id,role,joined_at") .eq("institute_id", institute_id) .eq("role", "student") .execute() .data or [] ) if not members: return {"status": "ok", "students": [], "total": 0} profile_ids = [m["profile_id"] for m in members] profiles = ( sb.supabase.table("profiles") .select("id,email,username,display_name") .in_("id", profile_ids) .execute() .data or [] ) profile_map = {p["id"]: p for p in profiles} students = [] for m in members: p = profile_map.get(m["profile_id"], {}) students.append({ "profile_id": m["profile_id"], "email": p.get("email"), "username": p.get("username"), "display_name": p.get("display_name"), "role": m["role"], "joined_at": m["joined_at"], }) return {"status": "ok", "students": students, "total": len(students)}