geocrop-platform./apps/nextgen/.harness/plans/2026-07-22-phase1-cohorts-a...

358 lines
24 KiB
Markdown

# 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)](./2026-07-22-phase2-analytics.md) · [Phase 3 — offboarding (stub)](./2026-07-22-phase3-offboarding.md) · [superseded 7-feature draft](./2026-07-22-cohorts-assignments-analytics-offboarding.md)
> **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 scope` section).
## Problem
Three concrete gaps in the admin/principal surface that block day-to-day school ops:
1. **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.
2. **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_groups` exists but is for exam *papers*, not student cohorts.
3. **Class + teacher assignment UX is fragmented.** `classes.class_teacher_id` (form tutor) and `subjects.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_roles` table + thin additive RBAC layer (new helper, JWT-embed `effective_roles`, **existing `req.user.role` checks keep working — no controller sweep in this PR**).
- New `student_cohorts` + `cohort_students` + `cohort_classes` + `cohort_exam_groups` tables + 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_students` table).
**Out of this plan (deferred to follow-up plan docs):**
- Analytics dashboard rebuild (Phase 2 — `2026-07-22-phase2-analytics.md` TBD).
- Offboarding workflows (Phase 3 — `2026-07-22-phase3-offboarding.md` TBD).
- Incremental migration of existing `req.user.role` checks to the new `hasRole` helper (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_roles` table and the JWT-embed of `effective_roles`.
- Adds the `hasRole` / `hasRoleForClass` helper to `server/src/utils/rbac.js`.
- **Leaves the existing `req.user.role` checks 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 `hasRole` helper 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`):**
```sql
-- 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):**
```js
// 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` → the `POST /:id/mark` route uses `requireRoleForClass('teacher', 'params.classId')` so a cover teacher (time-scoped `user_roles` row) 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 grants
- `POST /api/users/:id/roles` — grant (admin only, audit-logs the grant)
- `PUT /api/user-roles/:id` — update `expires_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 slice
- `client/src/pages/admin/UserRoles.tsx` — list/manage page (or section inside `Users.tsx` detail)
- `client/src/components/RoleBadge.tsx` — visual showing effective roles next to a user's name
- `client/src/components/CoverAssignmentForm.tsx` — modal for the class-cover wizard
- `client/src/components/ParentViewToggle.tsx` — the avatar-dropdown toggle
- Update `client/src/store/auth.ts` to add `effectiveRoles: string[]` + `refreshRoles()` action
- Update `client/src/components/Nav.tsx` to consider effective roles when picking the default nav (small change: the NAV_CONFIG lookup becomes "first key in `effectiveRoles` that has a config")
- Routes in `client/src/App.tsx` for the new pages; add `principal` to 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`):**
```sql
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_groups
- `POST /api/cohorts` — create (admin)
- `PUT /api/cohorts/:id` — update (admin)
- `DELETE /api/cohorts/:id` — soft delete (admin)
- `POST /api/cohorts/:id/students` — bulk add students
- `DELETE /api/cohorts/:id/students/:studentId` — remove
- `POST /api/cohorts/:id/classes` — link a class
- `DELETE /api/cohorts/:id/classes/:classId` — unlink
- `POST /api/cohorts/:id/exam-groups` — link exam paper set
- `DELETE /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.ts`
- `client/src/pages/admin/Cohorts.tsx` — list with programme + year + search filters
- `client/src/pages/admin/CohortDetail.tsx` — tabs: Overview / Students / Classes / Exam Groups
- `client/src/components/CohortForm.tsx` — create/edit modal (programme dropdown drives `level` suggestions)
- 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 with `status='active', request_status='approved'`
- `POST /api/enrollments/:id/transfer``{ newClassId }` → set old to `transferred`, create new active row in one transaction
- `POST /api/enrollments/:id/withdraw``{ reason }``status='inactive', request_status='withdrawn'`
- `PUT /api/classes/:id/class-teacher` — set `classes.class_teacher_id`
- `GET /api/classes/:id/teachers` — form tutor + subject teacher list
- `POST /api/classes/:id/subjects/:subjectId/teacher` — set `subjects.teacher_id`
- `POST /api/classes?withAssignments=1` — accepts `{ ...class, classTeacherId, initialStudentIds }`, runs all three in `db.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's `active-covers` endpoint).
- `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.js`
- `server/src/database/migrations/knex/2026072200000020_cohorts.js`
- `server/src/controllers/userRoles.controller.js`
- `server/src/controllers/cohorts.controller.js`
- `server/src/utils/rbac.js`
### Backend (modify)
- `server/src/controllers/attendance.controller.js` — one route adopts `requireRoleForClass` (the cover-teacher case)
- `server/src/controllers/classes.controller.js``?withAssignments=1` variant, `class-teacher` PUT
- `server/src/controllers/students.controller.js``POST /bulk-enroll`
- `server/src/controllers/enrollments.controller.js` — transfer, withdraw
- `server/src/services/SyncEngine.js` — append 5 new table names to `tablesToSync`
- `server/src/index.js` — register `userRoles`, `cohorts` controllers
- `server/src/controllers/auth.controller.js` (or equivalent login handler) — embed `effective_roles` in JWT, add `GET /api/auth/refresh-roles`
### Frontend (new)
- `client/src/store/userRoles.ts`
- `client/src/store/cohorts.ts`
- `client/src/pages/admin/UserRoles.tsx` (or section inside `Users.tsx`)
- `client/src/pages/admin/Cohorts.tsx`
- `client/src/pages/admin/CohortDetail.tsx`
- `client/src/pages/admin/ClassDetail.tsx`
- `client/src/components/RoleBadge.tsx`
- `client/src/components/CoverAssignmentForm.tsx`
- `client/src/components/ParentViewToggle.tsx`
- `client/src/components/CohortForm.tsx`
- `client/src/components/CreateClassWizard.tsx`
### Frontend (modify)
- `client/src/store/auth.ts` — add `effectiveRoles: string[]` + `refreshRoles()` action
- `client/src/App.tsx` — add 5 new routes per role arm; default-nav picker considers effective roles
- `client/src/components/Nav.tsx` — same
- `client/src/pages/Students.tsx` — bulk assign + status column + pending approvals
- `client/src/pages/Classes.tsx` — wire CreateClassWizard, link to ClassDetail
- `client/src/pages/admin/Users.tsx` — "Linked children" card; auxiliary role section
- `client/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 to `server/src/utils/rbac.js` and `effective_roles`
- `AGENTS.md` already updated (Knex framework note) as a side-task
## Constraints / decisions baked in
- **Naming:** `student_cohorts` (not `groups`) — explicit user decision. `student_groups` (fees) and `exam_groups` (papers) are unchanged.
- **Auxiliary role model:** one `user_roles` table, scoped (class / subject / cohort / time) or unscoped. `users.role` stays as the primary role. RBAC rewires to a small `server/src/utils/rbac.js` helper + `effective_roles` in the JWT. Existing `req.user.role` checks are NOT touched in this phase — they keep working.
- **Class covers** are a `user_roles` row with a time range and class scope. Only one controller (`attendance`) adopts the new `hasRoleForClass` check 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-roles` lets the client re-sync after a grant mutation.
- **Audit:** every user_roles grant/revoke writes an `audit_log` row via `AuditService`.
- **Reuse over new:** existing AuditService, SyncEngine, Recharts, lucide, axios. No new libraries.
- **No principal-specific portal** — principal shares `school_admin` portal 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:
1. **Migrations apply cleanly** on a fresh DB and on an existing DB. `npm run db:init` succeeds; `knex migrate:rollback` cleanly reverses each PR.
2. **Auxiliary roles (thin):**
- A teacher with an unscoped `librarian` `user_roles` row can see `/library` and `/dashboard/librarian` via the new RBAC layer.
- A `clubs_head` with a class-scoped `teacher` grant (expires_at = tomorrow) can mark attendance for that class today and cannot tomorrow — verified by an E2E spec that goes through the actual `/api/attendance` endpoint.
- Revoking a grant removes the attendance-marking ability within one client refresh (≤5s).
- JWT payload contains both `role` and `effective_roles`.
- **Existing controllers NOT touched in this PR still work** — verify with a regression sweep on the existing portal-smoke Playwright specs.
3. **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.
4. **Class + teacher assignment:**
- Admin can bulk-assign 10 unassigned students to a class; each gets a row in `enrollments` with `status='active', request_status='approved'`.
- Transferring a student creates a new active row + sets the old to `transferred` in 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.
5. **Parent-as-staff:** a teacher with a `parent_students` row to their child can toggle "view as parent" and see the parent dashboard for that child. Toggle reverts cleanly. JWT is not changed.
6. **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 `tablesToSync` sweep is clean.
7. **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.
8. **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 the `Reports.tsx` rebuild + the new `/api/reports/kpis`, `/timeseries`, `/cohorts` endpoints. Plan doc TBD.
- **Phase 3 — Offboarding workflows** (students + staff, fee/payroll blocks, archive to alumni, audit log). Owns `offboarding_records` + `offboarding_actions` tables + the `OffboardingService` pipeline. Plan doc TBD.
- **Incremental RBAC migration** — moving existing `req.user.role` checks to the new `hasRole` helper, 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.com` seed** (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.