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;