# Phase 3 — Offboarding Workflows (Students + Staff) **Date:** 2026-07-22 **Owner:** Mavis (orchestrator) + backend-expert / frontend-expert / database-expert / tester reins **Branch (target):** `feature/offboarding-2026-07-22` (worktree from `dev`, branched after Phase 1 lands) **Status:** plan (not started) **Assignee:** Craig **Companion plans:** [Phase 1 — auxiliary roles + cohorts + assignments](./2026-07-22-phase1-cohorts-assignments.md) · [Phase 2 — analytics dashboard](./2026-07-22-phase2-analytics.md) · [superseded 7-feature draft](./2026-07-22-cohorts-assignments-analytics-offboarding.md) **Parallelizable with:** Phase 2 (zero file overlap; both can develop in parallel after Phase 1 lands) ## Problem We have the pieces of offboarding scattered across the schema but no end-to-end process: - `users.is_active` (boolean) can be flipped manually but no UI or audit trail - `enrollments.status` already supports `active | inactive | transferred | graduated` but the values are only set by the `academic-rollover.controller.js` on year boundaries — there's no path for "this student is leaving mid-term" - HR data (leave, payroll, contracts) is in its own tables, and there's no link from "this teacher is leaving" to "close out their leave, settle their last payroll run, unassign them from their classes and subjects" - Fee data for students (Paynow, invoices, `fee_plans`) has no "this student is leaving" hook - The existing `AcademicRollover` controller does `graduate` / `transfer` at year boundaries, but that's not the same as an admin-initiated offboarding for a student who leaves mid-term, or a staff member who resigns What we need is a single, auditable process that: 1. Closes the user's current enrollments / positions 2. Cleans up their hostel, transport, club memberships 3. Settles (or blocks on) outstanding fees / payroll 4. Deactivates the account 5. Archives them to alumni / former-staff 6. Writes an audit trail that ties every step to the same case — one transaction, all or nothing **What this is, in one line:** turn "this student is leaving" or "this teacher resigned" from a multi-step manual process into a single wizard that produces a clean, audited offboarding record. ## Scope (in / out) **In this plan:** - New `offboarding_records` + `offboarding_actions` tables - New `server/src/controllers/offboarding.controller.js` and `server/src/services/OffboardingService.js` (the pipeline logic, testable in isolation) - Offboarding wizard UI (admin + principal) - Alumni directory for former students; former-staff list for HR - "Offboard" button on the student detail (`Users.tsx`) and on the staff directory - Admin override for the fee/payroll block, with mandatory reason - All steps run in a single `db.transaction()` — any step failure rolls back the whole offboarding - `users.archive_status` ALTER column (`'active_alumni' | 'inactive_alumni' | 'former_staff' | NULL`) **Out of this plan (explicitly):** - Paynow integration into the offboarding fee block — the wizard blocks on outstanding fees; the actual settlement stays a separate action on the existing Paynow flow - Auto-triggered offboarding (e.g. "after 30 days of inactivity, mark as withdrawn") — manual trigger only this round - A "former staff" full directory page (we ship a list view, not a directory) - Migrating the `AcademicRollover` controller to use the new offboarding service (the rollover already does what it does; we can refactor later) - Hard-deletion of user records after N years (offboarded users are kept forever with `is_active=0`) - Tightening JWT secret, CORS, request-size limit — separate open follow-ups ## Approach ### Schema (new Knex migration `2026-XX-XX_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 NOT NULL 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; NULL for resigned alumni_status TEXT, -- students only: '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, -- see step lists below 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 ); ALTER TABLE users ADD COLUMN archive_status TEXT CHECK(archive_status IN ('active_alumni','inactive_alumni','former_staff')); ``` **Step values for `offboarding_actions.step`** (used in both pipelines, just with different actual side-effects per audience): ``` 'enrollment_closed', 'hostel_released', 'transport_removed', 'clubs_removed', 'fee_settled', 'leave_closed', 'payroll_finalized', 'class_assignments_cleared', 'subject_assignments_cleared', 'account_deactivated', 'record_archived' ``` ### Service: `server/src/services/OffboardingService.js` The pipeline logic lives in a service, not a controller, because it must run inside a single `db.transaction()` and be unit-testable without HTTP. Two entry points: **`offboardStudent(userId, payload, initiatedBy)`** Pipeline (each step becomes a row in `offboarding_actions`, written in the same transaction): 1. **Close enrollments.** For each active `enrollments` row for this student, set `status` based on `payload.reason`: - `graduated` → `status='graduated'` - `transferred` → `status='transferred'` - `withdrawn` / `expelled` → `status='inactive'` - `other` → ask the admin to pick in the wizard 2. **Release hostel.** Set every active `room_assignments` row for this student to `status='inactive'`. 3. **Remove from transport routes.** Set every active `transport_assignments` row to `status='inactive'`. 4. **Remove from clubs.** Set every active `club_attendance` row (the "is member" indicator is derived from active attendance) to a soft-deleted state. If the schema doesn't carry membership explicitly, this step is a no-op. 5. **Settle fees.** If `student_fees.outstanding > 0` (sum of all unpaid invoices): - Without override → fail this step; the wizard prompts the admin to either settle or override. - With override (`override: { reason: string, by: userId }`) → mark the step as `completed` with the reason in `notes`. 6. **Deactivate account.** `users.is_active = 0`. 7. **Archive.** `users.archive_status = 'active_alumni'` (or `'inactive_alumni'` for `expelled`). 8. **Audit log.** Insert an `audit_log` row via `AuditService` summarizing the offboarding. **`offboardStaff(userId, payload, initiatedBy)`** Pipeline: 1. **Close leave.** Every `leave_requests` row with `status='pending'` for this staff → `status='cancelled'`, with `notes='Auto-cancelled on offboarding'`. 2. **Settle payroll.** If the last `payroll_runs` for this staff has unpaid line items: - Without override → fail this step; the wizard prompts the admin to either settle or override. - With override → mark as `completed` with the reason. 3. **Clear class assignments.** Every `classes.class_teacher_id` referencing this user → `NULL`. Every `subjects.teacher_id` referencing this user → `NULL`. (Auxiliary `user_roles` rows for this user get `revoked_at` set; reuse the revoke logic from Phase 1.) 4. **Deactivate account.** `users.is_active = 0`. 5. **Archive.** `users.archive_status = 'former_staff'`. 6. **Audit log.** Same as student. **Both pipelines return `{ record, actions }` so the controller can return the new IDs in the HTTP response.** ### Controller: `server/src/controllers/offboarding.controller.js` - `POST /api/offboarding/students/:userId` — body: `{ reason, effectiveDate, destination?, reasonNotes?, override?: { reason } }` → calls `OffboardingService.offboardStudent` - `POST /api/offboarding/staff/:userId` — body: `{ reason, effectiveDate, reasonNotes?, override?: { reason } }` → calls `OffboardingService.offboardStaff` - `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 (sets `status='cancelled'`, leaves the `offboarding_actions` rows intact as a historical record) - `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 (read-only, principal can also see) - `GET /api/offboarding/former-staff?department_id=` — former-staff list (HR) **Auth / RBAC:** - Initiate / complete / cancel = `school_admin | systems_admin | principal` - View records = same + `hr` (for staff offboardings) - Override = `school_admin | systems_admin` only - Use the `hasRole` helper from Phase 1 if it has landed; otherwise use the existing `req.user.role` checks **Transaction discipline:** every endpoint that mutates state (`POST /students/:userId`, `POST /staff/:userId`, `POST /records/:id/cancel`, `POST /records/:id/override`) runs the service inside `db.transaction()`. If any step throws, the transaction rolls back and the HTTP response is 500 with the failing step name in the body. ### Sync engine Append `offboarding_records` and `offboarding_actions` to `tablesToSync` in `server/src/services/SyncEngine.js`, in dependency order (after `users`, after `enrollments`, before any future table that references them). Sync-test in isolation before merging — see the verification bar. ### Frontend **New pages:** - `client/src/pages/admin/Offboarding.tsx` — list of in-progress + recent offboardings. Two tabs: "Students" and "Staff". Filterable by status (`in_progress | completed | cancelled`), date range, initiator. - `client/src/pages/admin/OffboardingWizard.tsx` — multi-step wizard: - **Step 1 — Reason.** Radio buttons for the relevant reasons (student vs staff). Free-text `reason_notes`. - **Step 2 — Effective date.** Date picker (defaults to today). - **Step 3 — Checklist.** Rendered dynamically based on the audience: - For students: enrollments to close, hostel room, transport route, clubs, fee balance. The fee step is highlighted red if there's an outstanding balance, with a "Settle first" or "Override with reason" CTA. - For staff: pending leave count, last payroll run status, class/subject assignment count. The payroll step is highlighted red if unpaid line items exist. - **Step 4 — Confirm.** Shows the full summary, the audit-trail preview, the override reason (if any), the destination (for transfers). "Offboard" button. - `client/src/pages/admin/Alumni.tsx` — searchable alumni directory. Read-only. Filters: cohort (uses `student_cohorts` from Phase 1), year of offboarding, reason. **Modified pages:** - `client/src/pages/admin/Users.tsx` — "Offboard" button on the student detail (top-right of the detail panel, next to "Edit"). Disabled if the user is already offboarded (`users.is_active = 0`). - `client/src/pages/admin/HRManagement.tsx` — "Offboard" button on the staff detail. - `client/src/pages/hr/StaffDirectory.tsx` — same. - `client/src/App.tsx` — add routes for the new pages; gate them to the right roles. - `client/src/components/Nav.tsx` — add Nav entries. **New components:** - `client/src/components/OffboardingStepRow.tsx` — single step in the wizard checklist, with status icon (pending / ok / blocked), label, and detail link. - `client/src/components/OverrideModal.tsx` — small modal that captures the override reason; required before a blocked step can be marked complete. ### Sync / offline `offboarding_records` and `offboarding_actions` both carry the standard sync columns. Each row is pushed in the next sync batch. The `offboarding_actions` rows reference `offboarding_records(id)` — sync must respect the FK dependency order. The SyncEngine already manages `tablesToSync` in dependency order; we just append the two new names in the right place. The offboarding wizard itself is a write-heavy screen and should not work offline in this round — if the user is offline, the wizard shows a "you must be online to offboard" message and a retry button. This keeps the data-integrity story simple. ## Files to add / change ### Backend (new) - `server/src/database/migrations/knex/2026-XX-XX_offboarding.js` — `offboarding_records` + `offboarding_actions` + `users.archive_status` ALTER - `server/src/controllers/offboarding.controller.js` - `server/src/services/OffboardingService.js` ### Backend (modify) - `server/src/services/SyncEngine.js` — append 2 new table names to `tablesToSync` - `server/src/index.js` — register the `offboarding` controller - `server/src/controllers/users.controller.js` (or `students.controller.js`) — no API change; the "Offboard" button on the frontend calls the new endpoint - `server/src/controllers/hr.controller.js` — same - `server/src/controllers/hostels.controller.js` — `OffboardingService` calls the existing `room_assignments` update directly via SQL, not via this controller; the controller is untouched - `server/src/controllers/transport.controller.js` — same pattern ### Frontend (new) - `client/src/store/offboarding.ts` - `client/src/pages/admin/Offboarding.tsx` - `client/src/pages/admin/OffboardingWizard.tsx` - `client/src/pages/admin/Alumni.tsx` - `client/src/components/OffboardingStepRow.tsx` - `client/src/components/OverrideModal.tsx` ### Frontend (modify) - `client/src/pages/admin/Users.tsx` — "Offboard" button on student detail - `client/src/pages/admin/HRManagement.tsx` — "Offboard" button on staff detail - `client/src/pages/hr/StaffDirectory.tsx` — same - `client/src/App.tsx` — add routes for the new pages; gate them to the right roles - `client/src/components/Nav.tsx` — add Nav entries ### Docs / changelogs - `.harness/changelogs/2026-07-22-offboarding.md` ## Constraints / decisions baked in - **Transaction integrity is non-negotiable.** Every offboarding pipeline runs in 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. This is the one place in the codebase where we care more about transaction atomicity than throughput. - **Override is admin-only and always audited.** The override reason is stored in `offboarding_actions.notes` AND in a separate `audit_log` row via `AuditService`. Two audit sources, one source of truth. - **Cancel keeps history.** Cancelling an offboarding doesn't delete the `offboarding_actions` rows — it sets the `offboarding_records.status = 'cancelled'`. The audit trail is preserved. - **Sync dependency order matters.** `offboarding_actions` references `offboarding_records`; `offboarding_records` references `users` and `enrollments`. Sync the parents first, then the children. The existing SyncEngine manages this; we just append in the right place. - **No Paynow integration this round.** The wizard blocks on outstanding fees; settlement is a separate action on the existing Paynow flow. - **No auto-triggered offboarding.** Manual trigger only. - **No former-staff directory page.** A list view under `/offboarding/former-staff` is enough for this round. - **No hard-deletion.** Offboarded users are kept forever with `is_active=0` and `archive_status` set. We can revisit retention later. - **The `AcademicRollover` controller is not refactored** to use the new offboarding service in this plan. It can be migrated later if we want consistency. - **No new libraries.** Reuse AuditService, SyncEngine, Recharts, lucide, axios, the existing modal components. No new form lib. ## 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. 2. **Student offboarding — happy path.** Offboard a sample student with no outstanding fees. After completion: - `users.is_active = 0`, `users.archive_status = 'active_alumni'` - All `enrollments` for that student are `status = 'transferred'` (or `graduated` / `inactive` depending on the chosen reason) - Hostel `room_assignments` rows are `status = 'inactive'` - Transport rows are `status = 'inactive'` - Clubs rows are marked inactive - 11 `offboarding_actions` rows for the 11 steps, all `status='completed'` - One `audit_log` entry via AuditService - Alumni directory lists the student 3. **Student offboarding — fee block.** Offboard a sample student with an outstanding balance: - The wizard's fee step shows the outstanding amount in red. - Without override, the wizard does not allow completion. - With override + reason, the step is marked `completed` with the reason in `notes`; one extra `audit_log` row tagged `override`. 4. **Staff offboarding — happy path.** 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` - Active `user_roles` rows for this user have `revoked_at` set (uses Phase 1's revoke logic if it has landed; otherwise just sets `is_deleted = 1`) - Pending `leave_requests` are `status = 'cancelled'` with `notes = 'Auto-cancelled on offboarding'` - Payroll block is skipped if the last run is fully paid; otherwise the wizard blocks - Audit log entry exists 5. **Staff offboarding — payroll block.** Offboard a sample teacher with an unpaid payroll run: - The wizard's payroll step shows the unpaid amount in red. - Without override, no completion. - With override + reason, completion proceeds; one extra `audit_log` row. 6. **Transaction integrity.** Force a failure at step 4 of the student pipeline (e.g. mock the hostel release to throw). The whole offboarding rolls back: `users.is_active` stays `1`, no `offboarding_actions` rows are written, no `enrollments` updates persist. 7. **Cancel.** Initiate an offboarding, cancel it before completion. `offboarding_records.status = 'cancelled'`, the partial `offboarding_actions` rows remain visible, no further state mutations happened. 8. **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 both are added, the full `tablesToSync` sweep is clean. 9. **E2E (Playwright):** one spec for student offboarding happy path, one for student fee block + override, one for staff offboarding happy path, one for cancel. Existing portal-smoke tests still pass with zero regression. 10. **No regression:** every existing admin/principal dashboard, the exam-review module, the finance module, and the analytics dashboard (if Phase 2 has landed) all still load and respond. ## Execution order (this plan only) ``` PR 1: Schema migration + OffboardingService (no API yet) + unit tests for the service PR 2: Offboarding controller + routes PR 3: Frontend — Offboarding list page + wizard + Alumni directory PR 4: "Offboard" buttons on Users.tsx, HRManagement.tsx, StaffDirectory.tsx + E2E spec ``` PR 1 is the foundation. PR 2 and PR 3 can be developed in parallel (different files). PR 4 is small and merges last because it depends on the wizard. **Why the service-first split:** the transaction discipline is the hardest part of this plan. If the service is right, the controller is thin, the frontend is straightforward, and the E2E tests can focus on user-visible behavior. If the service is wrong, nothing else matters. Putting the service in its own PR with unit tests gives us a clear "did the design work" signal before we wire the HTTP layer. ## Out of scope (explicitly) - Paynow integration into the offboarding fee block (separate action on the Paynow flow) - Auto-triggered offboarding (manual trigger only) - A full "former staff" directory page (list view only) - Migrating the `AcademicRollover` controller to use the new offboarding service - Hard-deletion of user records after N years - Tightening JWT secret, CORS, request-size limit — separate open follow-ups ## Parallelizability with Phase 2 This plan and Phase 2 (analytics) share **zero files**: - Different controllers (`offboarding.controller.js` vs the extensions to `reports.controller.js`) - Different frontend pages (`Offboarding.tsx` / `OffboardingWizard.tsx` / `Alumni.tsx` vs the rebuild of `Reports.tsx`) - Different tables (`offboarding_records` + `offboarding_actions` + `users.archive_status` ALTER vs no new tables) - Different sync impact (2 new tables here vs none) After Phase 1 lands, a dev can pick up Phase 2 in one worktree and another dev can pick up Phase 3 in another. The only coordination is the merge order: either order is fine, since the two PRs don't touch the same code.