15 KiB
Phase 1 — PR 2 closeout + PR 3 execution plan
Date: 2026-07-27
Branch: feature/admin-cohorts-2026-07-22 (from dev)
Companion: Phase 1 design
Why this doc exists: the design plan lays out what ships. This doc is the ordered execution list for the closeout of PR 2 (cohorts frontend) and the full build of PR 3 (class + teacher assignment UX). Read top-to-bottom, do in order.
Current state (re-verified 2026-07-27)
PR 1 (auxiliary roles) is merged + pushed on the feature branch. PR 2 (cohorts) is partially built on the same branch — backend + Zustand store + list page + detail page are written, but routes/nav/E2E are missing.
| Piece | State |
|---|---|
Knex migration 2026072200000020_cohorts.js |
✅ written, on disk |
server/src/controllers/cohorts.controller.js |
✅ written, on disk (10 endpoints, audit-logged) |
server/src/index.js mount at /api/cohorts |
✅ modified, uncommitted |
server/src/services/SyncEngine.js tablesToSync (4 new tables) |
✅ modified, uncommitted |
client/src/store/cohorts.ts (Zustand) |
✅ written, on disk |
client/src/pages/admin/Cohorts.tsx (list) |
✅ written, on disk |
client/src/pages/admin/CohortDetail.tsx (4 tabs + 3 modals) |
✅ written, on disk |
client/src/components/CohortForm.tsx (create/edit) |
❌ missing |
client/src/App.tsx routes (/admin/cohorts, /admin/cohorts/:id, /admin/cohorts/new) |
❌ missing |
client/src/components/Nav.tsx Cohorts entry |
❌ missing |
client/e2e/helpers/nav.ts ROUTES_PER_ROLE updates |
❌ missing |
client/e2e/cohorts.spec.ts smoke |
❌ missing |
.harness/changelogs/2026-07-22-cohorts.md |
❌ missing |
| Commit + push | ❌ pending |
Backend smoke from prior session confirmed working: create cohort, add student, link class, link exam group, list, detail, remove student, unlink class.
PR 2 — Cohorts closeout
A. client/src/components/CohortForm.tsx (new)
A modal component that handles both create and edit. The plan's spec:
- programme dropdown drives
levelsuggestions - submit calls
createCohort/updateCohortfrom store - close on success
Programme → level suggestions (encoded as a constant, not magic strings):
const PROGRAMME_LEVELS: Record<string, string[]> = {
zimsec: ['ECD A', 'ECD B', 'Grade 1', 'Grade 2', 'Grade 3', 'Grade 4',
'Grade 5', 'Grade 6', 'Grade 7', 'Form 1', 'Form 2', 'Form 3',
'Form 4', 'Form 5', 'Form 6'],
igcse: ['Year 1', 'Year 2', 'IGCSE'],
as: ['Form 5', 'AS Level'],
a2: ['Form 6', 'A2 Level'],
primary: ['Reception', 'Year 1', 'Year 2', 'Year 3', 'Year 4', 'Year 5', 'Year 6'],
other: [], // free-text only
};
UI shape (mirrors CoverAssignmentForm + UserRoles patterns already in
the repo):
- ModalShell (fixed inset-0 overlay, inner
stopPropagation) - Form fields: name (required), programme (required, dropdown), level (datalist of suggestions + free-text), academic_year (text), description (textarea), start_date / end_date (date inputs, optional), is_active (toggle)
- Two action buttons: Cancel + "Save cohort" / "Create cohort"
- Loading + error states; on success,
onClose()— parent navigates away
Props:
interface CohortFormProps {
cohortId?: number; // omit for create
onClose: () => void;
onSaved?: (cohort: Cohort) => void;
}
Why not edit-in-place on the detail page: cards navigate to detail, and the detail page is read-mostly with tab actions. The "Edit cohort" action is a small button on the detail page header that opens this same modal. Simpler than two separate code paths.
B. client/src/App.tsx — add cohort routes
Three routes per role arm (school_admin, systems_admin, principal):
/admin/cohorts→Cohorts(list)/admin/cohorts/new→ a thin wrapper that rendersCohortFormwith nocohortIdand on close navigates to/admin/cohorts(use a small inline component or pass a wrapper; the wrapper is ~10 lines)/admin/cohorts/:id→CohortDetail
Add an Edit button to the detail page header (admin only) that navigates to
/admin/cohorts/:id/edit (same wrapper but with cohortId=id).
Add new page imports at the top of App.tsx:
import Cohorts from './pages/admin/Cohorts';
import CohortDetail from './pages/admin/CohortDetail';
import CohortForm from './components/CohortForm';
Mount under the case 'school_admin':, case 'systems_admin':, and
case 'principal': arms. Principal is read-only — gate the routes with the
same allowedRoles array but the page checks canManage internally (it
already does).
C. client/src/components/Nav.tsx — Cohorts entries
Add to all three role arms (school_admin, systems_admin, principal):
{ path: '/admin/cohorts', label: 'Cohorts', icon: GraduationCap },
The GraduationCap icon is already imported. Place it near the existing
/students entry for natural grouping.
D. client/e2e/helpers/nav.ts — extend ROUTES_PER_ROLE
Add /admin/cohorts to school_admin and systems_admin. Skip principal
for now (no principal smoke spec yet — see prinicpal: [] note in the file).
E. client/e2e/cohorts.spec.ts (new)
Smoke + happy-path:
test.describe('Cohorts (Phase 1 PR 2) @flow', () => {
test.beforeEach(async ({ page }) => { await loginAs(page, 'school_admin'); });
test('list page mounts and shows the empty or cohort state', async ({ page }) => {
await page.goto(`${BASE}/admin/cohorts`);
await expect(page.locator('h1, h2').first()).toBeVisible();
// Either a cohort card or the "No cohorts match" empty state
const cardOrEmpty = page
.getByText(/Form 4 IGCSE 2026|Grade 7 ZIMSEC|No cohorts/i)
.or(page.getByRole('button', { name: /New Cohort/i }));
await expect(cardOrEmpty.first()).toBeVisible({ timeout: 5_000 });
});
test('create new cohort end-to-end via the form', async ({ page }) => {
await page.goto(`${BASE}/admin/cohorts/new`);
const cohortName = `E2E ${Date.now()}`;
await page.getByLabel(/name/i).fill(cohortName);
await page.locator('select').first().selectOption('igcse');
await page.getByLabel(/level/i).fill('Year 1');
await page.getByLabel(/academic year/i).fill('2026');
await page.getByRole('button', { name: /Create cohort|Save cohort/i }).click();
// Should land back on list and show the new cohort
await expect(page).toHaveURL(/\/admin\/cohorts$/);
await expect(page.getByText(cohortName)).toBeVisible({ timeout: 5_000 });
});
test('principal can read cohort list but cannot see New Cohort', async ({ page }) => {
// No principal demo seed; skip if not present
const ok = await isRoleAvailable('principal');
test.skip(!ok, 'principal demo account not in seed');
await loginAs(page, 'principal');
await page.goto(`${BASE}/admin/cohorts`);
await expect(page.locator('h1, h2').first()).toBeVisible();
await expect(page.getByRole('button', { name: /New Cohort/i })).toHaveCount(0);
});
test('teacher gets 403 on the cohort list API', async ({ page }) => {
await loginAs(page, 'teacher');
const res = await page.request.get(`${API}/cohorts`);
expect(res.status()).toBe(403);
});
});
This file follows the same patterns as client/e2e/rbac.spec.ts (uses
loginAs, isRoleAvailable, base URL constants).
F. .harness/changelogs/2026-07-22-cohorts.md (new)
Brief changelog per project convention (see
.harness/changelogs/2026-07-22-user-roles.md for the template).
G. Commit + push
Single feature commit for PR 2:
- Subject:
feat(cohorts): frontend + routes + e2e (PR 2 closeout) - Body references the plan doc and lists the new files + key behaviors
- Push to
origin/feature/admin-cohorts-2026-07-22
PR 3 — Class + teacher assignment UX
Backend (additive, no schema changes)
The plan calls for 5 new endpoints, all in existing controllers. Each one is
small and audited. No new migration needed — enrollments, classes, and
subjects already have the right columns (class_teacher_id, teacher_id,
status, request_status).
| Endpoint | Controller | Notes |
|---|---|---|
POST /api/students/bulk-enroll |
students.controller.js | { studentIds, classId, academicYear } — bulk insert, status='active', request_status='approved'. Idempotent on existing rows. Wrapped in db.transaction(). Admin-only. |
POST /api/enrollments/:id/transfer |
enrollments.controller.js | { newClassId } — sets old to transferred (request_status), creates new active/approved row. Wrapped in db.transaction(). Admin/teacher. |
PUT /api/classes/:id/class-teacher |
classes.controller.js | { teacherId } — sets classes.class_teacher_id, validates teacher exists + role='teacher'. Admin-only. |
GET /api/classes/:id/teachers |
classes.controller.js | Returns form tutor + per-subject teachers. Used by ClassDetail's Teachers tab. |
POST /api/classes/:id/subjects/:subjectId/teacher |
classes.controller.js | { teacherId } — sets subjects.teacher_id. Admin-only. |
POST /api/classes?withAssignments=1 (create with assignments) |
classes.controller.js | Accepts { name, section, capacity, classTeacherId, initialStudentIds }. Wrapped in db.transaction(). Admin-only. |
Note on the transfer route: the existing
enrollments.controller.js already has a /withdraw endpoint at
POST /:id/withdraw and a /approve, /reject. The plan's "withdraw" is
already in place — no new work needed there. The new endpoints are just
transfer and the four class-side ones.
E2E for backend: covered by the frontend spec below (the happy-path tests exercise the real endpoints, not mocks).
Frontend (5 new files + 2 modified)
New files:
client/src/pages/admin/ClassDetail.tsx— 5 tabs (Overview / Roster / Teachers / Subjects / Cohorts). "Currently covering" indicator on Teachers tab uses the existingGET /api/user-roles/active-coversendpoint from PR 1.client/src/components/CreateClassWizard.tsx— multi-step modal: 1) class details, 2) form tutor, 3) initial roster (optional). On submit, callsPOST /api/classes?withAssignments=1.client/src/store/classDetail.ts— Zustand slice for class detail (parallel to the cohorts pattern).
Modified files:
client/src/pages/Classes.tsx— replace existing simple create form withCreateClassWizard. Add "View detail" button on each row →/admin/classes/:id.client/src/pages/Students.tsx— add "Bulk assign" toolbar button → modal. Add enrollment status column. Pending-approval rows with Approve/Reject.client/src/components/CohortDetail.tsx— on the Classes tab, link each class to/admin/classes/:id(cross-link to PR 3 surface).client/src/App.tsx— add/admin/classes/:idroute (admin + principal).client/src/components/Nav.tsx— add/admin/classes/:id(only meaningful when filtered by id, so this is more of a deep-link target — the main nav entry stays/classes).client/e2e/helpers/nav.ts— add/admin/classes/1toschool_admin.
File-order guidance (conventions: 5-file commit)
PR 3 ships in two commits:
- Backend commit — 5 controller changes + AuditService calls. Backend smoke test via curl.
- Frontend commit — 3 new files + 3 modified files + App.tsx + Nav + nav.ts helper.
Why split: easier to review the API contract before the UI. The backend contract is what the frontend signs against, and a coherent API surface is easier to land when reviewed in isolation.
Out of scope for PR 3 (explicit)
- Auto-creating a cohort when a class is created. Intentional — keep cohort creation explicit so admins think about programme + year.
- Cross-class bulk transfer. Single-class transfer only; bulk transfer is a follow-up.
- Subject teacher UI rewrite. The class detail's Subjects tab shows
current teachers and lets admin change them — the underlying
subjectstable is the source of truth; no separate "subject catalog" page is in scope. - Per-cohort enrollment report. A read-only view of "students in cohort X enrolled in class Y" is a useful Phase 2 dashboard, not a PR 3 deliverable.
Files to change (cumulative)
PR 2 closeout
client/src/components/CohortForm.tsx(new)client/src/App.tsx(modify — 3 routes × 3 role arms + 2 page imports + 1 component import)client/src/components/Nav.tsx(modify — 3 entries)client/e2e/helpers/nav.ts(modify — 2 entries)client/e2e/cohorts.spec.ts(new).harness/changelogs/2026-07-22-cohorts.md(new)- Commit + push
PR 3
server/src/controllers/students.controller.js(modify —POST /bulk-enroll)server/src/controllers/enrollments.controller.js(modify —POST /:id/transfer)server/src/controllers/classes.controller.js(modify — 3 new routes)client/src/pages/admin/ClassDetail.tsx(new)client/src/components/CreateClassWizard.tsx(new)client/src/store/classDetail.ts(new)client/src/pages/Classes.tsx(modify)client/src/pages/Students.tsx(modify)client/src/pages/admin/CohortDetail.tsx(modify — cross-link to /admin/classes/:id)client/src/App.tsx(modify)client/src/components/Nav.tsx(modify)client/e2e/helpers/nav.ts(modify)client/e2e/class-assignments.spec.ts(new).harness/changelogs/2026-07-22-class-assignments.md(new)- Commit + push (×2 — backend, then frontend)
Verification
npm run db:initruns cleanly on a fresh DB (idempotent)- Backend smoke: create class, assign form tutor, bulk-enroll 3 students
- Backend smoke: transfer student (old → transferred, new → active)
- Backend smoke: link subject teacher
- Frontend smoke (admin): navigate to
/admin/classes/1, see all 5 tabs, switch a form tutor, see it update - Frontend smoke (admin): bulk-assign 2 unassigned students
- Frontend smoke (principal): read-only on ClassDetail (no Edit / Change buttons; can see Roster + Teachers + Cohorts)
- Frontend smoke (teacher):
GET /api/classes/1/teachersworks for a class they teach, gets 403 for a class they don't - Portal smoke (
@smoke) for all three role arms walks the new routes without errors - Cohorts smoke (
@flow) from PR 2 still passes git log --oneline origin/feature/admin-cohorts-2026-07-22shows the three new commits (cohorts closeout, PR 3 backend, PR 3 frontend) on top of the merge commit
Order of execution
- PR 2 closeout, top to bottom (A → G). Commit + push.
- PR 3 backend (4 controller changes, all in one commit). Smoke via curl.
- PR 3 frontend (top to bottom). Commit + push.
- Run full Playwright suite (portal smokes + cohorts flow + class-assignments flow) and confirm green.
Why this order: PR 2's frontend is the smaller lift and is half-done already; shipping it unblocks the cross-link from CohortDetail to ClassDetail (PR 3). PR 3's backend is small and isolated — getting the API surface locked first lets the frontend sign against a known contract.