fix: soft-reset UX + stale-cookie handling + leaderboard 'is_you' by id

Three coupled fixes from the first manual test pass:

1. Stale signed cookie no longer 500s. `rooms.me()` now raises KeyError
   when the participant row is gone (the previous code deref'd None and
   threw TypeError, caught by quiz.js's catch-all as 'link expired').
   `/api/session/{sid}/me` translates KeyError into 401 + delete_cookie,
   so the client falls back to the join form cleanly.
   Returning a JSONResponse directly because `raise HTTPException`
   discards Response.delete_cookie mutations (FastAPI middleware
   composes a fresh response on exception).

2. Reset is now a soft restart from the student's perspective. Before
   closing each student WS in `RoomManager.reset`, the server now sends
   a `{"type": "session_reset"}` message. The student SPA tears down
   local state and re-runs boot(); /me returns 401 (now that the
   participant is gone) and the join form renders without the user
   having to manually reload. The WS close handler suppresses its
   "Disconnected" screen during a reset to avoid a flash.

3. "You" highlight on the student leaderboard is now matched by id, not
   by name. `RoomManager.leaderboard()` accepts an optional
   `you_student_id` and stamps `is_you: true` on the matching entry only.
   No other students' ids leak over the wire (we still don't include
   `student_id` in the public top5 payload). quiz.js's renderBoard
   prefers `r.is_you` when any row is marked, falling back to name match
   for backward compatibility.

41/41 tests pass. Two new tests cover (a) the 401 + cookie-clear path
after reset and (b) `is_you` marking only the requesting student.
This commit is contained in:
ameer
2026-05-02 22:40:52 +08:00
parent b40f05220c
commit cfbda260fa
4 changed files with 121 additions and 13 deletions

View File

@@ -130,7 +130,14 @@ class RoomManager:
(sid,),
)
await db.commit()
# Tell each student client the session was reset BEFORE closing the
# socket, so the JS can clear local state and re-bootstrap into the
# join form rather than showing a generic "disconnected" screen.
for ws in list(self.student_clients.get(sid, {}).keys()):
try:
await ws.send_json({"type": "session_reset"})
except Exception:
pass
try:
await ws.close(code=4002)
except Exception:
@@ -504,13 +511,14 @@ class RoomManager:
async def question_closed_message(self, sid: str, question_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
pool = await self.get_pool_for_session(sid)
question = get_question(pool, question_idx)
you_id = identity["student_id"] if identity else None
msg = {
"type": "question_closed",
"question_idx": question_idx,
"correct": question["correct"],
"explanation": question.get("explanation", ""),
"histogram": await self.histogram(sid, question_idx),
"top5": await self.leaderboard(sid, limit=5),
"top5": await self.leaderboard(sid, limit=5, you_student_id=you_id),
}
if identity:
student = identity["student_id"]
@@ -528,14 +536,16 @@ class RoomManager:
return msg
async def between_message(self, sid: str, next_idx: int, identity: dict[str, Any] | None = None) -> dict[str, Any]:
msg = {"type": "between_questions", "next_idx": next_idx, "top5": await self.leaderboard(sid, limit=5)}
you_id = identity["student_id"] if identity else None
msg = {"type": "between_questions", "next_idx": next_idx, "top5": await self.leaderboard(sid, limit=5, you_student_id=you_id)}
if identity:
msg["your_rank"] = await self.rank_for(sid, identity["student_id"])
msg["your_total"] = await self.total_for(sid, identity["student_id"])
return msg
async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]:
msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5)}
you_id = identity["student_id"] if identity else None
msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5, you_student_id=you_id)}
if identity:
student = identity["student_id"]
msg.update(await self.student_summary(sid, student))
@@ -564,7 +574,17 @@ class RoomManager:
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
return result
async def leaderboard(self, sid: str, limit: int | None = None, include_ids: bool = False) -> list[dict[str, Any]]:
async def leaderboard(
self,
sid: str,
limit: int | None = None,
include_ids: bool = False,
you_student_id: str | None = None,
) -> list[dict[str, Any]]:
"""Top scores. If `you_student_id` is given and that student appears
in the returned slice, that one entry is marked with `is_you: True`
so the client can highlight by id without exposing other students'
ids over the wire."""
query_limit = "" if limit is None else f"LIMIT {int(limit)}"
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
@@ -585,6 +605,8 @@ class RoomManager:
item = {"rank": rank, "name": row["name"], "score": int(row["score"])}
if include_ids:
item["student_id"] = row["student_id"]
if you_student_id is not None and row["student_id"] == you_student_id:
item["is_you"] = True
board.append(item)
return board
@@ -656,6 +678,11 @@ class RoomManager:
async with connect(self.settings.db_path) as db:
part_cursor = await db.execute("SELECT * FROM participants WHERE sid = ? AND student_id = ?", (sid, student_id))
participant = await part_cursor.fetchone()
if participant is None:
# Participant row is gone (typically because the instructor
# ran a reset). Caller is expected to translate this into a
# 401 + cookie-clear so the client lands on the join form.
raise KeyError(f"No participant {student_id!r} in session {sid!r}")
sub_cursor = await db.execute(
"SELECT question_idx, answer, elapsed_ms, score, status FROM submissions WHERE sid = ? AND student_id = ? ORDER BY question_idx",
(sid, student_id),
@@ -677,7 +704,7 @@ class RoomManager:
"response_time_avg_ms": None,
"response_time_distribution": {},
"average_score": 0,
"top5": await self.leaderboard(sid, limit=5),
"top5": await self.leaderboard(sid, limit=5, you_student_id=student_id),
"your_rank": None,
}
async with connect(self.settings.db_path) as db:
@@ -705,7 +732,7 @@ class RoomManager:
"response_time_avg_ms": round(sum(times) / len(times)) if times else None,
"response_time_distribution": distribution,
"average_score": round(sum(scores) / len(scores), 2) if scores else 0,
"top5": await self.leaderboard(sid, limit=5),
"top5": await self.leaderboard(sid, limit=5, you_student_id=student_id),
}
if student_id:
payload["your_rank"] = await self.rank_for(sid, student_id)