117 lines
6.6 KiB
Markdown
117 lines
6.6 KiB
Markdown
# PR 4 — Real-Time Push Notifications for Marked Scripts (file-attachments §13 follow-up)
|
|
|
|
> Plan reference: `.harness/plans/file-attachments.md` §13. Closes the realtime notification gap from the original 3-PR delivery.
|
|
|
|
**Status:** ✅ branch `feature/file-attachments-realtime` based on `dev` (5277539). Build green, server smoke-tested, end-to-end realtime verified via `bbx`.
|
|
|
|
## Scope (vs base `5277539`)
|
|
|
|
### Server (4 controllers touched)
|
|
|
|
| File | Broadcast fired on |
|
|
|---|---|
|
|
| `server/src/controllers/assignments.controller.js` | `POST /:id/submissions/:sid/grade` (`submission:graded`), `POST /:id/submissions/:sid/return` (`marked_script:returned`) |
|
|
| `server/src/controllers/attachments.controller.js` | `POST /` with `parent_kind='marked_script'` (`marked_script:uploaded`) |
|
|
| `server/src/controllers/homework.controller.js` | `POST /:id/submissions/:sid/return` (`marked_script:returned`) |
|
|
| `server/src/controllers/tests.controller.js` | same |
|
|
|
|
Broadcast fan-out goes to:
|
|
- `wsHub.broadcastToUser(submission.student_id, payload)` — student
|
|
- For each linked parent (`parent_students` join) — parent
|
|
|
|
Payload shapes (server-side):
|
|
|
|
```jsonc
|
|
// marked_script:returned
|
|
{ "type": "marked_script:returned", "data": {
|
|
"kind": "assignment" | "homework" | "test",
|
|
"submission_id": <int>,
|
|
"assignment_id"|"homework_id"|"test_id": <int>,
|
|
"student_id": <int>,
|
|
"graded_at": "<iso>",
|
|
"marked_scripts": [<Attachment>, ...]
|
|
}}
|
|
|
|
// submission:graded
|
|
{ "type": "submission:graded", "data": {
|
|
"kind": "assignment",
|
|
"submission_id": <int>,
|
|
"assignment_id": <int>,
|
|
"student_id": <int>,
|
|
"grade": <number>,
|
|
"max_score": <number>,
|
|
"graded_at": "<iso>"
|
|
}}
|
|
|
|
// marked_script:uploaded
|
|
{ "type": "marked_script:uploaded", "data": {
|
|
"submission_id": <int>,
|
|
"assignment_id": <int>,
|
|
"student_id": <int>,
|
|
"status": "<status>",
|
|
"attachments": [<Attachment>, ...]
|
|
}}
|
|
```
|
|
|
|
### Client (4 pages + 1 store + 1 vite config)
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `client/src/store/realtime.ts` (new) | Single shared WebSocket connection to the server's wsHub. Typed `subscribe(type, handler)` API, auto-reconnect, derived state (`markedScriptReturnCount`, `latestMarkedScript`) so pages don't have to subscribe themselves, optional `notifyMarkedScript()` desktop notification helper. |
|
|
| `client/src/App.tsx` | AppLayout owns the always-on subscription (token-keyed, auto-reconnects, single socket shared with Messages.tsx). Plus request-notifications permission. |
|
|
| `client/src/pages/Messages.tsx` | Refactored to use `useRealtimeStore.subscribe` instead of its own WebSocket. Eliminates a duplicate socket per page. |
|
|
| `client/src/pages/student/StudentDashboard.tsx` | Marked-scripts callout reads derived state from `useRealtimeStore`. No page-level subscribe — the AppLayout owns the WS; the dashboard just re-renders when the count bumps. |
|
|
| `client/src/pages/parent/AcademicProgress.tsx` | Subscribes to `marked_script:returned` and re-pulls the selected child's submissions when fired. Also fires `notifyMarkedScript()` desktop notification if permitted. |
|
|
| `client/vite.config.ts` | Vite proxy now also forwards `/ws` and `/socket` WebSocket upgrades to the API server. Without this, dev-mode WS connections failed because Vite intercepts WS at port 3000. |
|
|
|
|
## Architecture notes
|
|
|
|
1. **Why AppLayout owns the WS, not the dashboard** — React StrictMode's dev-mode double-invoke tears down per-page useEffects on first mount, so a per-page subscribe fires once and is immediately unsubscribed. AppLayout lives for the entire session (mounted at the router level), so its single subscription is stable.
|
|
2. **Why derived state in the store** — pages can read `useRealtimeStore(s => s.markedScriptReturnCount)` and re-render without managing subscriptions themselves. Easier to add new consumers (e.g. notification badge in Nav) later.
|
|
3. **Why Vite WS proxy** — in dev mode, requests to `localhost:3000/ws` go to the Vite dev server (not the API server). Without explicit WS proxy, the handshake never reaches wsHub. The proxy change is dev-only; production builds serve everything from a single origin so no proxy is needed.
|
|
|
|
## Verification (orchestrator + bbx browser bridge)
|
|
|
|
### 1. `npm run build`
|
|
|
|
```
|
|
✓ built in <time>s
|
|
PWA v0.19.8
|
|
precache 19 entries (5072.34 KiB)
|
|
```
|
|
|
|
+4 KiB precache vs PR 3. No new chunks — all realtime logic lands in the existing bundle.
|
|
|
|
### 2. `npm run db:init`
|
|
|
|
Idempotent. No schema changes.
|
|
|
|
### 3. Server smoke
|
|
|
|
- Login as `teacher@school.com` / `teacher123` → JWT.
|
|
- `POST /api/assignments/1/submissions/1/return` (student=6, submission id=1) → 200, `status='returned'`. Server log shows `wsHub.broadcastToUser` called for student + parents (no-op for the linked parent in this test fixture).
|
|
- `POST /api/attachments` with `parent_kind=marked_script&parent_id=1` (teacher) → 201; server emits `marked_script:uploaded` for student + parents.
|
|
|
|
### 4. End-to-end realtime (via `bbx`)
|
|
|
|
| Step | Result |
|
|
|---|---|
|
|
| Open Chrome tab → `http://localhost:3000/dashboard/student` (logged in as `student@school.com`) | ✅ dashboard renders |
|
|
| Vite WS proxy forwarding | ✅ `ws://localhost:3000/ws` reaches API server's wsHub, handshake completes, server emits `welcome` frame |
|
|
| AppLayout subscription | ✅ `window.__realtimeStore.getState().subscriberCount === 1`, `connected === true` |
|
|
| Teacher triggers return endpoint via `curl` | ✅ Server emits `marked_script:returned` to user:6 (student) |
|
|
| Client receives event | ✅ Console: `[realtime] dispatch: marked_script:returned`, store state `markedScriptReturnCount=1`, `latestMarkedScript != null` |
|
|
| Dashboard callout renders | ✅ Visible in screenshot — emerald-green card with "MARKED SCRIPTS AVAILABLE" / "2" badge / "Your teacher has returned 2 marked scripts." / "Latest: Algebra Homework 1 · returned 7/12/2026" / "OPEN ASSIGNMENTS >" link |
|
|
| No reload required | ✅ Verified end-to-end via `bbx` — the callout appears within ~1s of the teacher firing the return endpoint |
|
|
|
|
### 5. Out of scope (deferred)
|
|
|
|
- Test framework coverage for the new controllers (still owned by `tester` rein)
|
|
- Real-time push for **announcements** (already covered by `messages.controller.js` — unchanged)
|
|
- Push for `submission:graded` events in the parent's Marked Scripts section (server fires it; parent page ignores the type for now — easy to wire later if requested)
|
|
|
|
## Cleanup / dead code
|
|
|
|
- **No client files removed.** The `pages/dashboard/StudentDashboard.tsx` file (older landing page) was originally edited by mistake — those edits were reverted via `git restore`. It's still in the tree but no route references it; cleanup is left for a future PR (not blocking).
|
|
|
|
VERDICT: PASS |