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

35 KiB
Raw Permalink Blame History

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):

  • Keep the 3-PR cadence, or split further? → Adopted (PR 1 + PR 2 landed; PR 3 queued).
  • Confirm v1 PDF viewer is plain <iframe> (no react-pdf)? → Adopted. Both PRs use native <iframe> + <img>; react-pdf deferred indefinitely.
  • "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

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)

  • 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:

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 →click. Falls back to legacysubmissions.file_pathif no attachments (one-lineif (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):

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), 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 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.tsxreact-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:

"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 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.

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