# File Attachments, Marked Scripts, Course Resources

> Plan for letting teachers and students upload PDFs/docs to assignments, tests, homework, and exams; teachers to upload marked scripts that students, parents, and admins can read; and a working view/download surface for everything that's currently broken.

**Status legend:** ⬜ not started · 🔄 in flight · ✅ landed on `dev`
**Branch model:** work on a worktree branched from `dev`. Promote to `dev` after build-green. Never `main`.

---

## §0.0 — Confirmation stamp

**Confirmed 2026-07-11** — user re-pinged the plan. Verified: schema, controllers, and stores from §2-§7 are still **not in the codebase** (grep across `init.js` for `attachment|homework|test_paper|marked_script` returns nothing; no `attachments.controller.js`; no `attachments` in `tablesToSync`). Plan is still the right shape; user wants it built.

**Two updates vs the original plan:**

1. **PDF viewer is `<iframe>` + `<img>` in v1.** The plan originally said `react-pdf` + `pdfjs-dist` for multi-page zoom. User explicitly said "now just allow uploads and viewing of the data… leave chapters/sections for later" — basic view is enough. We can swap in `react-pdf` later if a teacher complains. **Skip §4.4 entirely.**
2. **Bundle the 5 phases into 3 PRs** (vs 5) to keep review overhead low. Each PR still ships as a worktree branched from `dev` and gets a code review.

| PR | Bundles | Branch | Owner |
|---|---|---|---|
| **PR 1 — Foundation** | §2 (all schema) + §3.1 (attachments controller) + §3.5 (fix existing download) + §4.1 (attachments store) + §4.2 (homework/tests stores) + §4.3 (AttachmentList + AttachmentUploader — no PdfPreview in v1) + wire `index.js` + `tablesToSync` | `feature/file-attachments-foundation` | `backend-expert` (schema+controller) → `frontend-expert` (store+components) |
| **PR 2 — Teacher** | §3.2 + §3.3 (homework/tests controllers) + §3.6 (syllabus fix) + §5.1 + §5.2 + §5.3 + §5.4 (Teacher Tools fix, teacher Assignments marked-script, teacher Resources, teacher Homework, teacher Tests) | `feature/file-attachments-teacher` | `frontend-expert` (UI) with `backend-expert` (controllers) |
| **PR 3 — Student + Parent** | §6.1 + §6.2 + §6.3 + §6.4 + §7.1 + §7.2 + §7.3 (student Assignments fix + Homework + Tests + Resources + dashboard callout, parent AcademicProgress marked-scripts + parent Resources, admin scope widening) | `feature/file-attachments-views` | `frontend-expert` |

**Recommended starting line: PR 1 (Foundation).** It's the lowest-risk slice, unblocks everything, and exercises the upload→store→download round trip end-to-end on a teacher syllabus (existing `subject` parent_kind). PR 2 and 3 only get interesting once that's green.

**Carry-over open questions for the user to confirm before PR 1 starts** (see chat thread 2026-07-11):

- [x] Keep the 3-PR cadence, or split further? → **Adopted (PR 1 + PR 2 landed; PR 3 queued).**
- [x] Confirm v1 PDF viewer is plain `<iframe>` (no `react-pdf`)? → **Adopted.** Both PRs use native `<iframe>` + `<img>`; `react-pdf` deferred indefinitely.
- [x] "Tests" and "Exams" — same concept for this purpose (a teacher-uploaded paper, student-uploads-their-script)? Plan currently has `parent_kind='test'` and `parent_kind='exam_paper'` as separate values; can collapse to one if the user treats them as the same. → **Resolved 2026-07-11: keep both as separate `parent_kind` values.** `test` = in-class test (PR 2 wired `/tests`); `exam_paper` = standalone exam outside exam_groups (still on the schema, schema only — no UI yet). Treat collapse later if the surfaces converge.

---

## §0 — Conventions (from `.harness/docs/conventions.md` and root `AGENTS.md`)

- 5-file frontend checklist per new page: `client/src/store/<module>.ts`, `client/src/pages/<area>/<Page>.tsx`, route in `client/src/App.tsx` `<ProtectedRoute allowedRoles=...>`, nav entry in `client/src/components/Nav.tsx`, changelog entry in `.harness/changelogs/YYYY-MM-DD.md`.
- Backend controller template: inline `auth` middleware, `db.pragma('foreign_keys = ON')` already on by default in `init.js`, every write sets `sync_status='pending'`.
- SQL contract per table: `uid TEXT UNIQUE`, `last_synced_at DATETIME`, `sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict'))`, `is_deleted INTEGER DEFAULT 0`. New tables appended to `tablesToSync` in `server/src/services/SyncEngine.js` in FK dependency order.
- Single axios instance at `client/src/store/api.ts`. Don't create a second one.
- Static uploads served at `/uploads` and `/api/uploads` from `server/src/index.js:112` and `:116`. Already wired.
- Demo accounts in `server/src/database/init.js`. **Do not** add new ones in a migration.

