geocrop-platform./apps/nextgen/.harness/plans/file-attachments.md

546 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).