35 KiB
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:
- PDF viewer is
<iframe>+<img>in v1. The plan originally saidreact-pdf+pdfjs-distfor 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 inreact-pdflater if a teacher complains. Skip §4.4 entirely. - Bundle the 5 phases into 3 PRs (vs 5) to keep review overhead low. Each PR still ships as a worktree branched from
devand 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):
- Keep the 3-PR cadence, or split further? → Adopted (PR 1 + PR 2 landed; PR 3 queued).
- Confirm v1 PDF viewer is plain
<iframe>(noreact-pdf)? → Adopted. Both PRs use native<iframe>+<img>;react-pdfdeferred indefinitely. - "Tests" and "Exams" — same concept for this purpose (a teacher-uploaded paper, student-uploads-their-script)? Plan currently has
parent_kind='test'andparent_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 separateparent_kindvalues.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 inclient/src/App.tsx<ProtectedRoute allowedRoles=...>, nav entry inclient/src/components/Nav.tsx, changelog entry in.harness/changelogs/YYYY-MM-DD.md. - Backend controller template: inline
authmiddleware,db.pragma('foreign_keys = ON')already on by default ininit.js, every write setssync_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 totablesToSyncinserver/src/services/SyncEngine.jsin FK dependency order. - Single axios instance at
client/src/store/api.ts. Don't create a second one. - Static uploads served at
/uploadsand/api/uploadsfromserver/src/index.js:112and: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
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)
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)
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)
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)
syllabusestable — left in schema (other code reads from it elsewhere), but no new code writes to it. New syllabus uploads go throughattachmentswithparent_kind='subject'. Add a// DEPRECATEDcomment at the top ofteacher.controller.js:152-160.subjects_new.syllabus_url/syllabus_name— same: leave for backward compat, new code usesattachments. Comment atsubjects.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), adminparent_kind IN ('assignment','homework','test','exam_paper'): teacher (own courses only), adminparent_kind='submission': student-self, teacher (course), adminparent_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 studentssubmission: student-self, teacher (course), admin, parent of studentmarked_script: same as submission + only visible if the linkedsubmissions.statusis'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; multerfile+content; creates or updateshomework_submissionsrow; also writes anattachmentsrow withparent_kind='submission')GET /api/homework/:id/submissions/:sid/grade(teacher; supports grade + feedback)POST /api/homework/:id/submissions/:sid/return(teacher; setsstatus='returned', optionally attachesmarked_scriptvia 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:
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:
- In
server/src/controllers/assignments.controller.js, whenGET /api/assignments/:idjoins submissions (assignments.controller.js:329-359), also joinattachmentswhereparent_kind='submission' AND parent_id=submissions.id AND is_deleted=0. Return them assubmissions[i].attachments: [{ uid, original_filename, mime_type, size_bytes, ... }]. - Update
client/src/store/teacherAssignments.tsandclient/src/store/assignments.tstypes to includeattachments: Attachment[]on each submission. student/Assignments.tsxdownload helper (Assignments.tsx:199-237) — replace the base64-from-state path withapi.get(\attachments/${att.uid}`, { responseType: 'blob' })→ object URL →click. Falls back to legacysubmissions.file_pathif no attachments (one-lineif (attachments.length === 0 && submission.file_path) ...`).- Same fix for the teacher's grading drawer: surface
submission.attachmentsand 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/attachmentswithparent_kind='subject'. The Teacher Tools Syllabus form (client/src/pages/teacher/TeacherTools.tsx:74-81) switches tomultipart/form-data. The existingcreateSyllabusaction instore/teacher.ts:56is replaced byuseAttachmentStore.upload({ parent_kind:'subject', parent_id:subjectId, files:[file] }). - B (rejected): Fix
teacher.controller.js:152-160to 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):
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
apifrom./api. download:api.get(\attachments/${uid}`)(returns the row), thenapi.get(`uploads/${stored_filename}`, { responseType: 'blob' })→ blob → object URL →` 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 ofAttachmentwith download/preview buttons; reusable on student, teacher, parent pages. Tailwind matches the existingbg-card border border-border rounded-2xlcard style.client/src/components/AttachmentUploader.tsx— multi-file drop zone (extends the hand-rolled one atstudent/Assignments.tsx:612-640with<input type="file" multiple>, mime filter, progress, error toast). On upload: callsuseAttachmentStore.upload(...).client/src/components/PdfPreview.tsx—react-pdfwrapper. 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}>ifpdfjs-distworker fails to load (offline case).client/src/components/AttachmentPreviewModal.tsx— composesPdfPreviewfor PDFs,<img>for images,<iframe>for other types. Mirrors the modal atclient/src/pages/Messages.tsx:101.
§4.4 Add react-pdf dependency
In client/package.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`inclient/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
AttachmentUploaderforparent_kind='submission'+ a "Return to student" toggle that flipssubmissions.statusto'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
attachmentswithparent_kind='subject'and acaptionmatching 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
AttachmentUploaderwithparent_kind='submission'. - In the "completed" tab, show a "View Marked Script" button per submission if
submissions[i].status === 'returned'AND an attachment withparent_kind='marked_script'exists. Click opensAttachmentPreviewModal.
§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,principalalready have read access toassignments/submissionsvia existing routes — extend the teacher's/api/attachmentsGET scope to includeschool_admin,systems_admin,principalfor allparent_kindvalues.- No new pages needed — admin views via the existing
/admindashboard surfaces, withAttachmentsfiltering 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
devas7b1b86d), PR 2 Teacher (merged as0f28b27), 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.
Original 5-phase breakdown (kept for historical reference; see supersession note above)
| 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.
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 existingschool.db. - No
ALTER TABLEon existing tables (submissions.file_path,subjects_new.syllabus_url,syllabuses.*all left intact). - Existing
submissions.file_pathvalues: a one-time backfill script (Node script inserver/scripts/backfill-attachments.js, NOT auto-run on init) readssubmissionswherefile_path IS NOT NULL, checks for an existing file inserver/uploads/, and inserts anattachmentsrow withparent_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.jsstill acceptsfileon/:id/submitand writessubmissions.file_path. Newattachmentswrites are additive.
§11 — Verification (no test framework installed — manual recipe)
Per phase, the following smoke checks must pass before merge:
§A foundation
cd server && npm run db:init— confirmattachments,homework,tests,homework_submissions,test_submissionsappear indata/school.db(e.g.sqlite3 data/school.db ".schema attachments").curl -X POST http://localhost:3001/api/attachments -H "Authorization: Bearer <teacher jwt>" -F parent_kind=subject -F parent_id=1 -F files=@./test.pdfreturns a row with a non-nullstored_filenameand the file exists atserver/uploads/<stored_filename>.- As student (different JWT):
GET /api/attachments?parent_kind=subject&parent_id=1returns the row (or 403 if not enrolled — depending on fixture). - As student, submit an assignment via the existing modal → reload page → "Download Submission" still works (was broken before §3.5).
cd client && npm run build— confirm SW generates andreact-pdfworker 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>.mdwith the new tables, controllers, routes, and the manual verification recipe that was actually executed. - After §A lands: update root
AGENTS.mdto remove "no centralattachmentstable" from the "Things that will trip you up" section and to addattachmentsto the canonical reference list. - After §B lands: note in root
AGENTS.mdthatsyllabusesis deprecated.
§13 — Open follow-ups created by this plan (do not take these on — ask)
- Test framework coverage for the new controllers (the
testerrein owns Vitest + integration tests). - Offline PDF rendering without network first-load (requires bundling the
pdfjs-distworker into the SW precache list —vite-plugin-pwaconfig 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).