22 KiB
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 · Phase 2 — analytics dashboard · superseded 7-feature draft
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 trailenrollments.statusalready supportsactive | inactive | transferred | graduatedbut the values are only set by theacademic-rollover.controller.json 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
AcademicRollovercontroller doesgraduate/transferat 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:
- Closes the user's current enrollments / positions
- Cleans up their hostel, transport, club memberships
- Settles (or blocks on) outstanding fees / payroll
- Deactivates the account
- Archives them to alumni / former-staff
- 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_actionstables - New
server/src/controllers/offboarding.controller.jsandserver/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_statusALTER 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
AcademicRollovercontroller 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)
-- 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):
- Close enrollments. For each active
enrollmentsrow for this student, setstatusbased onpayload.reason:graduated→status='graduated'transferred→status='transferred'withdrawn/expelled→status='inactive'other→ ask the admin to pick in the wizard
- Release hostel. Set every active
room_assignmentsrow for this student tostatus='inactive'. - Remove from transport routes. Set every active
transport_assignmentsrow tostatus='inactive'. - Remove from clubs. Set every active
club_attendancerow (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. - 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 ascompletedwith the reason innotes.
- Deactivate account.
users.is_active = 0. - Archive.
users.archive_status = 'active_alumni'(or'inactive_alumni'forexpelled). - Audit log. Insert an
audit_logrow viaAuditServicesummarizing the offboarding.
offboardStaff(userId, payload, initiatedBy)
Pipeline:
- Close leave. Every
leave_requestsrow withstatus='pending'for this staff →status='cancelled', withnotes='Auto-cancelled on offboarding'. - Settle payroll. If the last
payroll_runsfor 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
completedwith the reason.
- Clear class assignments. Every
classes.class_teacher_idreferencing this user →NULL. Everysubjects.teacher_idreferencing this user →NULL. (Auxiliaryuser_rolesrows for this user getrevoked_atset; reuse the revoke logic from Phase 1.) - Deactivate account.
users.is_active = 0. - Archive.
users.archive_status = 'former_staff'. - 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 } }→ callsOffboardingService.offboardStudentPOST /api/offboarding/staff/:userId— body:{ reason, effectiveDate, reasonNotes?, override?: { reason } }→ callsOffboardingService.offboardStaffGET /api/offboarding/records?audience=student|staff&status=in_progress|completed— listGET /api/offboarding/records/:id— detail with actionsPOST /api/offboarding/records/:id/cancel— re-open if not yet completed (setsstatus='cancelled', leaves theoffboarding_actionsrows intact as a historical record)POST /api/offboarding/records/:id/override— admin override for the fee/payroll block, with mandatoryreasonGET /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_adminonly - Use the
hasRolehelper from Phase 1 if it has landed; otherwise use the existingreq.user.rolechecks
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.
- Step 1 — Reason. Radio buttons for the relevant reasons (student vs staff). Free-text
client/src/pages/admin/Alumni.tsx— searchable alumni directory. Read-only. Filters: cohort (usesstudent_cohortsfrom 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_statusALTERserver/src/controllers/offboarding.controller.jsserver/src/services/OffboardingService.js
Backend (modify)
server/src/services/SyncEngine.js— append 2 new table names totablesToSyncserver/src/index.js— register theoffboardingcontrollerserver/src/controllers/users.controller.js(orstudents.controller.js) — no API change; the "Offboard" button on the frontend calls the new endpointserver/src/controllers/hr.controller.js— sameserver/src/controllers/hostels.controller.js—OffboardingServicecalls the existingroom_assignmentsupdate directly via SQL, not via this controller; the controller is untouchedserver/src/controllers/transport.controller.js— same pattern
Frontend (new)
client/src/store/offboarding.tsclient/src/pages/admin/Offboarding.tsxclient/src/pages/admin/OffboardingWizard.tsxclient/src/pages/admin/Alumni.tsxclient/src/components/OffboardingStepRow.tsxclient/src/components/OverrideModal.tsx
Frontend (modify)
client/src/pages/admin/Users.tsx— "Offboard" button on student detailclient/src/pages/admin/HRManagement.tsx— "Offboard" button on staff detailclient/src/pages/hr/StaffDirectory.tsx— sameclient/src/App.tsx— add routes for the new pages; gate them to the right rolesclient/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. Theoffboarding_actionsrows 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.notesAND in a separateaudit_logrow viaAuditService. Two audit sources, one source of truth. - Cancel keeps history. Cancelling an offboarding doesn't delete the
offboarding_actionsrows — it sets theoffboarding_records.status = 'cancelled'. The audit trail is preserved. - Sync dependency order matters.
offboarding_actionsreferencesoffboarding_records;offboarding_recordsreferencesusersandenrollments. 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-staffis enough for this round. - No hard-deletion. Offboarded users are kept forever with
is_active=0andarchive_statusset. We can revisit retention later. - The
AcademicRollovercontroller 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:
- Migrations apply cleanly on a fresh DB and on an existing DB.
npm run db:initsucceeds;knex migrate:rollbackcleanly reverses. - Student offboarding — happy path. Offboard a sample student with no outstanding fees. After completion:
users.is_active = 0,users.archive_status = 'active_alumni'- All
enrollmentsfor that student arestatus = 'transferred'(orgraduated/inactivedepending on the chosen reason) - Hostel
room_assignmentsrows arestatus = 'inactive' - Transport rows are
status = 'inactive' - Clubs rows are marked inactive
- 11
offboarding_actionsrows for the 11 steps, allstatus='completed' - One
audit_logentry via AuditService - Alumni directory lists the student
- 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
completedwith the reason innotes; one extraaudit_logrow taggedoverride.
- Staff offboarding — happy path. Offboard a sample teacher. After completion:
users.is_active = 0,users.archive_status = 'former_staff'classes.class_teacher_idandsubjects.teacher_idrows referencing this teacher areNULL- Active
user_rolesrows for this user haverevoked_atset (uses Phase 1's revoke logic if it has landed; otherwise just setsis_deleted = 1) - Pending
leave_requestsarestatus = 'cancelled'withnotes = 'Auto-cancelled on offboarding' - Payroll block is skipped if the last run is fully paid; otherwise the wizard blocks
- Audit log entry exists
- 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_logrow.
- 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_activestays1, nooffboarding_actionsrows are written, noenrollmentsupdates persist. - Cancel. Initiate an offboarding, cancel it before completion.
offboarding_records.status = 'cancelled', the partialoffboarding_actionsrows remain visible, no further state mutations happened. - 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
tablesToSyncsweep is clean. - 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.
- 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
AcademicRollovercontroller 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.jsvs the extensions toreports.controller.js) - Different frontend pages (
Offboarding.tsx/OffboardingWizard.tsx/Alumni.tsxvs the rebuild ofReports.tsx) - Different tables (
offboarding_records+offboarding_actions+users.archive_statusALTER 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.