geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-22-cohorts.md

5.8 KiB

2026-07-22 — Phase 1 PR 2: student cohorts (frontend closeout)

Branch: feature/admin-cohorts-2026-07-22 Owner: fchin Scope: finish the cohorts surface — frontend (list / detail / form), routes, nav, E2E

What landed

This is the closeout commit for PR 2. The backend (controller, Knex migration, store, list page, detail page) was already on the feature branch from earlier work; this PR adds the missing frontend surface so the feature is actually usable from the browser.

Schema (recap — already on branch)

  • New Knex migration 2026072200000020_cohorts.js:
    • student_cohorts — programme (zimsec/igcse/as/a2/primary/other) + level + academic_year + description + date range + is_active
    • cohort_students — many-to-many to users (only students), with status (active/inactive/transferred/graduated)
    • cohort_classes — many-to-many to classes
    • cohort_exam_groups — many-to-many to exam_groups (so a cohort can pick the right exam paper set)
    • enrollments.cohort_id — nullable FK added via ALTER (backfill is a separate, non-blocking op)
    • All four tables follow the standard sync contract (uid, sync_status, last_synced_at, is_deleted)
    • Unique indexes on (cohort_id, student_id) and similar for the other two join tables

Backend (recap — already on branch)

  • server/src/controllers/cohorts.controller.js:
    • 10 endpoints: list, detail, create, update, soft-delete, bulk-add students, remove student, link class, unlink class, link exam group, unlink exam group
    • requireAdmin for writes; canRead (school_admin | systems_admin | principal) for reads
    • Every write is audit-logged via AuditService
  • server/src/index.js — controller mounted at /api/cohorts
  • server/src/services/SyncEngine.js — 4 new tables appended to tablesToSync after user_roles, in FK dependency order

Frontend (this PR)

  • client/src/store/cohorts.ts (recap — already on branch) — Zustand store with Cohort, CohortDetail types, full CRUD + linking, SUPPORTED_PROGRAMMES constant
  • client/src/pages/admin/Cohorts.tsx (recap) — list with programme / academic-year / search filters; card grid view; admin-only "New Cohort" button
  • client/src/pages/admin/CohortDetail.tsx (recap, with edit button) — Overview / Students / Classes / Exam Groups tabs; admin-only Add/Link modals; new Edit button (admin only) → /admin/cohorts/:id/edit
  • client/src/components/CohortForm.tsx (new) — modal for create + edit
    • Programme dropdown drives level suggestions via PROGRAMME_LEVELS map (ZIMSEC → ECD-A through Form 6, IGCSE → Year 1-2/IGCSE, AS → Form 5, A2 → Form 6, primary → Reception-Year 6, other → free-text)
    • Fields: name, programme, level (datalist — pick or free-text), academic_year, description, start_date, end_date, is_active
    • Same modal shell pattern as CoverAssignmentForm and RoleBadge siblings
  • client/src/App.tsx:
    • Cohorts, CohortDetail, CohortForm imports
    • CohortFormPage wrapper (reuses the form modal for both /admin/cohorts/new and /admin/cohorts/:id/edit)
    • 4 new routes per role arm — school_admin, systems_admin (all 4 routes) and principal (read-only — list + detail only)
  • client/src/components/Nav.tsx:
    • Cohorts entry (GraduationCap icon) added to school_admin, systems_admin, principal
    • Principal sees the entry as read-only; the page hides the "New Cohort" button

E2E

  • client/e2e/cohorts.spec.ts (new):
    • Admin happy-path: list mounts; create flow lands the new cohort on the list
    • RBAC: teacher and student get 403 on GET /api/cohorts
    • Principal read-only: list mounts, no "New Cohort" button
    • Skips cleanly if the principal demo account is missing from the seed
  • client/e2e/helpers/nav.ts/admin/cohorts added to ROUTES_PER_ROLE.school_admin and ROUTES_PER_ROLE.systems_admin (so the existing portal smoke walks the route)

Decisions / why

  • Programme-driven level suggestions, not enforcement. Different schools use different labels. The datalist is a hint, not a constraint.
  • One form modal, two routes. /new and /:id/edit both render the same CohortForm. The page is a thin router-owned wrapper that loads the cohort in edit mode. This keeps the create + edit UI in lockstep automatically.
  • Principal is read-only at the route level. No /new or /:id/edit route is mounted for principal. Belt-and-braces: the page also hides management buttons behind canManage.
  • No auto-create-on-class. Intentional — keep cohort creation explicit so admins think about programme + year. The link is one click on the Classes tab.
  • other programme has no level suggestions. Forces free-text so admins don't accidentally pick a misleading label.

Out of scope (deferred)

  • Bulk transfer between cohorts (PR 3+ follow-up if requested)
  • Per-cohort enrollment report (Phase 2 dashboard)
  • Cross-cohort timetable / shared subjects view
  • Auto-backfill of enrollments.cohort_id for existing rows (will run on the next academic rollover, or via the /admin/academic-rollover UI)

Verification

  • npm run db:init runs cleanly on a fresh DB (cohorts migration is forward-only, applied via Knex)
  • Backend smoke (run pre-closeout): create / list / detail / add student / link class / link exam group / remove student / unlink class all OK
  • Browser smoke: navigate to /admin/cohorts, create one, open detail, add a student, link a class, soft-delete
  • Principal sees the list and the read-only detail
  • Teacher is denied 403 on GET /api/cohorts and on /admin/cohorts (ProtectedRoute redirects to their own dashboard)
  • npm run test:e2e -- --grep "@flow|Cohorts" passes the new spec
  • Portal smoke (@smoke) walks the new route without console errors