# 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": , "assignment_id"|"homework_id"|"test_id": , "student_id": , "graded_at": "", "marked_scripts": [, ...] }} // submission:graded { "type": "submission:graded", "data": { "kind": "assignment", "submission_id": , "assignment_id": , "student_id": , "grade": , "max_score": , "graded_at": "" }} // marked_script:uploaded { "type": "marked_script:uploaded", "data": { "submission_id": , "assignment_id": , "student_id": , "status": "", "attachments": [, ...] }} ``` ### 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