24 KiB
Phase 1 — Auxiliary Roles (thin slice) + Cohorts + Class/Teacher Assignments
Date: 2026-07-22 (rev 2 — supersedes the earlier 7-feature plan)
Owner: Mavis (orchestrator) + backend-expert / frontend-expert / database-expert / tester reins
Branch (target): feature/admin-cohorts-2026-07-22 (worktree from dev)
Status: plan (not started)
Assignee: fchin (orchestrator — owns the foundational work; coordinate with the team as needed)
Companion plans: Phase 2 — analytics dashboard (stub) · Phase 3 — offboarding (stub) · superseded 7-feature draft
What changed from rev 1: the previous draft tried to ship 7 features (auxiliary roles, cohorts, student assign, teacher binding, class wizard, analytics dashboard, offboarding) in one plan. That was too much. This rev ships Phase 1 only — auxiliary roles as a thin slice (additive, no controller sweep), cohorts, and the class/teacher assignment UX. Analytics and offboarding are explicitly deferred to follow-up plan docs (see the
Out of scopesection).
Problem
Three concrete gaps in the admin/principal surface that block day-to-day school ops:
- No auxiliary roles. A user has exactly one
users.role. We need to express "teacher who also runs the library" or "sports coach covering a sick teacher's class for 2 weeks" without re-issuing the user a new account. - No student cohorts. Admins can't express "Form 4 IGCSE 2026" or "Grade 7 ZIMSEC" as a first-class object that links to classes, students, and the right exam paper set.
exam_groupsexists but is for exam papers, not student cohorts. - Class + teacher assignment UX is fragmented.
classes.class_teacher_id(form tutor) andsubjects.teacher_id(subject teacher) live on different pages. Creating a class and then assigning a teacher and a starter roster is two trips.
Scope
In this plan (Phase 1):
- New
user_rolestable + thin additive RBAC layer (new helper, JWT-embedeffective_roles, existingreq.user.rolechecks keep working — no controller sweep in this PR). - New
student_cohorts+cohort_students+cohort_classes+cohort_exam_groupstables + cohort admin UI. - Class detail page with tabs: Overview / Roster / Teachers / Subjects / Cohorts.
- Bulk student→class assignment modal.
- Create-class wizard that takes class details + form tutor + (optional) initial roster in one flow.
- Parent-as-staff "link my child" + "view as parent" toggle (uses existing
parent_studentstable).
Out of this plan (deferred to follow-up plan docs):
- Analytics dashboard rebuild (Phase 2 —
2026-07-22-phase2-analytics.mdTBD). - Offboarding workflows (Phase 3 —
2026-07-22-phase3-offboarding.mdTBD). - Incremental migration of existing
req.user.rolechecks to the newhasRolehelper (a separate, follow-up sweep, NOT bundled with F0). - Auto-creating a cohort when a class is created (intentional — keep cohort creation explicit so admins think about programme + year).
- Connecting offboarding to Paynow (Phase 3, when it ships).
Approach
PR 1 — Auxiliary roles (thin slice)
Why thin: every existing controller today does req.user.role === 'admin' (or similar). Rewriting them all in one PR is the kind of change that hides regressions. Instead, this PR:
- Adds the new
user_rolestable and the JWT-embed ofeffective_roles. - Adds the
hasRole/hasRoleForClasshelper toserver/src/utils/rbac.js. - Leaves the existing
req.user.rolechecks untouched — they still work, they just don't see auxiliary roles. - Adds the new management UI (admin grants/revokes auxiliary roles).
- Adds the "active covers" indicator on the class detail page so you can see "currently covering" (uses the new scope query, but the existing class-detail access check still uses the primary role).
- The first PR where the new
hasRolehelper actually replaces an old check is the class cover for take-attendance flow — that's the one place the new pattern has to land for the feature to work end-to-end. One controller, well-tested.
Schema (new Knex migration 2026072200000010_user_roles.js):
-- user_roles: auxiliary (or scoped) roles beyond users.role
CREATE TABLE user_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT NOT NULL CHECK(role IN (
'systems_admin','school_admin','teacher','student','parent','accountant',
'librarian','nurse','clubs_head','principal','hr','bursar','dining_staff',
'driver','groundsman','matron','boarding_master','security','janitor'
)),
-- Optional scope. NULL scope = auxiliary role that applies app-wide
-- (e.g. "Mrs Moyo is also a librarian"). A row with scope applies only
-- to the scoped resource (e.g. cover teacher for Form 3A until 2026-07-29).
scope_class_id INTEGER REFERENCES classes(id),
scope_subject_id INTEGER REFERENCES subjects(id),
scope_cohort_id INTEGER REFERENCES student_cohorts(id),
starts_at DATETIME,
expires_at DATETIME,
reason TEXT,
granted_by INTEGER REFERENCES users(id),
granted_at DATETIME DEFAULT (datetime('now','localtime')),
revoked_by INTEGER REFERENCES users(id),
revoked_at DATETIME,
revoke_reason TEXT,
-- standard sync columns
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
);
-- Active-grant uniqueness (one live grant per user+role+scope tuple)
CREATE UNIQUE INDEX idx_user_roles_active
ON user_roles(user_id, role,
COALESCE(scope_class_id, 0),
COALESCE(scope_subject_id, 0),
COALESCE(scope_cohort_id, 0))
WHERE revoked_at IS NULL AND is_deleted = 0;
Auth helper (server/src/utils/rbac.js — new):
// Effective roles = primary + non-revoked, non-expired user_roles
function getEffectiveRoles(user) { /* ... */ }
// Boolean check: "does this user have role X right now?"
function hasRole(user, role) { /* ... */ }
// Scoped check: "does this user have role X for class Y right now?"
function hasRoleForClass(user, classId, role) { /* ... */ }
// Middleware-style guards for express
function requireRole(role) { return (req, res, next) => ...; }
function requireRoleForClass(role, classIdFrom = 'params.id') { return (req, res, next) => ...; }
Login flow: POST /api/auth/login now embeds effective_roles: string[] in the JWT payload alongside role. The existing req.user.role is unchanged. New endpoint GET /api/auth/refresh-roles re-signs the JWT with the latest effective_roles; client calls it on app load + after every grant mutation.
Where the new helper actually lands in this PR (intentionally tiny):
server/src/controllers/attendance.controller.js→ thePOST /:id/markroute usesrequireRoleForClass('teacher', 'params.classId')so a cover teacher (time-scopeduser_rolesrow) can mark attendance for the class they're covering. That's the one place the new pattern is exercised. Every other route keeps its old check.
New controller server/src/controllers/userRoles.controller.js:
GET /api/users/:id/roles— list active + revoked grantsPOST /api/users/:id/roles— grant (admin only, audit-logs the grant)PUT /api/user-roles/:id— updateexpires_at/reason(extend or amend a cover)DELETE /api/user-roles/:id— soft-revoke (admin only, audit-logs the revoke)GET /api/user-roles/active-covers?class_id=X&date=YYYY-MM-DD— for the class detail "currently covering" indicator
Parent-as-staff UI (small, ships with PR 1): schema is already there (parent_students). Add on the user profile: "Linked children at this school" card with add/remove (admin-only write). Add a "View as parent" toggle in the user menu — appears when the user has at least one parent_students row, swaps the active dashboard to /dashboard/parent, reverts on toggle off. The underlying role doesn't change. New endpoint: GET /api/users/me/children (canonical, may shadow an existing one — verify before adding).
Frontend (5-file commit per conventions):
client/src/store/userRoles.ts— Zustand sliceclient/src/pages/admin/UserRoles.tsx— list/manage page (or section insideUsers.tsxdetail)client/src/components/RoleBadge.tsx— visual showing effective roles next to a user's nameclient/src/components/CoverAssignmentForm.tsx— modal for the class-cover wizardclient/src/components/ParentViewToggle.tsx— the avatar-dropdown toggle- Update
client/src/store/auth.tsto addeffectiveRoles: string[]+refreshRoles()action - Update
client/src/components/Nav.tsxto consider effective roles when picking the default nav (small change: the NAV_CONFIG lookup becomes "first key ineffectiveRolesthat has a config") - Routes in
client/src/App.tsxfor the new pages; addprincipalto allowed roles where appropriate
SyncEngine: append user_roles to tablesToSync after users (FK dependency). Sync-test in isolation before merging — see the verification bar.
Audit: every grant/revoke writes an audit_log row via AuditService. The user_roles table itself is also audited via the standard last_synced_at / sync_status columns.
PR 2 — Student cohorts
Why new table, not reusing exam_groups: exam_groups already means "a set of exam questions/papers" (used by exams.controller.js for attempts, schedules, etc.). Forcing it to also mean "a student cohort for ZIMSEC Grade 7" creates two unrelated meanings on the same row. Cohorts get their own table; cohorts then JOIN to exam_groups so "Form 4 IGCSE 2026 cohort" gets the right exam paper set.
Schema (new Knex migration 2026072200000020_cohorts.js):
CREATE TABLE student_cohorts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
name TEXT NOT NULL, -- "Form 4 IGCSE 2026", "Grade 7 ZIMSEC"
programme TEXT NOT NULL CHECK(programme IN ('zimsec','igcse','as','a2','primary','other')),
level TEXT, -- "Form 4", "Grade 7"
academic_year TEXT, -- "2026"
description TEXT,
start_date DATE,
end_date DATE,
is_active INTEGER DEFAULT 1,
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
);
CREATE TABLE cohort_students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
cohort_id INTEGER NOT NULL REFERENCES student_cohorts(id),
student_id INTEGER NOT NULL REFERENCES users(id),
joined_at DATETIME DEFAULT (datetime('now','localtime')),
status TEXT DEFAULT 'active' CHECK(status IN ('active','inactive','transferred','graduated')),
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,
UNIQUE(cohort_id, student_id)
);
CREATE TABLE cohort_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
cohort_id INTEGER NOT NULL REFERENCES student_cohorts(id),
class_id INTEGER NOT NULL REFERENCES classes(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,
UNIQUE(cohort_id, class_id)
);
CREATE TABLE cohort_exam_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
cohort_id INTEGER NOT NULL REFERENCES student_cohorts(id),
exam_group_id INTEGER NOT NULL REFERENCES exam_groups(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,
UNIQUE(cohort_id, exam_group_id)
);
ALTER TABLE enrollments ADD COLUMN cohort_id INTEGER REFERENCES student_cohorts(id);
enrollments.cohort_id is nullable; existing rows stay NULL until manually backfilled or carried forward on next academic rollover.
New controller server/src/controllers/cohorts.controller.js:
GET /api/cohorts— list, with?programme=,?academic_year=,?is_active=GET /api/cohorts/:id— detail + students + classes + exam_groupsPOST /api/cohorts— create (admin)PUT /api/cohorts/:id— update (admin)DELETE /api/cohorts/:id— soft delete (admin)POST /api/cohorts/:id/students— bulk add studentsDELETE /api/cohorts/:id/students/:studentId— removePOST /api/cohorts/:id/classes— link a classDELETE /api/cohorts/:id/classes/:classId— unlinkPOST /api/cohorts/:id/exam-groups— link exam paper setDELETE /api/cohorts/:id/exam-groups/:examGroupId— unlink
Auth: read = school_admin | systems_admin | principal. Write = admin. The hasRole helper from PR 1 is used here for the read paths (so an admin can also grant "principal can read cohorts" via auxiliary role without code change).
SyncEngine: append student_cohorts, cohort_students, cohort_classes, cohort_exam_groups to tablesToSync in dependency order. Sync-test in isolation.
Frontend (5-file commit per conventions):
client/src/store/cohorts.tsclient/src/pages/admin/Cohorts.tsx— list with programme + year + search filtersclient/src/pages/admin/CohortDetail.tsx— tabs: Overview / Students / Classes / Exam Groupsclient/src/components/CohortForm.tsx— create/edit modal (programme dropdown driveslevelsuggestions)- Routes + Nav entries for the three admin-level roles
PR 3 — Class + teacher assignment UX
Backend (small, additive):
POST /api/students/bulk-enroll—{ studentIds: number[], classId: number, academicYear: string }→ bulk insert withstatus='active', request_status='approved'POST /api/enrollments/:id/transfer—{ newClassId }→ set old totransferred, create new active row in one transactionPOST /api/enrollments/:id/withdraw—{ reason }→status='inactive', request_status='withdrawn'PUT /api/classes/:id/class-teacher— setclasses.class_teacher_idGET /api/classes/:id/teachers— form tutor + subject teacher listPOST /api/classes/:id/subjects/:subjectId/teacher— setsubjects.teacher_idPOST /api/classes?withAssignments=1— accepts{ ...class, classTeacherId, initialStudentIds }, runs all three indb.transaction()
Frontend:
client/src/pages/Students.tsx— add "Bulk assign" button → modal with searchable multi-select + class picker. Add enrollment-status column. Pending-approval row with Approve / Reject (admin).client/src/pages/admin/ClassDetail.tsx(new) — tabs: Overview / Roster / Teachers / Subjects / Cohorts. "Currently covering" indicator on Teachers tab (uses PR 1'sactive-coversendpoint).client/src/pages/Classes.tsx— wire the create-class wizard; "View detail" link on each row → ClassDetail.client/src/components/CreateClassWizard.tsx(new) — extends existing create modal with optional teacher + initial-roster tab.
Auth: same as PR 2. No new hasRole adoption in this PR — old req.user.role checks stay for write paths.
Files to add / change
Backend (new)
server/src/database/migrations/knex/2026072200000010_user_roles.jsserver/src/database/migrations/knex/2026072200000020_cohorts.jsserver/src/controllers/userRoles.controller.jsserver/src/controllers/cohorts.controller.jsserver/src/utils/rbac.js
Backend (modify)
server/src/controllers/attendance.controller.js— one route adoptsrequireRoleForClass(the cover-teacher case)server/src/controllers/classes.controller.js—?withAssignments=1variant,class-teacherPUTserver/src/controllers/students.controller.js—POST /bulk-enrollserver/src/controllers/enrollments.controller.js— transfer, withdrawserver/src/services/SyncEngine.js— append 5 new table names totablesToSyncserver/src/index.js— registeruserRoles,cohortscontrollersserver/src/controllers/auth.controller.js(or equivalent login handler) — embedeffective_rolesin JWT, addGET /api/auth/refresh-roles
Frontend (new)
client/src/store/userRoles.tsclient/src/store/cohorts.tsclient/src/pages/admin/UserRoles.tsx(or section insideUsers.tsx)client/src/pages/admin/Cohorts.tsxclient/src/pages/admin/CohortDetail.tsxclient/src/pages/admin/ClassDetail.tsxclient/src/components/RoleBadge.tsxclient/src/components/CoverAssignmentForm.tsxclient/src/components/ParentViewToggle.tsxclient/src/components/CohortForm.tsxclient/src/components/CreateClassWizard.tsx
Frontend (modify)
client/src/store/auth.ts— addeffectiveRoles: string[]+refreshRoles()actionclient/src/App.tsx— add 5 new routes per role arm; default-nav picker considers effective rolesclient/src/components/Nav.tsx— sameclient/src/pages/Students.tsx— bulk assign + status column + pending approvalsclient/src/pages/Classes.tsx— wire CreateClassWizard, link to ClassDetailclient/src/pages/admin/Users.tsx— "Linked children" card; auxiliary role sectionclient/src/pages/parent/ParentDashboard.tsx(or wherever the user lands on "view as parent") — verify the toggle re-uses it correctly
Docs / changelogs (commit per conventions)
.harness/changelogs/2026-07-22-user-roles.md.harness/changelogs/2026-07-22-cohorts.md.harness/changelogs/2026-07-22-assignments.md- Update
.harness/docs/conventions.md— replace the auth/RBAC role-list line with a reference toserver/src/utils/rbac.jsandeffective_roles AGENTS.mdalready updated (Knex framework note) as a side-task
Constraints / decisions baked in
- Naming:
student_cohorts(notgroups) — explicit user decision.student_groups(fees) andexam_groups(papers) are unchanged. - Auxiliary role model: one
user_rolestable, scoped (class / subject / cohort / time) or unscoped.users.rolestays as the primary role. RBAC rewires to a smallserver/src/utils/rbac.jshelper +effective_rolesin the JWT. Existingreq.user.rolechecks are NOT touched in this phase — they keep working. - Class covers are a
user_rolesrow with a time range and class scope. Only one controller (attendance) adopts the newhasRoleForClasscheck in this PR — the rest migrate in follow-up PRs. - No sync rewrite — append new tables. Each new table is sync-tested in isolation before being added to
tablesToSync. - No migration framework upgrade — continue with Knex migrations.
- PWA offline: all new screens work offline through the existing sync contract. JWT carries a snapshot of effective roles;
/api/auth/refresh-roleslets the client re-sync after a grant mutation. - Audit: every user_roles grant/revoke writes an
audit_logrow viaAuditService. - Reuse over new: existing AuditService, SyncEngine, Recharts, lucide, axios. No new libraries.
- No principal-specific portal — principal shares
school_adminportal with role-scoped data, per existing convention. - No analytics, no offboarding in this plan. Defer to Phase 2 and Phase 3 plan docs.
Verification (acceptance bar)
This plan is "done" when all of the following pass:
- Migrations apply cleanly on a fresh DB and on an existing DB.
npm run db:initsucceeds;knex migrate:rollbackcleanly reverses each PR. - Auxiliary roles (thin):
- A teacher with an unscoped
librarianuser_rolesrow can see/libraryand/dashboard/librarianvia the new RBAC layer. - A
clubs_headwith a class-scopedteachergrant (expires_at = tomorrow) can mark attendance for that class today and cannot tomorrow — verified by an E2E spec that goes through the actual/api/attendanceendpoint. - Revoking a grant removes the attendance-marking ability within one client refresh (≤5s).
- JWT payload contains both
roleandeffective_roles. - Existing controllers NOT touched in this PR still work — verify with a regression sweep on the existing portal-smoke Playwright specs.
- A teacher with an unscoped
- Cohorts: admin can create a cohort "Form 4 IGCSE 2026", link a class, link 30 students, link 1 exam_group. After creation, the cohort detail page shows the right counts. Soft-delete hides the cohort from default lists but keeps history.
- Class + teacher assignment:
- Admin can bulk-assign 10 unassigned students to a class; each gets a row in
enrollmentswithstatus='active', request_status='approved'. - Transferring a student creates a new active row + sets the old to
transferredin one transaction. - On the class detail, admin can set the form tutor AND change a subject teacher, both persist, both show in the class list.
- The "currently covering" indicator on the class detail shows the active cover teacher with their expiry date.
- Create-class wizard: admin creates "Form 1B" with form tutor "Mrs Moyo" and 0 students in one go; all three pieces land in the DB.
- Admin can bulk-assign 10 unassigned students to a class; each gets a row in
- Parent-as-staff: a teacher with a
parent_studentsrow to their child can toggle "view as parent" and see the parent dashboard for that child. Toggle reverts cleanly. JWT is not changed. - Sync: each new table sync-tests green in isolation (push 100 rows from local → Supabase, pull 100 back, verify no FK errors and no row drift). After all 5 are added, the full
tablesToSyncsweep is clean. - E2E (Playwright): one spec per major flow (auxiliary role grant, class cover, cohort create, bulk assign, view-as-parent toggle). Existing portal-smoke tests still pass with zero regression.
- No regression: every existing admin/principal dashboard, the exam-review module, and the finance module still load and respond.
Execution order (this plan only)
PR 1: auxiliary roles (thin) ─┬─► PR 2: cohorts
└─► PR 3: class + teacher assignment UX
PR 1 is foundational. PR 2 and PR 3 can be developed in parallel after PR 1 merges, but only one worktree at a time per developer (per the project's solo-dev convention).
Each PR: branch from dev, worktree at .worktrees/<name>/, conventional commit messages, changelog entry, E2E spec.
Out of scope (deferred)
These are explicitly not in this plan. Each gets its own plan doc when it's time:
- Phase 2 — Analytics dashboard rebuild (
/reports→ KPI tiles, time-series, cohort pass-rate drilldown, CSV export, principal access). Owns theReports.tsxrebuild + the new/api/reports/kpis,/timeseries,/cohortsendpoints. Plan doc TBD. - Phase 3 — Offboarding workflows (students + staff, fee/payroll blocks, archive to alumni, audit log). Owns
offboarding_records+offboarding_actionstables + theOffboardingServicepipeline. Plan doc TBD. - Incremental RBAC migration — moving existing
req.user.rolechecks to the newhasRolehelper, one controller at a time, with regression tests per controller. Tracked as a separate sweep, not bundled with PR 1. - Auto-creating a cohort when a class is created. Intentional non-goal: keep cohort creation explicit so admins think about programme and year.
- Connecting offboarding to Paynow (deferred with Phase 3).
- Replacing the SyncEngine with anything smarter. Append-only.
- Tightening JWT secret, CORS, request-size limit — separate open follow-ups; not this plan.
- Auto-creating the missing demo
sysadmin@school.comseed (mentioned in the 2026-07-19 portal-e2e plan as a one-line seed addition). Trivial; do it as a side-task in PR 1 if it's nearby, otherwise leave for a separate cleanup.