geocrop-platform./apps/nextgen/.harness/plans/2026-07-22-phase3-offboardi...

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

-- 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:
    • graduatedstatus='graduated'
    • transferredstatus='transferred'
    • withdrawn / expelledstatus='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.jsoffboarding_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.jsOffboardingService 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.