### Constraints locked in with user (covered above)

| Decision | Choice |
|---|---|
| Homework / tests schema | **Separate `homework` and `tests` tables** (not a `kind` column on `assignments`) |
| Per-course resource storage | **Polymorphic `attachments` table**; deprecate the half-working `syllabuses` table and `subjects_new.syllabus_url` column |
| File-size / batch | **20 MB per file, up to 10 files per batch** |
| PDF viewer | **`react-pdf` + `pdfjs-dist`** for inline multi-page preview with zoom + page nav |

### Out of scope (deferred — user said "leave for later")

- Syllabus chapter/section parsing (hardware-constrained).
- OCR / auto-tagging / virus scanning.
- Cloud object-store (S3/R2) — keep on local disk + Docker volume, matching today's setup.
- Bulk ZIP download.
- Versioning of marked scripts (keep last-write-wins for v1; record `uploaded_at`).

---

## §1 — Root-cause summary of "I can't even view most of these"

| # | Symptom | Cause |
|---|---|---|
| 1 | Student uploads an assignment file; can't download it back after reload | `submissions.file_path` stores the multer filename but no GET endpoint returns the binary. The student-side "Download" relies on base64 in Zustand state, which only lives for the current session. |
| 2 | Teacher's grading drawer shows no link to the student's submitted file | `GET /api/assignments/:id` returns `submissions[].file_path` but `teacher/Assignments.tsx:734-779` ignores it. |
| 3 | Teacher Tools "Syllabus" tab — UI exists for download but the upload form sends JSON only; the file never reaches the server | `useTeacherStore.createSyllabus` (`store/teacher.ts:56`) → `POST` body is `{ title, subject_id, ... }` with no multer field. `teacher.controller.js:152-160` reads `file_path` from the JSON body which is always empty. |
| 4 | Students have no surface to read any teacher-uploaded file | No route, no page, no nav entry. |
| 5 | No concept of "marked script" exists anywhere | `submissions.status = 'returned'` is in the schema CHECK constraint but never set; no UI; no upload endpoint; no file storage. |
| 6 | Parents have nowhere to see what teachers marked or what the syllabus looks like | Parent routes (`App.tsx:506-518`) have no file surface at all. |

§3 fixes items 1, 2, 3 directly. §4–§7 layer the new flows on top.

---

## §2 — Schema changes (all in `server/src/database/init.js`)

### §2.1 New polymorphic table: `attachments`

```sql
CREATE TABLE IF NOT EXISTS attachments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT UNIQUE,
  parent_kind TEXT NOT NULL CHECK(parent_kind IN (
    'subject',           -- syllabus, general resources, test papers, homework sheets
    'assignment',        -- supplementary materials for an assignment
    'homework',          -- homework sheet
    'test',              -- test paper
    'exam_paper',        -- standalone exam paper outside exam_groups
    'submission',        -- student's submission file (replaces submissions.file_path new flow)
    'marked_script'      -- teacher-uploaded marked script for a submission
  )),
  parent_id INTEGER NOT NULL,
  original_filename TEXT NOT NULL,
  stored_filename TEXT NOT NULL,    -- the multer-stored name on disk
  mime_type TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  uploaded_by INTEGER REFERENCES users(id),
  uploaded_at DATETIME DEFAULT (datetime('now','localtime')),
  caption TEXT,                     -- optional human label ("Marked Script — June Test")
  last_synced_at DATETIME,
  sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
  is_deleted INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_attachments_parent ON attachments(parent_kind, parent_id);
CREATE INDEX IF NOT EXISTS idx_attachments_uploader ON attachments(uploaded_by);
```

