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

558 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [SUPERSEDED 2026-07-22] Auxiliary Roles + Cohorts + Class/Teacher Assignments + Analytics Dashboard + Offboarding
> **Superseded by the three-phase plan set.** This document is kept for paper trail only — do NOT execute against it.
>
> - **Phase 1** (active plan, ready to ship) — [auxiliary roles (thin slice) + cohorts + class/teacher assignments](./2026-07-22-phase1-cohorts-assignments.md)
> - **Phase 2** (stub, details TBD) — [analytics dashboard rebuild](./2026-07-22-phase2-analytics.md)
> - **Phase 3** (stub, details TBD) — [offboarding workflows](./2026-07-22-phase3-offboarding.md)
>
> Reasons for splitting:
> 1. Scope bloat — 7 features in one plan, even with phased execution, is too much for one brain.
> 2. Auth-layer risk — "touch every controller" is the kind of line that hides regressions. Thin-slice the auxiliary-roles change so existing checks keep working, migrate controllers incrementally in follow-up PRs.
> 3. Sync engine complexity — appending 57 new tables with FK relationships to a server-wins sync engine is a chunk of work in its own right; better to ship Phase 1 first and validate the sync process before scaling up.
>
> See the new plan docs for the executable details.
**Date:** 2026-07-22
**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)
## Problem
Seven gaps in the admin/principal surface that block day-to-day school ops:
1. **No auxiliary roles.** A user has exactly one `users.role` (CHECK constraint enforces it). We need to express "teacher who also runs the library", "sports coach covering a sick teacher's class for 2 weeks", and "teacher whose own child learns at this school". Today all three are either impossible or hacky.
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. **Student → class assignment is half-built.** `Students.tsx` has a form but the UX is bare; the existing `enrollments.request_status` workflow is rarely exercised.
4. **Teacher → class binding is split.** `classes.class_teacher_id` (form tutor) and `subjects.teacher_id` (subject teacher) live on different pages. Admins can't manage both in one place.
5. **Class creation doesn't include assignment.** Creating a class is one step; assigning a teacher and a starter roster is a second trip.
6. **Reports page is a single screen, not a dashboard.** The principal (who shares the same portal as `school_admin`) needs real analytics — KPI tiles, time-series, drilldowns, filters, exports.
7. **No offboarding flow.** We have `users.is_active` and `enrollments.status` but no end-to-end process: deactivate user, close enrollments/positions, settle fees/payroll, archive to alumni/former-staff, audit log.
## Scope (in / out)
**In this plan:**
- **Auxiliary roles + class covers + parent-as-staff UI** (foundational; lands first).
- New `student_cohorts` table + cohorts page (admin + principal).
- Improve student→class assignment UX (batch assign, search/filter, status workflow).
- Class detail page: assign form tutor + subject teachers in one place.
- Create-class modal with optional initial teacher + cohort selection.
- Rewrite `Reports.tsx` as a full analytics dashboard; add `principal` to the allowed roles for `/reports`.
- Offboarding flow for students AND staff (full scope, with audit + audit log).
- New tables: `user_roles`, `student_cohorts`, `cohort_students`, `cohort_classes`, `cohort_exam_groups`, `offboarding_records`, `offboarding_actions`.
**Out of this plan (open follow-ups, ask first):**
- Replacing the SyncEngine mapping (each new table will be appended to `tablesToSync` in dependency order — that's in-scope, but a full sync rewrite is not).
- BI-grade export (PDF, scheduled email) — we ship CSV only this round.
- Finance-side "student_groups" rename or merge. `student_groups` (fees) stays as-is; cohorts are a separate concept.
- Migration framework (still `CREATE TABLE IF NOT EXISTS` only — adding a new Knex migration is the convention).
## Approach
### Feature 0 — Auxiliary roles, class covers, parent-as-staff UI *(foundational; lands first)*
**Why this is foundational:** every other feature in this plan needs to know "what can this user do, and for which resources?". If we build cohorts / offboarding / analytics first, we'd either have to retro-fit the auth layer to consider auxiliary roles, or ship the rest with the old single-role model and refactor later. Land this first; everything else rides on it.
**Schema (new Knex migration `2026072200000000_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, -- "covering sick leave", "acting librarian"
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 index: a user can hold the same role+scope pair only while the prior
-- grant is revoked. Enforce at app layer; the index is the safety net.
CREATE UNIQUE INDEX idx_user_roles_active
ON user_roles(user_id, role, scope_class_id, scope_subject_id, scope_cohort_id)
WHERE revoked_at IS NULL AND is_deleted = 0;
```
`users.role` stays as the **primary** role — it's still used for default landing page, sync identity, and as the anchor for any role that doesn't need a scope. `user_roles` carries everything else.
**Auth + RBAC rewiring:**
The current `auth` middleware in each controller only knows `req.user.role`. We add a small helper module `server/src/utils/rbac.js`:
```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?"
// - Primary 'teacher' role applies to all classes they teach
// - Scoped user_roles with scope_class_id=Y count for that class only
// - Unscoped user_roles (scope=NULL) apply app-wide
function hasRoleForClass(user, classId, role) { /* ... */ }
```
All existing `req.user.role !== 'teacher'` style checks in controllers get rewritten as `!hasRole(req.user, 'teacher')`. Same for `ProtectedRoute` on the client.
**Login flow change:** when a user logs in (`POST /api/auth/login`), the server reads `users.role` + active `user_roles` rows, embeds `{ id, role, effective_roles: [...] }` into the JWT payload. The 7-day expiry means a freshly-granted role won't be visible until the user's next login — to soften that, we expose `GET /api/auth/refresh-roles` which re-reads and re-signs. The client calls it on app load and after every grant/revoke mutation.
**Class covers (time-scoped auxiliary role):** when an admin covers a teacher, the wizard inserts a `user_roles` row with `role='teacher'`, `scope_class_id=X`, `starts_at=now`, `expires_at=now+14d`, `reason='Covering Mrs Nkomo (sick leave) - ticket #123'`. RBAC for "take attendance for class X" then matches both the regular class teacher and any active cover row. The cover row auto-expires (the RBAC check filters `WHERE expires_at > datetime('now')`); we don't need a cron job, but a daily cleanup task prunes expired rows for hygiene.
**Parent-as-staff UI (separate small PR, but ships with Feature 0):** the schema is already there (`parent_students` table). The work is:
- On the user profile page: a "Linked children at this school" card with add/remove (admin-only write; a user can request a link from their own profile).
- A "View as parent" toggle in the user menu (top-right avatar dropdown) that appears whenever the user has at least one `parent_students` row. The toggle swaps the active dashboard view to `/dashboard/parent` and filters the side nav to the parent role; the underlying role is unchanged (they're still a teacher, just viewing the parent side). When toggled off, they go back to their primary-role dashboard.
- One new endpoint: `GET /api/users/me/children` returns the children linked via `parent_students`. Already partly served by an existing route; this is the canonical one.
**New controller `server/src/controllers/userRoles.controller.js`:**
- `GET /api/users/:id/roles` — list all grants (active + revoked) for a user
- `POST /api/users/:id/roles` — body: `{ role, scope_class_id?, scope_subject_id?, scope_cohort_id?, starts_at?, expires_at?, reason }` — 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 (`revoked_at`, `revoked_by`); admin only, audit-logs the revoke
- `GET /api/user-roles/active-covers?class_id=X&date=YYYY-MM-DD` — used by the class detail page to show "currently covering: Mr Moyo (until 2026-07-29)"
- `GET /api/auth/refresh-roles` — re-signs the JWT with the latest effective roles (called by the client on app load + after grant mutations)
**Frontend (per conventions checklist, 5-file commit):**
- `client/src/store/userRoles.ts` — Zustand slice
- `client/src/pages/admin/UserRoles.tsx` — list/manage page (or a section inside Users.tsx detail)
- `client/src/components/RoleBadge.tsx` — small 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[]` to the user shape, and re-compute on `refreshRoles()`
- Update `client/src/App.tsx` `ProtectedRoute` to check `effective_roles` instead of `user.role`
- Update `client/src/components/Nav.tsx` to consider effective roles when picking the default nav
**Audit:** every grant, update, and revoke writes an `audit_log` row via `AuditService` (already in repo). One row per action. The new `user_roles` table itself is also audited via the standard `last_synced_at` / `sync_status` columns for the offline-sync trail.
**SyncEngine:** append `user_roles` to `tablesToSync` after `users` (FK dependency).
**Verification (this feature, isolated):**
- A teacher with `user_roles` row for `librarian` (scope=NULL) can see `/library` and `/dashboard/librarian` without losing teacher access.
- A `clubs_head` with a `user_roles` row for `teacher` scoped to `class 5A`, `expires_at = tomorrow` can take attendance for class 5A today but not tomorrow, and cannot take attendance for class 5B.
- Revoking a grant makes the route inaccessible within one client refresh (≤5s).
- The parent-as-staff toggle moves the user to the parent dashboard, links to the right child, and reverts on toggle off.
- JWT inspection: payload contains both `role` and `effective_roles`.
### Feature 1 — 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" would create 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 `2026072200000001_cohorts.js`):**
```sql
-- student_cohorts: the academic-programme grouping
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,
-- 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
);
-- cohort_students: M:N cohort <-> student
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')),
-- 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,
UNIQUE(cohort_id, student_id)
);
-- cohort_classes: M:N cohort <-> class
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),
-- 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,
UNIQUE(cohort_id, class_id)
);
-- cohort_exam_groups: M:N cohort <-> exam_groups (which exam papers this cohort takes)
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),
-- 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,
UNIQUE(cohort_id, exam_group_id)
);
```
**`enrollments.cohort_id` add-on:** add a nullable `cohort_id INTEGER REFERENCES student_cohorts(id)` to `enrollments` so each enrollment row can carry the cohort the student is in *for that academic year*. Existing rows: `cohort_id = NULL` (acceptable — old data, will be backfilled from `student_cohorts` + `cohort_students` opportunistically, or set manually on next 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
- `PUT /api/cohorts/:id` — update (admin only for programme/level/year; everyone in role list can update description)
- `DELETE /api/cohorts/:id` — soft delete (`is_deleted = 1`)
- `POST /api/cohorts/:id/students` — bulk add students
- `DELETE /api/cohorts/:id/students/:studentId` — remove student
- `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:** `school_admin` + `systems_admin` + `principal` for read; create/update/delete admin-only. Audit on every write (`AuditService` already exists).
**SyncEngine:** append `student_cohorts`, `cohort_students`, `cohort_classes`, `cohort_exam_groups` to `tablesToSync` in dependency order (parents first).
**Frontend (per conventions checklist, 5-file commit):**
- `client/src/store/cohorts.ts` — Zustand slice
- `client/src/pages/admin/Cohorts.tsx` — list (programme filter, academic-year filter, search)
- `client/src/pages/admin/CohortDetail.tsx` — detail with tabs: Overview / Students / Classes / Exam Groups
- `client/src/components/CohortForm.tsx` — create/edit modal (programme dropdown drives `level` suggestions: ZIMSEC → ECD-A-B, Grade 1-7, Form 1-6; IGCSE → Year 1-2 / IGCSE; AS → Form 5; A2 → Form 6)
- Routes in `App.tsx` for `school_admin`, `systems_admin`, `principal`
- Nav entries in `Nav.tsx` for the same three roles
### Feature 2 — Student → Class assignment (enhance)
**Backend:** no new tables. Reuse `enrollments` with the existing `request_status` workflow. Add two new endpoints:
- `POST /api/students/bulk-enroll` — accept `{ studentIds: number[], classId: number, academicYear: string }`, insert with `status='active', request_status='approved'`, return rows created
- `POST /api/enrollments/:id/transfer` — accept `{ newClassId }`, set old enrollment to `status='transferred'`, create new active row in one transaction. Used by offboarding too.
- `POST /api/enrollments/:id/withdraw` — accept `{ reason }`, set `status='inactive', request_status='withdrawn'`
**Frontend (`client/src/pages/Students.tsx`):**
- Keep existing single-student form. Add a "Bulk assign" button that opens a modal:
- Left: searchable, multi-select student list (filter by "no current enrollment" to focus on unassigned)
- Right: target class picker (with cohort context if a cohort is selected first)
- Footer: "Assign N students to {class name} for {academic year}"
- Show an enrollment-status column in the student table (active / pending / transferred / graduated)
- Pending enrollments get an Approve / Reject button row (only admins) — uses the existing `enrollments.request_status` workflow
### Feature 3 — Teacher → Class binding (class tutor + subject teacher in one place)
**Backend:** new endpoints (no new tables):
- `PUT /api/classes/:id/class-teacher` — set `classes.class_teacher_id`
- `GET /api/classes/:id/teachers` — return form tutor + subject teacher list
- `POST /api/classes/:id/subjects/:subjectId/teacher` — set `subjects.teacher_id` (UPSERT semantics)
**Frontend (`client/src/pages/admin/ClassDetail.tsx` — new):**
- Tabs: Overview / Roster / Teachers / Subjects / Cohorts
- "Teachers" tab: form-tutor dropdown (single), then a table of subjects with a per-row teacher dropdown. Edits inline. Reuses `subjects` table.
- Accessible to `school_admin`, `systems_admin`, `principal`
- Add a "Quick assign" link from the existing `Classes.tsx` list rows to jump to this detail
### Feature 4 — Create class + assign in one flow
**Frontend (`client/src/pages/Classes.tsx` create modal — extend existing):**
- Tab 1 "Class details": name, section, capacity, academic year, **cohort picker** (optional)
- Tab 2 "Assign teacher": form-tutor dropdown + "skip for now" checkbox
- Tab 3 "Roster": optional bulk-import of students via the same multi-select used in Feature 2 (admins rarely use this, so it's collapsed by default)
- "Create" button submits in one go: `POST /api/classes` + `PUT /api/classes/:id/class-teacher` + bulk-enroll in a single transaction on the server
**Backend:** add a server-side `POST /api/classes` variant `?withAssignments=1` that accepts `{ ...class, classTeacherId, initialStudentIds }` and runs all three steps in `db.transaction()`. Keep the old simple endpoint untouched for the "just a class" case.
### Feature 5 — Full analytics dashboard
**Why this is more than a UI rebuild:** the existing Reports.tsx renders 45 charts but is one screen with one filter. A real dashboard is a small SPA — KPI tiles, tabbed sections, drilldown dialogs, role-scoped data.
**Backend (`server/src/controllers/reports.controller.js` — extend, not replace):**
Add 4 new endpoints, all gated by `auth + (school_admin | systems_admin | principal)`:
- `GET /api/reports/kpis?range=6m` — returns:
- `enrollments: { total, active, newThisTerm, byProgramme: { zimsec: N, igcse: N, ... } }`
- `attendance: { rate30d, rate7d, trend: 'up'|'down'|'flat' }`
- `academics: { avgMark, passRate, topSubject, bottomSubject }`
- `finance: { collected30d, outstanding, overdueCount }`
- `staff: { activeTeachers, onLeave, pendingApprovals }`
- `GET /api/reports/timeseries?metric=enrollments|attendance|marks|attendance&range=6m&granularity=week|month` — returns time-series points
- `GET /api/reports/cohorts` — returns per-cohort performance: `{ cohortId, name, programme, studentCount, avgMark, passRate, attendanceRate, feeCollectionRate }`
- `GET /api/reports/export?type=...&format=csv` — server-side CSV streaming, uses the same queries as the JSON endpoints
The existing `/api/reports/summary` and `/api/reports/dashboard` endpoints stay — they're consumed elsewhere.
**Frontend (`client/src/pages/admin/Reports.tsx` — rebuild):**
- Header: time-range picker (7d / 30d / 90d / 6m / YTD / custom), cohort filter, programme filter, class filter
- KPI tile row (5 tiles matching the backend shape) with sparklines
- Tab 1 "Overview" — enrollment area chart, attendance line, fee collection stacked bar
- Tab 2 "Academics" — grade distribution, subject performance, top/bottom classes
- Tab 3 "Cohorts" — per-cohort table with the metrics from `/api/reports/cohorts`; click row → drilldown modal with that cohort's students ranked
- Tab 4 "Finance" — only available to roles that have finance access (bursar, principal, school_admin, systems_admin)
- Tab 5 "Staff" — teachers on leave, pending approvals, staff count by department
- Export button (top right): CSV per tab
- All charts already use Recharts (in repo); no new chart lib
**Routing:** add `principal` (and `bursar`, `hr` where finance/staff tabs apply) to the `/reports` route in every `getRoutes()` arm of `App.tsx`. Right now `/reports` is gated `['school_admin']` only in some role blocks.
**Performance:** SQLite + WAL is fine for the time-series queries at the size this school runs. Each KPI query should be a single SQL statement with one index scan. If a query takes >500ms, add a covering index. Don't introduce a cache this round.
### Feature 6 — Offboarding (students + staff, full)
**Schema (new Knex migration `2026072200000002_offboarding.js`):**
```sql
-- offboarding_records: one row per offboarding case
CREATE TABLE offboarding_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id),
audience TEXT NOT NULL CHECK(audience IN ('student','staff')),
reason TEXT CHECK(reason IN ('graduated','transferred','withdrawn','expelled','resigned','terminated','retired','deceased','other')),
reason_notes TEXT,
initiated_by INTEGER REFERENCES users(id),
status TEXT DEFAULT 'in_progress' CHECK(status IN ('in_progress','completed','cancelled')),
effective_date DATE NOT NULL,
destination TEXT, -- "Hillside High" for transfers; "n/a" for resigned
alumni_status TEXT, -- students: 'active_alumni' | 'inactive_alumni' | NULL
-- 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
);
-- offboarding_actions: per-step audit trail (one row per action taken)
CREATE TABLE offboarding_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
record_id INTEGER NOT NULL REFERENCES offboarding_records(id),
step TEXT NOT NULL, -- 'enrollment_closed','hostel_released','transport_removed','fee_settled','leave_closed','payroll_finalized','account_deactivated','record_archived'
status TEXT NOT NULL CHECK(status IN ('pending','completed','skipped','failed')),
notes TEXT,
performed_by INTEGER REFERENCES users(id),
performed_at DATETIME DEFAULT (datetime('now','localtime')),
-- 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
);
```
**Student offboarding steps** (each becomes a row in `offboarding_actions`):
1. Close active enrollments: `enrollments.status = 'transferred' | 'graduated' | 'inactive'` (chosen by reason)
2. Release hostel: `room_assignments.status = 'inactive'`
3. Remove from transport routes
4. Remove from clubs (if applicable)
5. Settle fees: block if `student_fees.outstanding > 0` UNLESS admin overrides with reason
6. Set `users.is_active = 0`
7. Archive the user record: write `alumni_status` (active_alumni / inactive_alumni)
8. Audit log entry
**Staff offboarding steps:**
1. Close HR position: `users.role` unchanged, but set `is_active = 0`
2. Close pending leave: any `leave_requests` with `status='pending'``cancelled`
3. Settle payroll: block if last `payroll_runs` for this staff has unpaid line items UNLESS override
4. Revoke class teacher / subject teacher assignments (`classes.class_teacher_id = NULL`, `subjects.teacher_id = NULL`)
5. Set `users.is_active = 0`
6. Archive: mark `archive_status = 'former_staff'` (new column on `users`)
7. Audit log entry
**Backend (`server/src/controllers/offboarding.controller.js` — new):**
- `POST /api/offboarding/students/:userId` — body: `{ reason, effectiveDate, destination?, reasonNotes? }` → runs student pipeline, returns the record + actions
- `POST /api/offboarding/staff/:userId` — body: `{ reason, effectiveDate, reasonNotes? }` → runs staff pipeline
- `GET /api/offboarding/records?audience=student|staff&status=in_progress|completed` — list
- `GET /api/offboarding/records/:id` — detail with actions
- `POST /api/offboarding/records/:id/cancel` — re-open if not yet completed
- `POST /api/offboarding/records/:id/override` — admin override for the fee/payroll block, with mandatory reason
- `GET /api/offboarding/alumni?status=active_alumni&cohort_id=` — alumni directory (for principal review)
All endpoints run inside a single `db.transaction()`. Any step failure rolls back the whole offboarding. The `offboarding_actions` rows are written in the same transaction so the audit is always consistent with the state.
**Frontend:**
- `client/src/pages/admin/Offboarding.tsx` — list of in-progress + recent offboardings
- `client/src/pages/admin/OffboardingWizard.tsx` — multi-step wizard (Reason → Effective date → Checklist of steps → Confirm). For staff: HR + finance steps visible. For student: hostel/transport/fees steps visible.
- `client/src/pages/admin/Alumni.tsx` — searchable alumni directory (read-only, principal can also see this)
- Add "Offboard" button on the student detail (Users.tsx) and on the staff directory (`/hr/staff`) — admin/principal only
**Auth:** initiate/complete = `school_admin | systems_admin | principal`. View records = same. Override = `school_admin | systems_admin` only.
## Files to add / change (summary)
### Backend (new)
- `server/src/database/migrations/knex/2026072200000000_user_roles.js``user_roles` table + active-grant unique index
- `server/src/database/migrations/knex/2026072200000001_cohorts.js` — 4 cohort tables + `enrollments.cohort_id` ALTER
- `server/src/database/migrations/knex/2026072200000002_offboarding.js` — 2 offboarding tables + `users.archive_status` ALTER
- `server/src/database/migrations/knex/2026072200000003_users_archive_status.js` — if the previous migration can't be split cleanly; combine if simpler
- `server/src/controllers/userRoles.controller.js`
- `server/src/controllers/cohorts.controller.js`
- `server/src/controllers/offboarding.controller.js`
- `server/src/services/OffboardingService.js` — the pipeline logic (testable in isolation)
- `server/src/utils/rbac.js``getEffectiveRoles`, `hasRole`, `hasRoleForClass` helpers
### Backend (modify)
- `server/src/controllers/classes.controller.js``?withAssignments=1` variant on `POST /`
- `server/src/controllers/students.controller.js``POST /bulk-enroll`
- `server/src/controllers/enrollments.controller.js``POST /:id/transfer`, `POST /:id/withdraw`
- `server/src/controllers/reports.controller.js` — add `kpis`, `timeseries`, `cohorts`, `export` endpoints
- `server/src/services/SyncEngine.js` — append 7 new table names to `tablesToSync` in dependency order (users first, then user_roles, then the rest)
- `server/src/index.js` — register `userRoles`, `cohorts`, `offboarding` controllers
- `server/src/controllers/auth.controller.js` (or equivalent login handler) — embed `effective_roles` in JWT payload, add `GET /api/auth/refresh-roles`
- Every existing controller — replace `req.user.role !== X` with `!hasRole(req.user, X)`, and where class-scoped, with `!hasRoleForClass(req.user, classId, X)`
### Frontend (new)
- `client/src/store/userRoles.ts`
- `client/src/store/cohorts.ts`
- `client/src/store/offboarding.ts`
- `client/src/pages/admin/UserRoles.tsx` — list/manage page (or section inside Users.tsx detail)
- `client/src/pages/admin/Cohorts.tsx`
- `client/src/pages/admin/CohortDetail.tsx`
- `client/src/pages/admin/ClassDetail.tsx` — class detail with teacher assignments (incl. active covers)
- `client/src/pages/admin/Offboarding.tsx`
- `client/src/pages/admin/OffboardingWizard.tsx`
- `client/src/pages/admin/Alumni.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` — extends existing `Classes.tsx` modal
### Frontend (modify)
- `client/src/store/auth.ts` — add `effectiveRoles: string[]` to user shape, `refreshRoles()` action
- `client/src/App.tsx``ProtectedRoute` checks `effective_roles`; add 5 new routes per role arm, add `principal` (and `bursar`/`hr` where appropriate) to `/reports`
- `client/src/components/Nav.tsx` — add Nav entries; default-nav picker considers effective roles
- `client/src/pages/admin/Reports.tsx` — full rebuild
- `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` — add "Offboard" button on student detail; "Linked children" card; add auxiliary role section
- `client/src/pages/admin/HRManagement.tsx` — add "Offboard" button on staff detail
- `client/src/pages/hr/StaffDirectory.tsx` — same
- `client/src/pages/parent/ParentDashboard.tsx` (or wherever the user lands on "view as parent") — verify the view-as-parent toggle re-uses it correctly
- `client/src/store/api.ts` — no changes (single axios instance stays as-is)
### Docs / changelogs (commit per conventions)
- `.harness/changelogs/2026-07-22-user-roles.md` — F0
- `.harness/changelogs/2026-07-22-cohorts.md`
- `.harness/changelogs/2026-07-22-assignments.md`
- `.harness/changelogs/2026-07-22-analytics-dashboard.md`
- `.harness/changelogs/2026-07-22-offboarding.md`
- Update `.harness/AGENTS.md` — note `student_cohorts` is the canonical ZIMSEC/IGCSE/etc. concept; note the `user_roles` auxiliary-role pattern; note `ProtectedRoute` checks `effective_roles` not `user.role`
- Update `.harness/docs/conventions.md` — replace the "Roles: `admin`, `teacher`, ..." line with a reference to `server/src/utils/rbac.js` and `effective_roles`
## 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. This is foundational and lands first.
- **Class covers** are a `user_roles` row with a time range and class scope — same table, different shape. No second mechanism.
- **Parent-as-staff:** schema already there (`parent_students`); we ship UI for "link my child" + "view as parent" toggle.
- **Auth/RBAC:** write paths = admin; read paths include principal. No new role. Every controller that checks role must use `hasRole` / `hasRoleForClass`, not `req.user.role !== X`.
- **No sync rewrite** — just append new tables. Sync team owns the order in SyncEngine.
- **No migration framework upgrade** — continue with `CREATE TABLE IF NOT EXISTS` + targeted `ALTER` per file.
- **PWA offline:** cohort/offboarding/auxiliary-role screens must work offline (just like the rest of the admin pages) — data layer mutations go through the existing sync contract; no new offline-only quirks. JWT carries a snapshot of effective roles; a `/api/auth/refresh-roles` endpoint lets the client re-sync after a grant mutation.
- **Audit:** every offboarding action writes an `offboarding_actions` row in the same transaction; every user_roles grant/revoke writes an `audit_log` row via `AuditService`. No second source of truth.
- **Currency / locale:** analytics stay in the school's locale (use `useEducationTerms()` and the existing locale helpers, not hardcoded strings).
- **Reuse over new:** use 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.
## 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. Roll forward + back works (`npm run db:rollback` exists or manual `down` runs).
2. **Auxiliary roles:** a teacher with an unscoped `librarian` grant can see `/library` and `/dashboard/librarian`; a `clubs_head` with a class-scoped `teacher` grant (expires_at = tomorrow) can take attendance for that class today and cannot tomorrow. Revoking a grant removes the route access within one client refresh (≤5s). JWT payload contains both `role` and `effective_roles`. `parent_students` toggle moves a teacher (with a linked child) to the parent dashboard.
3. **Cohort flow:** admin can create a cohort "Form 4 IGCSE 2026", link a class, link 30 students, link 1 exam_group. After creation, querying `/api/reports/cohorts` returns that cohort with the right student count. After academic rollover, the cohort correctly carries its students forward.
4. **Assignment flow:** admin can bulk-assign 10 unassigned students to a class in one action; 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.
5. **Teacher binding:** on the class detail, admin can set the form tutor AND change a subject teacher, both persist, both show in the class list. Active covers (time-scoped `user_roles`) show up on the class detail as "currently covering: …".
6. **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.
7. **Analytics dashboard:**
- `/reports` renders for `school_admin`, `systems_admin`, `principal` (not for `student`, `parent`, `teacher`).
- KPI tiles compute against realistic data (seed has at least 3 cohorts, 1 graduated cohort, mixed attendance).
- Time-range filter actually changes the data.
- Cohort tab shows per-cohort pass rate. Drilldown modal renders the cohort's students ranked.
- CSV export downloads a non-empty file.
8. **Offboarding — student:** offboard a sample student. After completion:
- `users.is_active = 0`, `users.archive_status = 'active_alumni'`
- All `enrollments` for that student are `status = 'transferred'`
- Hostel/transport rows are closed
- Fee block fires if balance > 0, override requires a reason
- 8 `offboarding_actions` rows for the 8 steps, all `status='completed'`
- Alumni directory lists the student
9. **Offboarding — staff:** offboard a sample teacher. After completion:
- `users.is_active = 0`, `users.archive_status = 'former_staff'`
- `classes.class_teacher_id` and `subjects.teacher_id` rows referencing this teacher are `NULL`
- Pending leave is `cancelled`
- Payroll block fires if last run unpaid
- Audit log entry exists
10. **E2E (Playwright):** one spec per major flow (auxiliary role grant, cohort create, bulk assign, class cover, offboard student, offboard staff, reports loads for principal, view-as-parent toggle). Existing portal-smoke tests still pass.
11. **No regression:** existing admin/principal dashboards, the exam-review module, and the finance module all load and respond. Sync pushes the new tables without errors.
## Execution order (suggested)
The 7 features have these dependencies:
```
F0 (auxiliary roles + class covers + parent-as-staff)
├─► F1 (cohorts) ─┬─► F2 (student assign, uses cohort picker)
│ └─► F5 (analytics, uses cohort data + effective roles)
└─► F3 (teacher) ─┘
F4 (create-class) uses F3 (teacher) and F2 (bulk-enroll)
F6 (offboarding) is mostly independent of F0..F5
F5 (analytics) uses F1 (cohorts) for the Cohorts tab
```
Recommended order to ship (each is its own PR into `dev`):
1. **F0 auxiliary roles + class covers + parent-as-staff UI** (foundation — touches auth/RBAC, every other feature depends on it)
2. **F1 cohorts** (unlocks the cohort picker everywhere)
3. **F2 student assign** (depends on F0, F1)
4. **F3 teacher binding** (depends on F0; can go in parallel with F2)
5. **F4 create-class wizard** (depends on F2, F3)
6. **F5 analytics** (depends on F0, F1; can start UI while F1 is in review)
7. **F6 offboarding** (mostly independent; can run in parallel with F4/F5)
Each PR: branch from `dev`, worktree at `.worktrees/<name>/`, follow `.harness/AGENTS.md` branch model, conventional commit messages, changelog entry, E2E spec where applicable.
## Out-of-scope but worth noting (do not silently take on)
- Bulk CSV import of historical cohorts / students. Currently out of scope; can be a separate feature.
- Connecting offboarding to Paynow (settle outstanding fees via Paynow). Today the wizard just *blocks* on outstanding fees; the actual settlement is a separate action on the existing Paynow flow.
- Auto-creating a cohort when a class is created (we keep cohort creation explicit, so admins think about programme and year).
- A "former staff" directory page (we keep it as a list under `/offboarding/former-staff` for now, not a full directory like Alumni).
- Tightening JWT secret, CORS, request-size limit — those are flagged in the repo's open follow-ups; not this plan.