geocrop-platform./apps/nextgen/server/src/services/OffboardingService.js

434 lines
17 KiB
JavaScript

const Database = require('better-sqlite3');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const AuditService = require('./AuditService');
const dbPath = process.env.DB_PATH || path.join(__dirname, '../../data/school.db');
let db;
const getDb = () => {
if (!db) {
db = new Database(dbPath);
db.pragma('foreign_keys = ON');
}
return db;
};
class OffboardingService {
/**
* Run the student offboarding pipeline in a single transaction.
*/
static offboardStudent(userId, payload, initiatedBy) {
const database = getDb();
// Find student
const student = database.prepare('SELECT * FROM users WHERE id = ? AND is_deleted = 0').get(userId);
if (!student) {
throw new Error('User not found');
}
if (student.role !== 'student') {
throw new Error('User is not a student');
}
const recordUid = uuidv4();
const effectiveDate = payload.effectiveDate || new Date().toISOString().split('T')[0];
const destination = payload.destination || null;
const reasonNotes = payload.reasonNotes || null;
const reason = payload.reason || 'withdrawn';
const alumniStatus = reason === 'expelled' ? 'inactive_alumni' : 'active_alumni';
// Steps to execute
const steps = [
'enrollment_closed', 'hostel_released', 'transport_removed',
'clubs_removed', 'fee_settled', 'leave_closed', 'payroll_finalized',
'class_assignments_cleared', 'subject_assignments_cleared',
'account_deactivated', 'record_archived'
];
const pipeline = database.transaction(() => {
// 1. Insert offboarding record
const recordResult = database.prepare(`
INSERT INTO offboarding_records (
uid, user_id, audience, reason, reason_notes, initiated_by,
status, effective_date, destination, alumni_status, sync_status
)
VALUES (?, ?, 'student', ?, ?, ?, 'completed', ?, ?, ?, 'pending')
`).run(recordUid, userId, reason, reasonNotes, initiatedBy, effectiveDate, destination, alumniStatus);
const recordId = recordResult.lastInsertRowid;
// Helper to insert action
const writeAction = (step, status, notes = null) => {
database.prepare(`
INSERT INTO offboarding_actions (
uid, record_id, step, status, notes, performed_by, sync_status
)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
`).run(uuidv4(), recordId, step, status, notes, initiatedBy);
};
// Execute Step 1: enrollment_closed
try {
const enrollments = database.prepare('SELECT id FROM enrollments WHERE student_id = ? AND is_deleted = 0').all(userId);
let targetStatus = 'inactive';
if (reason === 'graduated') targetStatus = 'graduated';
else if (reason === 'transferred') targetStatus = 'transferred';
else if (payload.enrollmentStatus) targetStatus = payload.enrollmentStatus;
for (const enroll of enrollments) {
database.prepare(`
UPDATE enrollments
SET status = ?, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(targetStatus, enroll.id);
}
writeAction('enrollment_closed', 'completed', `Closed ${enrollments.length} enrollments to status ${targetStatus}`);
} catch (e) {
writeAction('enrollment_closed', 'failed', e.message);
throw e;
}
// Execute Step 2: hostel_released
try {
const assignments = database.prepare("SELECT id, room_id FROM room_assignments WHERE student_id = ? AND status = 'active' AND is_deleted = 0").all(userId);
for (const assign of assignments) {
database.prepare(`
UPDATE room_assignments
SET status = 'inactive', end_date = date('now', 'localtime'), sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(assign.id);
database.prepare(`
UPDATE rooms
SET status = 'available', sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(assign.room_id);
}
writeAction('hostel_released', 'completed', `Released ${assignments.length} hostel room assignments`);
} catch (e) {
writeAction('hostel_released', 'failed', e.message);
throw e;
}
// Execute Step 3: transport_removed
try {
const result = database.prepare(`
UPDATE transport_allocations
SET is_active = 0, is_deleted = 1, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE student_id = ? AND is_active = 1 AND is_deleted = 0
`).run(userId);
writeAction('transport_removed', 'completed', `Removed from transport routes. Affected ${result.changes} routes.`);
} catch (e) {
writeAction('transport_removed', 'failed', e.message);
throw e;
}
// Execute Step 4: clubs_removed
try {
const memResult = database.prepare(`
UPDATE club_memberships
SET status = 'inactive', sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE student_id = ? AND status = 'active' AND is_deleted = 0
`).run(userId);
const attResult = database.prepare(`
UPDATE club_attendance
SET is_deleted = 1, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE student_id = ? AND is_deleted = 0
`).run(userId);
writeAction('clubs_removed', 'completed', `Removed from clubs: memberships inactive (${memResult.changes}), attendance soft-deleted (${attResult.changes})`);
} catch (e) {
writeAction('clubs_removed', 'failed', e.message);
throw e;
}
// Execute Step 5: fee_settled
try {
const feeRow = database.prepare(`
SELECT COALESCE(SUM(amount - paid_amount - discount_amount + fine_amount), 0) AS outstanding
FROM student_fees
WHERE student_id = ? AND is_deleted = 0
`).get(userId);
const outstanding = Math.round((feeRow.outstanding || 0) * 100) / 100;
if (outstanding > 0) {
if (payload.override && payload.override.reason) {
writeAction('fee_settled', 'completed', `Overridden: ${payload.override.reason} (Outstanding: $${outstanding})`);
} else {
throw new Error(`Outstanding fees of $${outstanding} must be settled or overridden`);
}
} else {
writeAction('fee_settled', 'completed', 'No outstanding fees');
}
} catch (e) {
writeAction('fee_settled', 'failed', e.message);
throw e;
}
// Execute Step 6: leave_closed (Not applicable to students)
writeAction('leave_closed', 'completed', 'Not applicable to students');
// Execute Step 7: payroll_finalized (Not applicable to students)
writeAction('payroll_finalized', 'completed', 'Not applicable to students');
// Execute Step 8: class_assignments_cleared (Not applicable to students)
writeAction('class_assignments_cleared', 'completed', 'Not applicable to students');
// Execute Step 9: subject_assignments_cleared (Not applicable to students)
writeAction('subject_assignments_cleared', 'completed', 'Not applicable to students');
// Execute Step 10: account_deactivated
try {
database.prepare(`
UPDATE users
SET is_active = 0, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(userId);
writeAction('account_deactivated', 'completed', 'User account login deactivated');
} catch (e) {
writeAction('account_deactivated', 'failed', e.message);
throw e;
}
// Execute Step 11: record_archived
try {
database.prepare(`
UPDATE users
SET archive_status = ?, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(alumniStatus, userId);
writeAction('record_archived', 'completed', `Student archived to alumni status: ${alumniStatus}`);
} catch (e) {
writeAction('record_archived', 'failed', e.message);
throw e;
}
// 8. Write Audit Log
AuditService.log(null, 'STUDENT_OFFBOARD', {
db: database,
userId: initiatedBy,
entityType: 'user',
entityId: student.uid,
oldData: { is_active: student.is_active, archive_status: student.archive_status },
newData: { is_active: 0, archive_status: alumniStatus, offboarding_reason: reason }
});
if (payload.override && payload.override.reason && outstandingRowNeeded(userId, database)) {
AuditService.log(null, 'FEE_OVERRIDE', {
db: database,
userId: initiatedBy,
entityType: 'user',
entityId: student.uid,
newData: { override_reason: payload.override.reason }
});
}
// Return details
const record = database.prepare('SELECT * FROM offboarding_records WHERE id = ?').get(recordId);
const actions = database.prepare('SELECT * FROM offboarding_actions WHERE record_id = ?').all(recordId);
return { record, actions };
});
return pipeline();
}
/**
* Run the staff offboarding pipeline in a single transaction.
*/
static offboardStaff(userId, payload, initiatedBy) {
const database = getDb();
// Find staff
const staff = database.prepare('SELECT * FROM users WHERE id = ? AND is_deleted = 0').get(userId);
if (!staff) {
throw new Error('User not found');
}
const staffRoles = ['systems_admin', 'school_admin', 'teacher', 'principal', 'hr', 'bursar', 'librarian', 'clubs_head', 'nurse', 'dining_staff', 'driver', 'groundsman', 'matron', 'boarding_master', 'security', 'janitor'];
if (!staffRoles.includes(staff.role)) {
throw new Error('User is not a staff member');
}
const recordUid = uuidv4();
const effectiveDate = payload.effectiveDate || new Date().toISOString().split('T')[0];
const reasonNotes = payload.reasonNotes || null;
const reason = payload.reason || 'resigned';
const pipeline = database.transaction(() => {
// 1. Insert offboarding record
const recordResult = database.prepare(`
INSERT INTO offboarding_records (
uid, user_id, audience, reason, reason_notes, initiated_by,
status, effective_date, sync_status
)
VALUES (?, ?, 'staff', ?, ?, ?, 'completed', ?, 'pending')
`).run(recordUid, userId, reason, reasonNotes, initiatedBy, effectiveDate);
const recordId = recordResult.lastInsertRowid;
// Helper to insert action
const writeAction = (step, status, notes = null) => {
database.prepare(`
INSERT INTO offboarding_actions (
uid, record_id, step, status, notes, performed_by, sync_status
)
VALUES (?, ?, ?, ?, ?, ?, 'pending')
`).run(uuidv4(), recordId, step, status, notes, initiatedBy);
};
// Execute non-applicable student steps as completed
writeAction('enrollment_closed', 'completed', 'Not applicable to staff');
writeAction('hostel_released', 'completed', 'Not applicable to staff');
writeAction('transport_removed', 'completed', 'Not applicable to staff');
writeAction('clubs_removed', 'completed', 'Not applicable to staff');
writeAction('fee_settled', 'completed', 'Not applicable to staff');
// Execute Step 6: leave_closed
try {
const leaveResult = database.prepare(`
UPDATE leave_requests
SET status = 'cancelled', rejection_reason = 'Auto-cancelled on offboarding', sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE staff_id = ? AND status = 'pending' AND is_deleted = 0
`).run(userId);
writeAction('leave_closed', 'completed', `Cancelled pending leave requests. Count: ${leaveResult.changes}`);
} catch (e) {
writeAction('leave_closed', 'failed', e.message);
throw e;
}
// Execute Step 7: payroll_finalized
try {
const latestPayslip = database.prepare(`
SELECT p.*, r.period_month, r.period_year
FROM payslips p
JOIN payroll_runs r ON p.payroll_run_id = r.id
WHERE p.staff_id = ? AND p.is_deleted = 0 AND p.status != 'cancelled'
ORDER BY r.period_year DESC, r.period_month DESC
LIMIT 1
`).get(userId);
if (latestPayslip && latestPayslip.status !== 'paid') {
if (payload.override && payload.override.reason) {
writeAction('payroll_finalized', 'completed', `Overridden: ${payload.override.reason} (Unpaid Net: $${latestPayslip.net_salary})`);
} else {
throw new Error(`Outstanding payroll in period ${latestPayslip.period_year}-${latestPayslip.period_month} of $${latestPayslip.net_salary} must be settled or overridden`);
}
} else {
writeAction('payroll_finalized', 'completed', 'No outstanding payroll runs');
}
} catch (e) {
writeAction('payroll_finalized', 'failed', e.message);
throw e;
}
// Execute Step 8: class_assignments_cleared
try {
const classResult = database.prepare(`
UPDATE classes
SET class_teacher_id = NULL, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE class_teacher_id = ?
`).run(userId);
writeAction('class_assignments_cleared', 'completed', `Unassigned from classes as class teacher. Count: ${classResult.changes}`);
} catch (e) {
writeAction('class_assignments_cleared', 'failed', e.message);
throw e;
}
// Execute Step 9: subject_assignments_cleared
try {
const subjectResult = database.prepare(`
UPDATE subjects
SET teacher_id = NULL, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE teacher_id = ?
`).run(userId);
// Revoke user roles from Phase 1
let roleChanges = 0;
try {
const roleResult = database.prepare(`
UPDATE user_roles
SET revoked_by = ?, revoked_at = datetime('now', 'localtime'), revoke_reason = 'Staff offboarded via wizard', sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE user_id = ? AND revoked_at IS NULL AND is_deleted = 0
`).run(initiatedBy, userId);
roleChanges = roleResult.changes;
} catch (roleErr) {
// If table user_roles doesn't exist yet (e.g. before Phase 1 table is loaded)
console.warn('[offboarding] Failed to update user_roles table:', roleErr.message);
}
writeAction('subject_assignments_cleared', 'completed', `Unassigned from subjects (${subjectResult.changes}) and revoked active auxiliary roles (${roleChanges})`);
} catch (e) {
writeAction('subject_assignments_cleared', 'failed', e.message);
throw e;
}
// Execute Step 10: account_deactivated
try {
database.prepare(`
UPDATE users
SET is_active = 0, sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(userId);
writeAction('account_deactivated', 'completed', 'User account login deactivated');
} catch (e) {
writeAction('account_deactivated', 'failed', e.message);
throw e;
}
// Execute Step 11: record_archived
try {
database.prepare(`
UPDATE users
SET archive_status = 'former_staff', sync_status = 'pending', updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(userId);
writeAction('record_archived', 'completed', 'Staff archived to former_staff status');
} catch (e) {
writeAction('record_archived', 'failed', e.message);
throw e;
}
// 8. Write Audit Log
AuditService.log(null, 'STAFF_OFFBOARD', {
db: database,
userId: initiatedBy,
entityType: 'user',
entityId: staff.uid,
oldData: { is_active: staff.is_active, archive_status: staff.archive_status },
newData: { is_active: 0, archive_status: 'former_staff', offboarding_reason: reason }
});
if (payload.override && payload.override.reason) {
AuditService.log(null, 'PAYROLL_OVERRIDE', {
db: database,
userId: initiatedBy,
entityType: 'user',
entityId: staff.uid,
newData: { override_reason: payload.override.reason }
});
}
// Return details
const record = database.prepare('SELECT * FROM offboarding_records WHERE id = ?').get(recordId);
const actions = database.prepare('SELECT * FROM offboarding_actions WHERE record_id = ?').all(recordId);
return { record, actions };
});
return pipeline();
}
}
function outstandingRowNeeded(userId, database) {
try {
const feeRow = database.prepare(`
SELECT COALESCE(SUM(amount - paid_amount - discount_amount + fine_amount), 0) AS outstanding
FROM student_fees
WHERE student_id = ? AND is_deleted = 0
`).get(userId);
return (feeRow?.outstanding || 0) > 0;
} catch (e) {
return false;
}
}
module.exports = OffboardingService;