6.6 KiB
6.6 KiB
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_studentsjoin) — parent
Payload shapes (server-side):
// 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
- 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.
- 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. - Why Vite WS proxy — in dev mode, requests to
localhost:3000/wsgo 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 showswsHub.broadcastToUsercalled for student + parents (no-op for the linked parent in this test fixture).POST /api/attachmentswithparent_kind=marked_script&parent_id=1(teacher) → 201; server emitsmarked_script:uploadedfor 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
testerrein) - Real-time push for announcements (already covered by
messages.controller.js— unchanged) - Push for
submission:gradedevents 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.tsxfile (older landing page) was originally edited by mistake — those edits were reverted viagit restore. It's still in the tree but no route references it; cleanup is left for a future PR (not blocking).
VERDICT: PASS