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

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 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):

-- 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 → 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):

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.jsPOST /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.