434 lines
17 KiB
JavaScript
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;
|