No FK on `parent_id` because the parent could be in any of several tables (and SQLite doesn't allow polymorphic FKs anyway). The lookup pattern is `WHERE parent_kind = ? AND parent_id = ?` — the index covers it.

### §2.2 New table: `homework` (parallel to `assignments`)

```sql
CREATE TABLE IF NOT EXISTS homework (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT UNIQUE,
  subject_id INTEGER NOT NULL REFERENCES subjects_new(id),
  class_id INTEGER REFERENCES classes(id),
  title TEXT NOT NULL,
  description TEXT,
  instructions TEXT,
  due_date DATETIME,
  max_score REAL DEFAULT 100,
  allow_late_submission INTEGER DEFAULT 0,
  is_published INTEGER DEFAULT 0,
  status TEXT DEFAULT 'draft' CHECK(status IN ('draft','published','closed')),
  created_by INTEGER REFERENCES users(id),
  created_at DATETIME DEFAULT (datetime('now','localtime')),
  updated_at DATETIME DEFAULT (datetime('now','localtime')),
  last_synced_at DATETIME,
  sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
  is_deleted INTEGER DEFAULT 0
);
```

### §2.3 New table: `tests` (parallel to `assignments`, with `test_date` + `duration_minutes`)

```sql
CREATE TABLE IF NOT EXISTS tests (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT UNIQUE,
  subject_id INTEGER NOT NULL REFERENCES subjects_new(id),
  class_id INTEGER REFERENCES classes(id),
  title TEXT NOT NULL,
  description TEXT,
  instructions TEXT,
  test_date DATETIME,
  duration_minutes INTEGER,
  max_score REAL DEFAULT 100,
  allow_late_submission INTEGER DEFAULT 0,
  is_published INTEGER DEFAULT 0,
  status TEXT DEFAULT 'draft' CHECK(status IN ('draft','published','closed')),
  created_by INTEGER REFERENCES users(id),
  created_at DATETIME DEFAULT (datetime('now','localtime')),
  updated_at DATETIME DEFAULT (datetime('now','localtime')),
  last_synced_at DATETIME,
  sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced','pending','conflict')),
  is_deleted INTEGER DEFAULT 0
);
```

### §2.4 New submission tables (parallel to `submissions`)

```sql
CREATE TABLE IF NOT EXISTS homework_submissions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT UNIQUE,
  homework_id INTEGER NOT NULL REFERENCES homework(id),
  student_id INTEGER NOT NULL REFERENCES users(id),
  content TEXT,
  submitted_at DATETIME DEFAULT (datetime('now','localtime')),
  status TEXT DEFAULT 'submitted' CHECK(status IN ('submitted','graded','returned','late')),
  grade REAL,
  feedback TEXT,
  graded_by INTEGER REFERENCES users(id),
  graded_at DATETIME,
  created_at DATETIME DEFAULT (datetime('now','localtime')),
  updated_at DATETIME DEFAULT (datetime('now','localtime')),
  last_synced_at DATETIME,
  sync_status TEXT DEFAULT 'pending',
  is_deleted INTEGER DEFAULT 0,
  UNIQUE(homework_id, student_id)
);

CREATE TABLE IF NOT EXISTS test_submissions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  uid TEXT UNIQUE,
  test_id INTEGER NOT NULL REFERENCES tests(id),
  student_id INTEGER NOT NULL REFERENCES users(id),
  content TEXT,
  submitted_at DATETIME DEFAULT (datetime('now','localtime')),
  status TEXT DEFAULT 'submitted' CHECK(status IN ('submitted','graded','returned','late')),
  grade REAL,
  feedback TEXT,
  graded_by INTEGER REFERENCES users(id),
  graded_at DATETIME,
  created_at DATETIME DEFAULT (datetime('now','localtime')),
  updated_at DATETIME DEFAULT (datetime('now','localtime')),
  last_synced_at DATETIME,
  sync_status TEXT DEFAULT 'pending',
  is_deleted INTEGER DEFAULT 0,
  UNIQUE(test_id, student_id)
);
```

The actual file content for a submission lives in `attachments` with `parent_kind='submission'` and `parent_id` = the submission's id. The legacy `submissions.file_path` column is **left in place** for backward compatibility with existing data; new code in §3.5 prefers `attachments` and falls back to `submissions.file_path` if no attachment row is found.

### §2.5 Deprecation (NOT a schema change — code only)

- `syllabuses` table — left in schema (other code reads from it elsewhere), but **no new code writes to it**. New syllabus uploads go through `attachments` with `parent_kind='subject'`. Add a `// DEPRECATED` comment at the top of `teacher.controller.js:152-160`.
- `subjects_new.syllabus_url` / `syllabus_name` — same: leave for backward compat, new code uses `attachments`. Comment at `subjects.controller.js:30-35`.

### §2.6 `tablesToSync` in `server/src/services/SyncEngine.js`

Append in this order (parents first), inside the existing block:

```
homework, tests, homework_submissions, test_submissions, attachments
```

`attachments` last because it has the most FK references but is the leaf.

---

## §3 — Backend

### §3.1 New controller: `server/src/controllers/attachments.controller.js`

Routes (all under `/api/attachments`):

| Method | Path | Body / Query | Returns |
|---|---|---|---|
| `POST` | `/` | `multipart/form-data` with fields `parent_kind`, `parent_id`, optional `caption`, plus `files` (array, 1-10) | array of attachment rows |
| `GET` | `/?parent_kind=&parent_id=` | — | array of attachment rows |
| `GET` | `/:uid` | — | single attachment row |
| `DELETE` | `/:uid` | — | `{ ok: true }` (soft-deletes; sets `is_deleted=1`) |

Multer config (mirrors `assignments.controller.js:14-31`):

- Field name: `files` (array)
- Storage: disk → `path.join(__dirname, '../../uploads')`
- Filename: `${Date.now()}-${Math.random().toString(36).slice(2,8)}${path.extname(file.originalname)}`
- Limits: `{ fileSize: 20 * 1024 * 1024, files: 10 }`
- `fileFilter`: mime allowlist — `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `application/vnd.ms-powerpoint`, `application/vnd.openxmlformats-officedocument.presentationml.presentation`, `image/png`, `image/jpeg`, `image/gif`, `application/zip`, `text/plain`

Auth: inline `auth` middleware (parses Bearer JWT). For `POST`/`DELETE`, role check:
- `parent_kind='subject'`: teacher (own subjects only), admin
- `parent_kind IN ('assignment','homework','test','exam_paper')`: teacher (own courses only), admin
- `parent_kind='submission'`: student-self, teacher (course), admin
- `parent_kind='marked_script'`: teacher (course), admin

For `GET`: visibility scoping (joins to enrollment/parent tables):
- `subject`, `assignment`, `homework`, `test`, `exam_paper`: teacher (course), admin, students enrolled in the course, parents of those students
- `submission`: student-self, teacher (course), admin, parent of student
- `marked_script`: same as submission + only visible if the linked `submissions.status` is `'returned'` for student/parent

Static download path is **unchanged** — files land at `/uploads/<stored_filename>` and the existing `express.static` mount at `/api/uploads/...` (`server/src/index.js:116`) serves them.

### §3.2 New controller: `server/src/controllers/homework.controller.js`

Mirrors `server/src/controllers/assignments.controller.js`. Endpoints:

- `GET /api/homework?subject_id&class_id` (teacher sees own courses; student sees enrolled courses)
- `POST /api/homework` (teacher/admin)
- `PUT /api/homework/:id` (teacher/admin)
- `DELETE /api/homework/:id` (soft-delete; teacher/admin)
- `GET /api/homework/:id` (with submissions + attachments joined)
- `POST /api/homework/:id/submit` (student; multer `file` + `content`; creates or updates `homework_submissions` row; also writes an `attachments` row with `parent_kind='submission'`)
- `GET /api/homework/:id/submissions/:sid/grade` (teacher; supports grade + feedback)
- `POST /api/homework/:id/submissions/:sid/return` (teacher; sets `status='returned'`, optionally attaches `marked_script` via the attachments endpoint)
- `GET /api/homework/my/submissions` (student; own rows)

### §3.3 New controller: `server/src/controllers/tests.controller.js`

Mirrors homework; same endpoint shape with `test_date` / `duration_minutes` extra fields on `POST`/`PUT`.

### §3.4 Wire controllers in `server/src/index.js`

Add under existing registrations:

```js
app.use('/api/attachments', require('./controllers/attachments.controller'));
app.use('/api/homework',    require('./controllers/homework.controller'));
app.use('/api/tests',       require('./controllers/tests.controller'));
```

### §3.5 Fix the existing broken download path

`client/src/pages/student/Assignments.tsx:529-540` "Download Submission" only works in-session because the server never returns binary. Fix:

1. In `server/src/controllers/assignments.controller.js`, when `GET /api/assignments/:id` joins submissions (`assignments.controller.js:329-359`), **also** join `attachments` where `parent_kind='submission' AND parent_id=submissions.id AND is_deleted=0`. Return them as `submissions[i].attachments: [{ uid, original_filename, mime_type, size_bytes, ... }]`.
2. Update `client/src/store/teacherAssignments.ts` and `client/src/store/assignments.ts` types to include `attachments: Attachment[]` on each submission.
3. `student/Assignments.tsx` download helper (`Assignments.tsx:199-237`) — replace the base64-from-state path with `api.get(\`attachments/${att.uid}\`, { responseType: 'blob' })` → object URL → `<a download>` click. Falls back to legacy `submissions.file_path` if no attachments (one-line `if (attachments.length === 0 && submission.file_path) ...`).
4. Same fix for the teacher's grading drawer: surface `submission.attachments` and provide a download/preview button per file.

### §3.6 Bring syllabuses upload back to life (deprecation path)

Two options — pick **A** in this plan:

- **A (chosen):** New syllabus uploads go through `POST /api/attachments` with `parent_kind='subject'`. The Teacher Tools Syllabus form (`client/src/pages/teacher/TeacherTools.tsx:74-81`) switches to `multipart/form-data`. The existing `createSyllabus` action in `store/teacher.ts:56` is replaced by `useAttachmentStore.upload({ parent_kind:'subject', parent_id:subjectId, files:[file] })`.
- B (rejected): Fix `teacher.controller.js:152-160` to accept multer. Out of scope — duplicates the attachments logic.

Student-side syllabus viewing: a new `GET /api/attachments?parent_kind=subject&parent_id=X` returns the list. Students see it on `student/MyCourses.tsx` (per-course resources section, §5.3).

---

## §4 — Frontend foundation

### §4.1 New client store: `client/src/store/attachments.ts`

Exports `useAttachmentStore` (Zustand, no persist — attachments list is per-session and re-fetched):

```ts
type Attachment = {
  id: number; uid: string; parent_kind: string; parent_id: number;
  original_filename: string; stored_filename: string;
  mime_type: string; size_bytes: number;
  uploaded_by: number; uploaded_at: string; caption?: string;
};

interface AttachmentState {
  byParent: Record<string, Attachment[]>;  // key = `${kind}:${id}`
  upload: (args: {
    parent_kind: string; parent_id: number; caption?: string;
    files: File[]; onProgress?: (pct:number)=>void;
  }) => Promise<Attachment[]>;
  list: (parent_kind: string, parent_id: number) => Promise<Attachment[]>;
  download: (uid: string, suggested_name?: string) => Promise<void>;
  preview: (uid: string) => Promise<{ url: string; mime: string; name: string }>;
  remove: (uid: string) => Promise<void>;
}
```

- Uses the shared `api` from `./api`.
- `download`: `api.get(\`attachments/\${uid}\`)` (returns the row), then `api.get(\`uploads/\${stored_filename}\`, { responseType: 'blob' })` → blob → object URL → `<a download>` click.
- `preview`: same first call + returns `{ url: objectURL, mime, name }` so callers can hand it to a PDF/image viewer.

### §4.2 New client store: `client/src/store/homework.ts` and `client/src/store/tests.ts`

Both mirror `client/src/store/assignments.ts` — list / create / update / delete / submit / grade / return. Reuses `useAttachmentStore.upload` for file attachment.

### §4.3 New shared components

- `client/src/components/AttachmentList.tsx` — renders a list of `Attachment` with download/preview buttons; reusable on student, teacher, parent pages. Tailwind matches the existing `bg-card border border-border rounded-2xl` card style.
- `client/src/components/AttachmentUploader.tsx` — multi-file drop zone (extends the hand-rolled one at `student/Assignments.tsx:612-640` with `<input type="file" multiple>`, mime filter, progress, error toast). On upload: calls `useAttachmentStore.upload(...)`.
- `client/src/components/PdfPreview.tsx` — `react-pdf` wrapper. Props: `url: string` (blob URL), `height?: number`. Renders multi-page viewer with next/prev page buttons, page indicator, zoom in/out. Falls back to `<iframe src={url}>` if `pdfjs-dist` worker fails to load (offline case).
- `client/src/components/AttachmentPreviewModal.tsx` — composes `PdfPreview` for PDFs, `<img>` for images, `<iframe>` for other types. Mirrors the modal at `client/src/pages/Messages.tsx:101`.

### §4.4 Add `react-pdf` dependency

In `client/package.json`:

```json
"react-pdf": "^9.0.0",
"pdfjs-dist": "^4.0.0"
```

Worker setup: `pdfjs.GlobalWorkerOptions.workerSrc = \`//unpkg.com/pdfjs-dist@4.x/build/pdf.worker.min.mjs\`` in `client/src/main.tsx`. For offline, the SW (production build only) will cache it from the first load — note in changelog that first load must be online. Acceptable trade-off given user's "leave for later" stance on heavy offline PDF support.

---

## §5 — Teacher UI

### §5.1 `client/src/pages/teacher/TeacherTools.tsx`

Replace the broken syllabus form (lines 74-81) with a real `AttachmentUploader` bound to `parent_kind='subject'`. Existing download button at lines 30-50 stays but switches to `useAttachmentStore.download`. Keep schemes/focus points/lesson plans unchanged.

### §5.2 `client/src/pages/teacher/Assignments.tsx`

In the create/edit modal (around line 170), add a "Reference Materials" section with `AttachmentUploader` for `parent_kind='assignment'`. In the grading drawer (around line 698), for each submission:
- Render existing submission files via `AttachmentList`
- Add an "Upload Marked Script" button → opens a modal with `AttachmentUploader` for `parent_kind='submission'` + a "Return to student" toggle that flips `submissions.status` to `'returned'`

### §5.3 New page: `client/src/pages/teacher/Resources.tsx`

Per-subject resources hub. Two views:
- **Subjects list** — cards from `/api/subjects` (filtered to teacher's courses).
- **Resources view** (subject selected) — tabs for **Syllabus**, **Test Papers**, **Homework Sheets**, **Past Exam Papers**. Each tab lists `attachments` with `parent_kind='subject'` and a `caption` matching the tab label (e.g. `caption LIKE 'syllabus:%'`). Upload button per tab → `AttachmentUploader`. Delete button per row.

Why captions? `attachments.parent_kind` is just `'subject'` — the per-tab label lives in the `caption` column (`'syllabus'`, `'test_paper'`, `'homework_sheet'`, `'past_exam'`).

Wire route: `<ProtectedRoute allowedRoles={['teacher','school_admin','principal']}>` at `client/src/App.tsx` in the teacher block. Nav entry in `client/src/components/Nav.tsx` teacher section.

### §5.4 New page: `client/src/pages/teacher/Homework.tsx` and `client/src/pages/teacher/Tests.tsx`

Each is a teacher control plane mirroring `teacher/Assignments.tsx` (draft / published / closed tabs, create/edit modal, lifecycle actions, submissions drawer, CSV export). Filter label shown to teacher: "Homework" vs "Tests". Reuses `useHomeworkStore` / `useTestStore` from §4.2.

Wire routes: `<ProtectedRoute allowedRoles={['teacher','school_admin']}>`. Nav entries in teacher section.

---

## §6 — Student UI

### §6.1 Fix `client/src/pages/student/Assignments.tsx`

- Replace session-only download (§3.5) with `useAttachmentStore.download`.
- Add multi-file supplementary upload in the submit modal (lines 546-677): primary file (single, current behavior) + "Additional files" via `AttachmentUploader` with `parent_kind='submission'`.
- In the "completed" tab, show a "View Marked Script" button per submission if `submissions[i].status === 'returned'` AND an attachment with `parent_kind='marked_script'` exists. Click opens `AttachmentPreviewModal`.

### §6.2 New page: `client/src/pages/student/Homework.tsx` and `client/src/pages/student/Tests.tsx`

Student-side mirrors of the teacher pages but read-only (no create/edit/delete), with submit action + marked-script view. Reuses `useHomeworkStore.submit` / `useTestStore.submit`.

Wire routes: `<ProtectedRoute allowedRoles={['student','parent']}>` (parent can view on behalf of child). Nav entries in student section.

### §6.3 New page: `client/src/pages/student/Resources.tsx`

Read-only mirror of teacher Resources: browse by subject → tabs (Syllabus / Test Papers / Homework Sheets / Past Exam Papers) → list with download + `AttachmentPreviewModal`. Reuses `AttachmentList` and `PdfPreview`.

Wire route + nav entry.

### §6.4 Add marked-scripts callout to `student/StudentDashboard.tsx`

Above the recent-marks card, if any of the student's submissions have `status='returned'` AND a `marked_script` attachment, show a "New marked scripts available" card → links to `Assignments` page filtered to "Completed + Returned".

---

## §7 — Parent + Admin views

### §7.1 `client/src/pages/parent/AcademicProgress.tsx`

Add a section: **Marked Scripts** — fetches `GET /api/attachments?parent_kind=marked_script` filtered to the parent's children (server-side filter in the GET scope rule in §3.1). Lists with preview/download buttons. Renders inside the existing page; no new route.

### §7.2 `client/src/pages/parent/Resources.tsx` (new)

Per-child subject resources viewer — same surface as `student/Resources.tsx` but switched via child selector at the top (parent already has child switcher in other pages — match the pattern).

Wire route: `<ProtectedRoute allowedRoles={['parent']}>`. Nav entry in parent section.

### §7.3 Admin/Principal

- `school_admin`, `principal` already have read access to `assignments`/`submissions` via existing routes — extend the teacher's `/api/attachments` GET scope to include `school_admin`, `systems_admin`, `principal` for all `parent_kind` values.
- No new pages needed — admin views via the existing `/admin` dashboard surfaces, with `Attachments` filtering added inline where the dashboard already lists assignments.

---

## §8 — RBAC matrix (consolidated)

| Action | Teacher | Student | Parent | School admin | Principal |
|---|---|---|---|---|---|
| Upload syllabus / course material | own courses | — | — | any | any |
| Upload test paper / homework sheet | own courses | — | — | any | any |
| Upload marked script | own course's submissions | — | — | any | any |
| View syllabus / course material | own courses | enrolled | child enrolled | any | any |
| Download student submission | own course's submissions | self | child | any | any |
| Download marked script | own course's submissions | self (only if returned) | child (only if returned) | any | any |
| Upload assignment / homework / test answer | — | self | — | — | — |
| Create assignment / homework / test | own courses | — | — | any | any |
| Grade submission | own course's submissions | — | — | any | any |
| Delete attachment | own uploads (within 24h) | own uploads (within 24h) | — | any | any |

Implementation note: RBAC is enforced server-side in `attachments.controller.js` and the new `homework.controller.js` / `tests.controller.js`. Client-side gating is just UX (route `<ProtectedRoute>`); the server is the source of truth.

---

## §9 — Phase breakdown

> **⚠ Superseded 2026-07-11 by §0's 3-PR table above.** The 5-phase A/B/C/D/E breakdown below describes the *original* phasing from before the user re-pinged the plan on 2026-07-11. The active delivery cadence is the 3-PR table at the top of this file: **PR 1 Foundation** (merged to `dev` as `7b1b86d`), **PR 2 Teacher** (merged as `0f28b27`), **PR 3 Student + Parent** (queued). Rough mapping: §A → PR 1; §B + §C → PR 2 (bundled); §D + §E → PR 3 (planned). Leaving §9 in place so anyone reading the historical record can see the original plan; treat it as read-only.

<details>
<summary>Original 5-phase breakdown (kept for historical reference; see supersession note above)</summary>

| Phase | Scope | Branch | Depends on |
|---|---|---|---|
| **§A — Foundation** | Schema (§2), `attachments.controller.js` (§3.1), `attachments` store (§4.1), `AttachmentList` + `AttachmentUploader` components, `react-pdf` install (§4.4), fix `submissions.file_path` download (§3.5), wire `attachments` into `index.js` + `SyncEngine.js` | `feature/file-attachments-foundation` | — |
| **§B — Teacher uploads** | Teacher Tools syllabus fix (§5.1), Assignments attachments + marked-script upload (§5.2), teacher `Resources` page (§5.3) | `feature/file-attachments-teacher` | §A |
| **§C — Teacher content** | Teacher `Homework` + `Tests` pages (§5.4), `homework.controller.js` + `tests.controller.js` (§3.2-3.3), `homework` + `tests` + submission tables (§2.2-2.4) | `feature/file-attachments-content` | §A |
| **§D — Student views** | Student `Homework` + `Tests` pages (§6.2), student `Resources` page (§6.3), marked-script callout on dashboard (§6.4), assignments modal multi-file (§6.1) | `feature/file-attachments-student` | §B, §C |
| **§E — Parent + Admin** | Parent marked-scripts section (§7.1), parent `Resources` page (§7.2), admin/principal scope widening (§7.3) | `feature/file-attachments-parent` | §A |

Each phase ships as its own worktree + branch; each gets a code review before merge to `dev`.

</details>

**PR-by-PR delivery status:**

| PR | Branch | Dev head | Status |
|---|---|---|---|
| PR 1 Foundation | `feature/file-attachments-foundation` | `7b1b86d` (merged) | ✅ |
| PR 2 Teacher | `feature/file-attachments-teacher` | `0f28b27` (merged) | ✅ |
| PR 3 Student + Parent | `feature/file-attachments-views` (planned) | — | ⬜ |

Cleanup PR (`fix/file-attachments-cleanup`, 2026-07-11): moved root-level `deliverable.md` → `.harness/changelogs/`; widened route `roles:` for `/homework`, `/tests`, `/resources` to include `school_admin`, `systems_admin`, `principal` per §5.4; annotated §0.0 open questions as resolved; marked §9 as superseded by §0's 3-PR table. (The `CHECK(sync_status IN (…))` on `homework_submissions` / `test_submissions` had already landed as part of PR 2 commit `9e8d021` — no extra ALTER needed.)

---

## §10 — Migration & data safety

- All new tables are `CREATE TABLE IF NOT EXISTS` — idempotent; safe on existing `school.db`.
- No `ALTER TABLE` on existing tables (`submissions.file_path`, `subjects_new.syllabus_url`, `syllabuses.*` all left intact).
- Existing `submissions.file_path` values: a one-time backfill script (Node script in `server/scripts/backfill-attachments.js`, NOT auto-run on init) reads `submissions` where `file_path IS NOT NULL`, checks for an existing file in `server/uploads/`, and inserts an `attachments` row with `parent_kind='submission', parent_id=<sub.id>, original_filename=<basename>, stored_filename=<file_path>, mime_type='application/octet-stream'`. Script is run manually by the user once.
- No changes to existing controllers' behavior — `assignments.controller.js` still accepts `file` on `/:id/submit` and writes `submissions.file_path`. New `attachments` writes are additive.

---

## §11 — Verification (no test framework installed — manual recipe)

Per phase, the following smoke checks must pass before merge:

**§A foundation**
1. `cd server && npm run db:init` — confirm `attachments`, `homework`, `tests`, `homework_submissions`, `test_submissions` appear in `data/school.db` (e.g. `sqlite3 data/school.db ".schema attachments"`).
2. `curl -X POST http://localhost:3001/api/attachments -H "Authorization: Bearer <teacher jwt>" -F parent_kind=subject -F parent_id=1 -F files=@./test.pdf` returns a row with a non-null `stored_filename` and the file exists at `server/uploads/<stored_filename>`.
3. As student (different JWT): `GET /api/attachments?parent_kind=subject&parent_id=1` returns the row (or 403 if not enrolled — depending on fixture).
4. As student, submit an assignment via the existing modal → reload page → "Download Submission" still works (was broken before §3.5).
5. `cd client && npm run build` — confirm SW generates and `react-pdf` worker URL resolves.

**§B teacher uploads**
6. Teacher Tools → Syllabus tab → upload a PDF → reload → click Download → file downloads.
7. Teacher Assignments → edit an assignment → upload a reference PDF → student in same course → see it in their Resources page (after §D lands; for §B itself, just confirm the row appears in `attachments`).
8. Teacher Assignments → grading drawer → click "Upload Marked Script" → upload PDF → confirm row in `attachments` with `parent_kind='marked_script'`.

**§C teacher content**
9. Teacher Homework page → create a homework → publish → student sees it (after §D).
10. Teacher Tests page → create a test with `test_date`/`duration_minutes` → save → reload → row visible.

**§D student views**
11. Student Homework page → submit homework with attached PDF → reload → submission visible with file.
12. Student dashboard → "New marked scripts available" callout appears for returned submission.
13. Student Resources page → open per-subject syllabus tab → click "Preview" on PDF → `PdfPreview` renders multi-page.

**§E parent + admin**
14. Parent AcademicProgress → Marked Scripts section lists the marked script uploaded in step 8.
15. Parent Resources → switched to a child → see course materials the child can see.
16. School admin loads any teacher's class assignment detail → sees all attachments.

After each phase, also run `cd client && npm run build` (production build exercises the SW registration path per root `AGENTS.md`).

---

## §12 — Changelog + AGENTS.md updates

For each phase PR:
- Add a section to `.harness/changelogs/<YYYY-MM-DD>.md` with the new tables, controllers, routes, and the manual verification recipe that was actually executed.
- After §A lands: update root `AGENTS.md` to remove "no central `attachments` table" from the "Things that will trip you up" section and to add `attachments` to the canonical reference list.
- After §B lands: note in root `AGENTS.md` that `syllabuses` is deprecated.

---

## §13 — Open follow-ups created by this plan (do not take these on — ask)

- Test framework coverage for the new controllers (the `tester` rein owns Vitest + integration tests).
- Offline PDF rendering without network first-load (requires bundling the `pdfjs-dist` worker into the SW precache list — `vite-plugin-pwa` config tweak + a tester rein asset-bundling plan).
- Real-time push notification when a teacher uploads a marked script (currently requires student reload).
- Versioning of marked scripts (current "last-write-wins" model).