# 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