546 lines
35 KiB
Markdown
546 lines
35 KiB
Markdown
# 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). |