geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-22-class-assignment...

6.4 KiB

2026-07-22 — Phase 1 PR 3: class + teacher assignment UX

Branch: feature/admin-cohorts-2026-07-22 Owner: fchin Scope: finish the class / teacher / roster assignment workflow in one consistent surface

What landed

Two commits: backend first (5 controller changes, no schema), then frontend (3 new files, 2 modified files, 1 route + nav entry, 1 e2e spec, 1 changelog).

Backend (commit 1)

  • server/src/controllers/students.controller.js:
    • POST /api/students/bulk-enroll{ studentIds, classId, academicYear? } Returns { added, skipped, errors }. Idempotent on (student_id, class_id) so re-running the same batch is a no-op. Admin/teacher only.
  • server/src/controllers/enrollments.controller.js:
    • POST /api/enrollments/:id/transfer{ newClassId, reason? } Atomic: old row -> status='transferred', new row -> active/approved. Refuses if the student is already active in the destination class. Admin/teacher only. Audit-logged.
  • server/src/controllers/classes.controller.js:
    • POST /api/classes?withAssignments=1 — accepts { classTeacherId, initialStudentIds, academicYear? }. Validates the teacher (role=teacher) and each student id (exists + role=student) up front, then runs the insert + bulk-enrol in one transaction.
    • PUT /api/classes/:id/class-teacher{ teacherId } (null clears). Admin-only. Audits the swap.
    • GET /api/classes/:id/teachers — returns { class_teacher, subject_teachers, active_covers } in one round-trip. Active covers join user_roles with the same datetime filter as rbac.getActiveCovers (PR 1's helper, inlined here to keep the controller self-contained).
    • POST /api/classes/:id/subjects/:subjectId/teacher{ teacherId } (null clears). Refuses if the subject isn't bound to the path class. Admin-only. Audits the swap.

Frontend (commit 2)

  • client/src/store/classDetail.ts — new Zustand store. Composes the class detail from the four existing endpoints (class detail, teachers, enrollments, cohorts-of-class). Exposes write actions: setFormTeacher, setSubjectTeacher, bulkEnrol, transferStudent, withdrawStudent.
  • client/src/components/CreateClassWizard.tsx — new modal. One flow: pick the class details, assign a form tutor (optional), bulk-enrol an initial roster (optional). Calls POST /api/classes?withAssignments=1.
  • client/src/pages/admin/ClassDetail.tsx — new page, 5 tabs:
    • Overview: stat cards (students, form tutor, subjects, cohorts) + description
    • Roster: active students + Bulk assign + per-row Transfer / Withdraw
    • Teachers: form tutor (with inline Change) + active covers + Add cover (uses PR 1's CoverAssignmentForm modal)
    • Subjects: per-subject teacher assignment with inline Change
    • Cohorts: cross-link to /admin/cohorts/:id for any cohort this class is in
  • client/src/pages/Classes.tsx:
    • New View detail button on each class card -> /admin/classes/:id
    • The old simple create form is replaced with CreateClassWizard. On create, the wizard closes, the list reloads, and the admin jumps straight to the new class detail.
  • client/src/pages/Students.tsx:
    • New Bulk assign toolbar button -> modal that picks a target class
      • checks students and calls /api/students/bulk-enroll.
    • BulkAssignStudentsModal component added at the bottom of the file.
  • client/src/App.tsx:
    • New /admin/classes/:id route per role arm (school_admin, systems_admin, principal). Principal is read-only — the page hides the management buttons behind canManage.
  • client/e2e/helpers/nav.ts:
    • /admin/classes/1 added to ROUTES_PER_ROLE.school_admin so the existing portal smoke walks the new route.
  • client/e2e/class-assignments.spec.ts — new spec:
    • Admin: page mounts, 5 tabs visible
    • Admin API: swap form tutor, bulk-enrol (idempotent)
    • RBAC: teacher can read /api/classes/:id (200) but gets 403 on PUT /class-teacher; student gets 403 on bulk-enroll and transfer

Decisions / why

  • 5 tabs, not 5 pages. A single URL with tab state keeps the per-class data fetch to one call. The store composes the four endpoints once, the tabs re-render from store state.
  • status='transferred' keeps request_status='approved'. The schema's enrollments.status enum allows transferred (existing 4-state lifecycle), but request_status is a 4-value enum (pending/approved/rejected/withdrawn). Transfer keeps the approval state to satisfy the constraint while flipping the lifecycle field. Documented in the controller's transfer handler.
  • POST /api/classes?withAssignments=1 uses a query flag rather than a body field so the no-assignments call shape is unchanged. The new flag is opt-in.
  • No auto-cohort-on-class-creation. Intentional — keep cohort creation explicit so admins think about programme + year. The Cohorts tab on the class detail page is a read view; the link is a manual one-click.
  • PR 1's user_roles reuse. Active covers come from PR 1's auxiliary role grants (scope_class_id + time-bounded). The GET /api/classes/:id/teachers endpoint inlines the same datetime filter so it doesn't depend on a server-side RBAC helper. The ClassDetail page renders the "Add cover" button using PR 1's CoverAssignmentForm modal, so a teacher covering a class for two weeks stays a one-screen flow.

Out of scope (deferred)

  • Per-class attendance / assessment drill-downs (already exist as separate routes; PR 3 just adds the cross-link surface)
  • Subject-level "Add new subject" UI inside ClassDetail (still lives on the existing Classes page)
  • Auto-promotion of a student to next-year class (academic rollover is a separate feature under /admin/academic-rollover)
  • Bulk transfer between cohorts (one student at a time this PR)

Verification

  • Backend smoke (curl): bulk-enroll, transfer, withAssignments, class-teacher swap, subject-teacher swap, teachers list — all OK
  • npm run db:init is unchanged (no migration)
  • New e2e spec: 3 admin tests + 3 RBAC tests pass
  • Portal smoke (@smoke) walks the new route without console errors
  • Existing cohort + rbac specs still pass
  • Manual: admin can navigate /classes -> View detail on Form 1A -> swap form tutor -> add a student via Bulk assign -> see the roster update without a manual refresh