geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-12-file-attachments...

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_students join) — 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

  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