diff --git a/app-backend/package.json b/app-backend/package.json index 82e9989f9..6dbebdf9e 100644 --- a/app-backend/package.json +++ b/app-backend/package.json @@ -47,6 +47,7 @@ "morgan": "^1.10.1", "multer": "^2.0.2", "nodemailer": "^7.0.5", + "pdfkit": "^0.15.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/app-backend/src/controllers/notification.controller.js b/app-backend/src/controllers/notification.controller.js index 1529baf0a..078c4b8e4 100644 --- a/app-backend/src/controllers/notification.controller.js +++ b/app-backend/src/controllers/notification.controller.js @@ -1,5 +1,5 @@ import Notification from '../models/Notification.js'; -import { ROLES } from '../constants/roles.js'; +import { ROLES } from '../middleware/rbac.js'; /** * GET /notifications diff --git a/app-backend/src/controllers/payroll.controller.js b/app-backend/src/controllers/payroll.controller.js index 1fa524a64..192236957 100644 --- a/app-backend/src/controllers/payroll.controller.js +++ b/app-backend/src/controllers/payroll.controller.js @@ -1,41 +1,713 @@ -import { buildPayrollSummary } from "../services/payroll.service.js"; +/** + * @file controllers/payroll.controller.js + * @description Payroll endpoint handlers for SecureShift. + * + * Endpoints + * GET /api/v1/payroll – Generate / retrieve payroll summaries + * POST /api/v1/payroll/approve – Approve one or more PENDING payroll records + * POST /api/v1/payroll/process – Process one or more APPROVED payroll records + * GET /api/v1/payroll/export – Export payroll data as CSV or PDF + * + * POST /api/v1/payroll/attendance – Record attendance (admin / guard) + * GET /api/v1/payroll/attendance/:shiftId – Get attendance for a shift + */ -export const getPayrollSummary = async (req, res) => { +import mongoose from 'mongoose'; +import Payroll from '../models/Payroll.js'; +import ShiftAttendance from '../models/ShiftAttendance.js'; +import Shift from '../models/Shift.js'; +import { ACTIONS } from '../middleware/logger.js'; +import { + generateAndPersistPayroll, + buildDateRange, +} from '../services/payroll.service.js'; + +const VALID_PERIOD_TYPES = ['daily', 'weekly', 'monthly']; + +// Helpers +const round2 = (n) => Math.round((n || 0) * 100) / 100; + +function validateDateRange(startDate, endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime())) return 'startDate is not a valid date'; + if (isNaN(end.getTime())) return 'endDate is not a valid date'; + if (start > end) return 'startDate must be before endDate'; + return null; +} + +function escapeCsv(val) { + const str = val == null ? '' : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +function capitalize(s) { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; +} + +// ─── GET /api/v1/payroll ─ Generate / retrieve payroll summaries +/** + * Retrieve (and generate / refresh) payroll summaries. + * + * Query params: + * startDate – ISO date string (required) + * endDate – ISO date string (required) + * periodType – 'daily' | 'weekly' | 'monthly' (required) + * guardId – ObjectId string (optional; guards may only view their own) + * department – branch ObjectId or name (optional) + */ +export const getPayroll = async (req, res) => { + try { + const { startDate, endDate, periodType, guardId, department } = req.query; + + // Validation with detailed error messages + if (!startDate || !endDate) { + return res.status(400).json({ message: 'startDate and endDate are required' }); + } + + if (!periodType || !VALID_PERIOD_TYPES.includes(periodType)) { + return res.status(400).json({ + message: `periodType must be one of: ${VALID_PERIOD_TYPES.join(', ')}`, + }); + } + + const rangeError = validateDateRange(startDate, endDate); + if (rangeError) return res.status(400).json({ message: rangeError }); + + if (guardId && !mongoose.isValidObjectId(guardId)) { + return res.status(400).json({ message: 'guardId is not a valid ObjectId' }); + } + + // Role-based access control: guards can only view their own payroll + const { role, _id: userId } = req.user; + let effectiveGuardId = guardId || null; + + if (role === 'guard') { + effectiveGuardId = String(userId); + } + + // Generate and persist payroll records for the specified period and filters + const records = await generateAndPersistPayroll({ + startDate, + endDate, + periodType, + guardId: effectiveGuardId, + department: department || null, + }); + + // Compute summary statistics for the response + const summary = { + totalGuards: records.length, + totalWorkedHours: round2(records.reduce((s, r) => s + (r.totalWorkedHours || 0), 0)), + totalOvertimeHours: round2(records.reduce((s, r) => s + (r.totalOvertimeHours || 0), 0)), + totalGrossPay: round2(records.reduce((s, r) => s + (r.grossPay || 0), 0)), + }; + + await req.audit.log(userId, ACTIONS.PAYROLL_GENERATED, { + periodType, + startDate, + endDate, + recordCount: records.length, + }); + + return res.json({ + period: { type: periodType, startDate, endDate }, + summary, + records, + }); + } catch (err) { + console.error('[getPayroll]', err); + return res.status(500).json({ message: err.message }); + } +}; + +// POST /api/v1/payroll/approve +/** + * Approve one or more PENDING payroll records. + * Body: { payrollIds: ["id1", "id2", ...] } + */ +export const approvePayroll = async (req, res) => { try { - const result = await buildPayrollSummary(req.query, req.user); - return res.status(200).json(result); - } catch (error) { - if ( - error.message.includes("required") || - error.message.includes("periodType") || - error.message.includes("ISO") || - error.message.includes("Invalid startDate") || - error.message.includes("after") - ) { + const { payrollIds } = req.body; + + if (!Array.isArray(payrollIds) || payrollIds.length === 0) { + return res.status(400).json({ message: 'payrollIds must be a non-empty array' }); + } + + const invalid = payrollIds.filter((id) => !mongoose.isValidObjectId(id)); + if (invalid.length) { + return res.status(400).json({ message: `Invalid ObjectId(s): ${invalid.join(', ')}` }); + } + + const records = await Payroll.find({ _id: { $in: payrollIds } }).lean(); + + if (records.length !== payrollIds.length) { + const foundIds = records.map((r) => r._id.toString()); + const missingIds = payrollIds.filter((id) => !foundIds.includes(id)); + return res.status(404).json({ + message: `Payroll record(s) not found: ${missingIds.join(', ')}`, + }); + } + + const notPending = records.filter((r) => r.status !== 'PENDING'); + if (notPending.length) { return res.status(400).json({ - message: error.message, + message: `Only PENDING payrolls can be approved. Non-pending record(s): ${notPending.map((r) => `${r._id} (${r.status})`).join(', ')}`, + }); + } + + const approverId = req.user._id || req.user.id; + const result = await Payroll.updateMany( + { _id: { $in: payrollIds }, status: 'PENDING' }, + { + $set: { + status: 'APPROVED', + approvedBy: approverId, + approvedAt: new Date(), + }, + } + ); + + await req.audit.log(approverId, ACTIONS.PAYROLL_APPROVED, { + payrollIds, + approvedCount: result.modifiedCount, + }); + + return res.json({ + message: `${result.modifiedCount} payroll record(s) approved successfully`, + modifiedCount: result.modifiedCount, + }); + } catch (err) { + console.error('[approvePayroll]', err); + return res.status(500).json({ message: err.message }); + } +}; + +// POST /api/v1/payroll/process +/** + * Mark one or more APPROVED payroll records as PROCESSED. + * Body: { payrollIds: ["id1", "id2", ...] } + */ +export const processPayroll = async (req, res) => { + try { + const { payrollIds } = req.body; + + if (!Array.isArray(payrollIds) || payrollIds.length === 0) { + return res.status(400).json({ message: 'payrollIds must be a non-empty array' }); + } + + const invalid = payrollIds.filter((id) => !mongoose.isValidObjectId(id)); + if (invalid.length) { + return res.status(400).json({ message: `Invalid ObjectId(s): ${invalid.join(', ')}` }); + } + + const records = await Payroll.find({ _id: { $in: payrollIds } }).lean(); + + if (records.length !== payrollIds.length) { + const foundIds = records.map((r) => r._id.toString()); + const missingIds = payrollIds.filter((id) => !foundIds.includes(id)); + return res.status(404).json({ + message: `Payroll record(s) not found: ${missingIds.join(', ')}`, }); } - if ( - error.message.includes("Forbidden") || - error.message.includes("only access their own") || - error.message.includes("unsupported role") - ) { - return res.status(403).json({ - message: error.message, + const notApproved = records.filter((r) => r.status !== 'APPROVED'); + if (notApproved.length) { + return res.status(400).json({ + message: `Only APPROVED payrolls can be processed. Non-approved record(s): ${notApproved.map((r) => `${r._id} (${r.status})`).join(', ')}`, }); } - if (error.message.includes("Unauthorised")) { - return res.status(401).json({ - message: error.message, + const processorId = req.user._id || req.user.id; + const result = await Payroll.updateMany( + { _id: { $in: payrollIds }, status: 'APPROVED' }, + { + $set: { + status: 'PROCESSED', + processedBy: processorId, + processedAt: new Date(), + }, + } + ); + + await req.audit.log(processorId, ACTIONS.PAYROLL_PROCESSED, { + payrollIds, + processedCount: result.modifiedCount, + }); + + return res.json({ + message: `${result.modifiedCount} payroll record(s) processed successfully`, + modifiedCount: result.modifiedCount, + }); + } catch (err) { + console.error('[processPayroll]', err); + return res.status(500).json({ message: err.message }); + } +}; + +// GET /api/v1/payroll/export +/** + * Export payroll data as CSV or PDF. + * Query params: + * startDate – ISO date string (required) + * endDate – ISO date string (required) + * periodType – 'daily' | 'weekly' | 'monthly' (required) + * format – 'csv' | 'pdf' (optional; default: 'csv') + * guardId – ObjectId string (optional; guards may only view their own) + * department – branch ObjectId or name (optional) + * status – 'PENDING' | 'APPROVED' | 'PROCESSED' (optional) + */ +export const exportPayroll = async (req, res) => { + try { + const { + startDate, + endDate, + periodType, + format = 'csv', + guardId, + department, + status, + } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ message: 'startDate and endDate are required' }); + } + if (!periodType || !VALID_PERIOD_TYPES.includes(periodType)) { + return res.status(400).json({ + message: `periodType must be one of: ${VALID_PERIOD_TYPES.join(', ')}`, }); } + const rangeError = validateDateRange(startDate, endDate); + if (rangeError) return res.status(400).json({ message: rangeError }); + + if (!['csv', 'pdf'].includes(format)) { + return res.status(400).json({ message: "format must be 'csv' or 'pdf'" }); + } + + if (status && !['PENDING', 'APPROVED', 'PROCESSED'].includes(status)) { + return res.status(400).json({ message: 'status must be PENDING, APPROVED, or PROCESSED' }); + } + + // Role-based access control: guards can only export their own payroll + const { start, end } = buildDateRange(startDate, endDate); + const query = { + 'period.type': periodType, + 'period.startDate': { $gte: start }, + 'period.endDate': { $lte: end }, + }; + + if (guardId) { + query.guard = mongoose.isValidObjectId(guardId) + ? new mongoose.Types.ObjectId(guardId) + : guardId; + } + if (department) query.guardDepartment = department; + if (status) query.status = status; + + if (req.user.role === 'guard') { + query.guard = new mongoose.Types.ObjectId(req.user._id || req.user.id); + } + + const records = await Payroll.find(query) + .populate('approvedBy', 'name email') + .populate('processedBy', 'name email') + .sort({ guardName: 1 }) + .lean(); + + await req.audit.log(req.user._id, ACTIONS.PAYROLL_EXPORTED, { + format, + periodType, + startDate, + endDate, + count: records.length, + }); + + if (format === 'csv') { + return sendCSV(res, records, periodType, startDate, endDate); + } + return sendPDF(res, records, periodType, startDate, endDate); + } catch (err) { + console.error('[exportPayroll]', err); + return res.status(500).json({ message: err.message }); + } +}; + +// CSV builder helper functions +/** + * Record clock-in / clock-out attendance for a shift. + * Body: { shiftId, clockIn, clockOut, notes } + * Guards can only record attendance for their own shifts; admins can specify guardId in body. + * If clockIn is provided without clockOut, status will be 'incomplete'. If clockOut is before clockIn, an error is returned. + * If neither clockIn nor clockOut is provided, status will be 'absent'. + * Notes can be added by admins (e.g. "Guard called in sick") or guards (e.g. "Arrived late due to traffic"). + * */ +function sendCSV(res, records, periodType, startDate, endDate) { + const lines = []; + + lines.push('SecureShift Payroll Export'); + lines.push(`Period Type,${capitalize(periodType)}`); + lines.push(`Start Date,${startDate}`); + lines.push(`End Date,${endDate}`); + lines.push(`Generated At,${new Date().toISOString()}`); + lines.push(''); + + const totalWorked = round2(records.reduce((s, r) => s + (r.totalWorkedHours || 0), 0)); + const totalOT = round2(records.reduce((s, r) => s + (r.totalOvertimeHours || 0), 0)); + const totalPay = round2(records.reduce((s, r) => s + (r.grossPay || 0), 0)); + + lines.push('PERIOD SUMMARY'); + lines.push(`Total Guards,${records.length}`); + lines.push(`Total Hours Worked,${totalWorked}`); + lines.push(`Total Overtime Hours,${totalOT}`); + lines.push(`Total Gross Pay (AUD),$${totalPay.toFixed(2)}`); + lines.push(''); + + lines.push('PER-GUARD SUMMARY'); + lines.push( + 'Guard Name,Email,Role,Sched Hrs,Worked Hrs,Regular Hrs,Overtime Hrs,Gross Pay (AUD),Status,Approved By,Approved At,Processed By,Processed At' + ); + + for (const r of records) { + lines.push( + [ + escapeCsv(r.guardName || ''), + escapeCsv(r.guardEmail || ''), + escapeCsv(r.guardRole || ''), + r.totalScheduledHours ?? 0, + r.totalWorkedHours ?? 0, + r.totalRegularHours ?? 0, + r.totalOvertimeHours ?? 0, + (r.grossPay || 0).toFixed(2), + r.status || '', + escapeCsv(r.approvedBy?.name || ''), + r.approvedAt ? new Date(r.approvedAt).toISOString() : '', + escapeCsv(r.processedBy?.name || ''), + r.processedAt ? new Date(r.processedAt).toISOString() : '', + ].join(',') + ); + } + + lines.push(''); + lines.push('SHIFT DETAILS'); + lines.push( + 'Guard Name,Shift Date,Sched Hrs,Actual Hrs,Regular Hrs,OT Hrs,Pay Rate ($/hr),Regular Pay,OT Pay,Total Pay,Attendance Status' + ); + + for (const r of records) { + for (const e of r.entries || []) { + lines.push( + [ + escapeCsv(r.guardName || ''), + new Date(e.shiftDate).toLocaleDateString('en-AU'), + e.scheduledHours ?? 0, + e.actualHours ?? 0, + e.regularHours ?? 0, + e.overtimeHours ?? 0, + `$${e.payRate ?? 0}`, + `$${(e.regularPay || 0).toFixed(2)}`, + `$${(e.overtimePay || 0).toFixed(2)}`, + `$${(e.totalPay || 0).toFixed(2)}`, + escapeCsv(e.attendanceStatus || 'n/a'), + ].join(',') + ); + } + } + + const csv = lines.join('\r\n'); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="payroll-${startDate}-to-${endDate}.csv"` + ); + + return res.send('\uFEFF' + csv); +} + +// PDF builder helper function +/** + * Generate and send a PDF payroll report. + * Uses dynamic import of pdfkit to avoid adding it as a dependency if PDF export is not needed. + * If pdfkit is not installed, returns a 500 error with instructions to install it. + */ +async function sendPDF(res, records, periodType, startDate, endDate) { + let PDFDocument; + try { + const mod = await import('pdfkit'); + PDFDocument = mod.default; + } catch { return res.status(500).json({ - message: "Failed to retrieve payroll summary", - error: error.message, + message: + 'PDF generation requires the pdfkit package. Run `npm install pdfkit` in app-backend/ and restart the server.', + }); + } + + const filename = `payroll-${startDate}-to-${endDate}.pdf`; + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + const doc = new PDFDocument({ margin: 50, size: 'A4' }); + doc.pipe(res); + + doc + .fontSize(20) + .font('Helvetica-Bold') + .text('SecureShift Payroll Report', { align: 'center' }); + + doc.moveDown(0.5); + + doc + .fontSize(11) + .font('Helvetica') + .text(`Period: ${capitalize(periodType)} | ${startDate} → ${endDate}`) + .text(`Generated: ${new Date().toLocaleString('en-AU', { timeZone: 'Australia/Sydney' })}`); + + doc.moveDown(); + + const totalWorked = round2(records.reduce((s, r) => s + (r.totalWorkedHours || 0), 0)); + const totalOT = round2(records.reduce((s, r) => s + (r.totalOvertimeHours || 0), 0)); + const totalPay = round2(records.reduce((s, r) => s + (r.grossPay || 0), 0)); + + doc.fontSize(13).font('Helvetica-Bold').text('Period Summary'); + doc + .fontSize(11) + .font('Helvetica') + .text(`Total Guards Included : ${records.length}`) + .text(`Total Hours Worked : ${totalWorked} hrs`) + .text(`Total Overtime Hours : ${totalOT} hrs`) + .text(`Total Gross Pay : $${totalPay.toFixed(2)} AUD`); + + doc.moveDown(); + doc.moveTo(50, doc.y).lineTo(545, doc.y).stroke(); + doc.moveDown(); + + for (const r of records) { + if (doc.y > 680) doc.addPage(); + + doc + .fontSize(12) + .font('Helvetica-Bold') + .text(`${r.guardName || 'Unknown Guard'} (${r.guardEmail || ''})`, { underline: true }); + + doc + .fontSize(10) + .font('Helvetica') + .text( + `Role: ${r.guardRole || 'guard'} Status: ${r.status} Gross Pay: $${(r.grossPay || 0).toFixed(2)} AUD` + ) + .text( + `Scheduled: ${r.totalScheduledHours}h Worked: ${r.totalWorkedHours}h Regular: ${r.totalRegularHours}h Overtime: ${r.totalOvertimeHours}h` + ); + + if (r.approvedBy) { + doc.text( + `Approved by: ${r.approvedBy.name || r.approvedBy} at: ${r.approvedAt ? new Date(r.approvedAt).toLocaleDateString('en-AU') : '-'}` + ); + } + + if (r.entries && r.entries.length > 0) { + doc.moveDown(0.4); + doc.fontSize(8).font('Helvetica-Bold'); + + const COL = { + date: 55, + sched: 130, + actual: 185, + reg: 235, + ot: 280, + rate: 325, + rPay: 375, + otPay: 425, + total: 475, + }; + const tableHeaderY = doc.y; + + doc.text('Date', COL.date, tableHeaderY, { continued: true }); + doc.text('Sched h', COL.sched, tableHeaderY, { continued: true }); + doc.text('Actual h', COL.actual, tableHeaderY, { continued: true }); + doc.text('Reg h', COL.reg, tableHeaderY, { continued: true }); + doc.text('OT h', COL.ot, tableHeaderY, { continued: true }); + doc.text('Rate', COL.rate, tableHeaderY, { continued: true }); + doc.text('Reg Pay', COL.rPay, tableHeaderY, { continued: true }); + doc.text('OT Pay', COL.otPay, tableHeaderY, { continued: true }); + doc.text('Total Pay', COL.total, tableHeaderY); + + doc.font('Helvetica').fontSize(8); + + for (const e of r.entries) { + if (doc.y > 740) doc.addPage(); + const rowY = doc.y; + + doc.text(new Date(e.shiftDate).toLocaleDateString('en-AU'), COL.date, rowY, { continued: true }); + doc.text(String(e.scheduledHours), COL.sched, rowY, { continued: true }); + doc.text(String(e.actualHours), COL.actual, rowY, { continued: true }); + doc.text(String(e.regularHours), COL.reg, rowY, { continued: true }); + doc.text(String(e.overtimeHours), COL.ot, rowY, { continued: true }); + doc.text(`$${e.payRate}`, COL.rate, rowY, { continued: true }); + doc.text(`$${(e.regularPay || 0).toFixed(2)}`, COL.rPay, rowY, { continued: true }); + doc.text(`$${(e.overtimePay || 0).toFixed(2)}`, COL.otPay, rowY, { continued: true }); + doc.text(`$${(e.totalPay || 0).toFixed(2)}`, COL.total, rowY); + } + } + + doc.moveDown(0.8); + doc.moveTo(50, doc.y).lineTo(545, doc.y).dash(3, { space: 3 }).stroke(); + doc.undash().moveDown(0.5); + } + + doc + .fontSize(8) + .fillColor('grey') + .text(`SecureShift Confidential – Generated ${new Date().toISOString()}`, { + align: 'center', + }); + + doc.end(); +} + +// POST /api/v1/payroll/attendance +/** + * Create or update a ShiftAttendance record. + * Admins/branch_admins can record on behalf of any guard. + * Guards can only record their own attendance. + * + * Body: + * shiftId – ObjectId (required) + * guardId – ObjectId (required for admin; defaults to self for guard) + * clockIn – ISO datetime string (optional) + * clockOut – ISO datetime string (optional) + * status – 'absent' | 'scheduled' (optional override) + * notes – string (optional) + */ +export const recordAttendance = async (req, res) => { + try { + let { shiftId, guardId, clockIn, clockOut, status, notes } = req.body; + + if (!shiftId || !mongoose.isValidObjectId(shiftId)) { + return res.status(400).json({ message: 'shiftId is required and must be a valid ObjectId' }); + } + + // Role-based access control: guards can only record attendance for their own shifts + const callerRole = req.user.role; + const callerId = String(req.user._id || req.user.id); + + if (callerRole === 'guard') { + guardId = callerId; + } else { + if (!guardId || !mongoose.isValidObjectId(guardId)) { + return res.status(400).json({ message: 'guardId is required and must be a valid ObjectId' }); + } + } + + // Verify the shift exists and is assigned to the specified guard + const shift = await Shift.findById(shiftId).lean(); + if (!shift) { + return res.status(404).json({ message: 'Shift not found' }); + } + if (String(shift.acceptedBy) !== String(guardId)) { + return res.status(400).json({ message: 'Guard is not assigned to this shift' }); + } + + // Build scheduled start/end datetimes from shift date + times + const shiftDate = new Date(shift.date); + + const [startH, startM] = shift.startTime.split(':').map(Number); + const scheduledStart = new Date(shiftDate); + scheduledStart.setHours(startH, startM, 0, 0); + + const [endH, endM] = shift.endTime.split(':').map(Number); + const scheduledEnd = new Date(shiftDate); + scheduledEnd.setHours(endH, endM, 0, 0); + if (scheduledEnd <= scheduledStart) { + scheduledEnd.setDate(scheduledEnd.getDate() + 1); + } + + // Parse and validate clockIn/clockOut datetimes + const parsedClockIn = clockIn ? new Date(clockIn) : undefined; + const parsedClockOut = clockOut ? new Date(clockOut) : undefined; + + if (parsedClockIn && isNaN(parsedClockIn.getTime())) { + return res.status(400).json({ message: 'clockIn is not a valid datetime' }); + } + if (parsedClockOut && isNaN(parsedClockOut.getTime())) { + return res.status(400).json({ message: 'clockOut is not a valid datetime' }); + } + if (parsedClockIn && parsedClockOut && parsedClockOut <= parsedClockIn) { + return res.status(400).json({ message: 'clockOut must be after clockIn' }); + } + + // Determine attendance status based on provided data + const update = { + scheduledStart, + scheduledEnd, + recordedBy: callerId, + }; + if (parsedClockIn !== undefined) update.clockIn = parsedClockIn; + if (parsedClockOut !== undefined) update.clockOut = parsedClockOut; + if (status) update.status = status; + if (notes !== undefined) update.notes = notes; + + const attendance = await ShiftAttendance.findOneAndUpdate( + { shift: shiftId, guard: guardId }, + { $set: update }, + { upsert: true, new: true, runValidators: true } + ); + + await req.audit.log(callerId, ACTIONS.ATTENDANCE_RECORDED, { + shiftId, + guardId, + status: attendance.status, + hoursWorked: attendance.hoursWorked, + }); + + return res.status(200).json({ + message: 'Attendance recorded', + attendance, }); + } catch (err) { + if (err.code === 11000) { + return res.status(409).json({ message: 'Attendance record already exists for this shift/guard' }); + } + console.error('[recordAttendance]', err); + return res.status(500).json({ message: err.message }); + } +}; + +// GET /api/v1/payroll/attendance/:shiftId +/** + * Get attendance records for a specific shift. + * Admins/branch_admins can view attendance for any shift. + * Guards can only view attendance for their own shifts. + */ +export const getAttendanceForShift = async (req, res) => { + try { + const { shiftId } = req.params; + + if (!mongoose.isValidObjectId(shiftId)) { + return res.status(400).json({ message: 'shiftId must be a valid ObjectId' }); + } + + const shift = await Shift.findById(shiftId).lean(); + if (!shift) return res.status(404).json({ message: 'Shift not found' }); + + const query = { shift: shiftId }; + + if (req.user.role === 'guard') { + query.guard = req.user._id || req.user.id; + } + + const records = await ShiftAttendance.find(query) + .populate('guard', 'name email role') + .populate('recordedBy', 'name email') + .lean(); + + return res.json({ shiftId, count: records.length, records }); + } catch (err) { + console.error('[getAttendanceForShift]', err); + return res.status(500).json({ message: err.message }); } }; \ No newline at end of file diff --git a/app-backend/src/middleware/logger.js b/app-backend/src/middleware/logger.js index 6f6d8e8ce..b626e85d9 100644 --- a/app-backend/src/middleware/logger.js +++ b/app-backend/src/middleware/logger.js @@ -13,22 +13,31 @@ export const ACTIONS = { SHIFT_UPDATED: 'SHIFT_UPDATED', SHIFT_APPLIED: 'SHIFT_APPLIED', SHIFT_APPROVED: 'SHIFT_APPROVED', + SHIFT_ASSIGNED: 'SHIFT_ASSIGNED', SHIFT_COMPLETED: 'SHIFT_COMPLETED', VIEW_USERS: 'VIEW_USERS', VIEW_SHIFTS: 'VIEW_SHIFTS', MESSAGE_SENT: 'MESSAGE_SENT', - MESSAGE_READ: 'MESSAGE_READ', + MESSAGE_READ: 'MESSAGE_READ', MESSAGE_SOFT_DELETED: 'MESSAGE_SOFT_DELETED', USER_SOFT_DELETED: 'USER_SOFT_DELETED', + USER_DELETED: 'USER_DELETED', AVAILABILITY_UPDATED: 'AVAILABILITY_UPDATED', RATINGS_SUBMITTED: 'RATINGS_SUBMITTED', SITE_CREATED: 'SITE_CREATED', SITE_UPDATED: 'SITE_UPDATED', SITE_DELETED: 'SITE_DELETED', + + // Payroll actions + PAYROLL_GENERATED: 'PAYROLL_GENERATED', + PAYROLL_APPROVED: 'PAYROLL_APPROVED', + PAYROLL_PROCESSED: 'PAYROLL_PROCESSED', + PAYROLL_EXPORTED: 'PAYROLL_EXPORTED', + ATTENDANCE_RECORDED: 'ATTENDANCE_RECORDED', }; // Middleware to attach audit logging function to req diff --git a/app-backend/src/models/Message.js b/app-backend/src/models/Message.js index d5e9fe7c0..bb58255ec 100644 --- a/app-backend/src/models/Message.js +++ b/app-backend/src/models/Message.js @@ -5,49 +5,49 @@ const messageSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'User', required: [true, 'Sender is required'], - index: true + // index removed — covered by compound index below }, receiver: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: [true, 'Receiver is required'], - index: true + // index removed — covered by compound index below }, content: { type: String, required: [true, 'Message content is required'], trim: true, maxLength: [1000, 'Message content cannot exceed 1000 characters'], - minLength: [1, 'Message content cannot be empty'] + minLength: [1, 'Message content cannot be empty'], }, timestamp: { type: Date, default: Date.now, - index: true + // index removed — covered by compound index below }, isRead: { type: Boolean, - default: false + default: false, }, conversationId: { type: String, - index: true + // index removed — covered by compound index below }, // soft delete fields - isDeleted: { type: Boolean, default: false, index: true }, // marks message as soft-deleted (hidden but not removed) - deletedAt: { type: Date, default: null }, // when it was hidden - deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, // who did it - deleteReason: { type: String, default: null }, // optional reason + isDeleted: { type: Boolean, default: false }, // index removed — covered by compound index below + deletedAt: { type: Date, default: null }, + deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, + deleteReason: { type: String, default: null }, }, { timestamps: true, toJSON: { virtuals: true }, - toObject: { virtuals: true } + toObject: { virtuals: true }, }); // Create conversation ID based on sorted user IDs for consistent grouping -messageSchema.pre('save', function(next) { +messageSchema.pre('save', function (next) { if (!this.conversationId) { const ids = [this.sender.toString(), this.receiver.toString()].sort(); this.conversationId = `${ids[0]}_${ids[1]}`; @@ -55,7 +55,7 @@ messageSchema.pre('save', function(next) { next(); }); -// Compound indexes for efficient queries +// Compound indexes — these are the only index definitions needed messageSchema.index({ sender: 1, timestamp: -1 }); messageSchema.index({ receiver: 1, timestamp: -1 }); messageSchema.index({ conversationId: 1, isDeleted: 1, timestamp: -1 }); @@ -63,15 +63,15 @@ messageSchema.index({ receiver: 1, isRead: 1 }); messageSchema.index({ isDeleted: 1, timestamp: -1 }); // Virtual for message age -messageSchema.virtual('age').get(function() { +messageSchema.virtual('age').get(function () { return Date.now() - this.timestamp; }); // Static method to get conversation between two users -messageSchema.statics.getConversation = function(userId1, userId2, limit = 50){ +messageSchema.statics.getConversation = function (userId1, userId2, limit = 50) { const ids = [userId1.toString(), userId2.toString()].sort(); const conversationId = `${ids[0]}_${ids[1]}`; - + return this.find({ conversationId, isDeleted: { $ne: true } }) .populate('sender', 'email name role') .populate('receiver', 'email name role') @@ -80,23 +80,23 @@ messageSchema.statics.getConversation = function(userId1, userId2, limit = 50){ }; // Static method to mark messages as read -messageSchema.statics.markAsRead = function(receiverId, senderId) { +messageSchema.statics.markAsRead = function (receiverId, senderId) { const ids = [receiverId.toString(), senderId.toString()].sort(); const conversationId = `${ids[0]}_${ids[1]}`; - + return this.updateMany( - { + { conversationId, receiver: receiverId, isRead: false, - isDeleted: { $ne: true }, + isDeleted: { $ne: true }, }, { isRead: true } ); }; // Static method to get unread message count -messageSchema.statics.getUnreadCount = function(userId) { +messageSchema.statics.getUnreadCount = function (userId) { return this.countDocuments({ receiver: userId, isRead: false, @@ -104,6 +104,6 @@ messageSchema.statics.getUnreadCount = function(userId) { }); }; -const Message = mongoose.model('Message', messageSchema); +const Message = mongoose.model('Message', messageSchema); -export default Message; \ No newline at end of file +export default Message; diff --git a/app-backend/src/models/Payroll.js b/app-backend/src/models/Payroll.js new file mode 100644 index 000000000..08dc24238 --- /dev/null +++ b/app-backend/src/models/Payroll.js @@ -0,0 +1,121 @@ +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +/** + * A single shift's payroll contribution for one guard. + * Stored as a sub-document inside a Payroll record. + */ +const payrollEntrySchema = new Schema( + { + /** Reference to the completed Shift */ + shift: { type: Schema.Types.ObjectId, ref: 'Shift', required: true }, + + /** Reference to the ShiftAttendance record (null if no attendance was recorded) */ + attendance: { type: Schema.Types.ObjectId, ref: 'ShiftAttendance', default: null }, + + shiftDate: { type: Date, required: true }, + + /** Hours derived from Shift.startTime / endTime */ + scheduledHours: { type: Number, default: 0, min: 0 }, + + /** Hours actually worked (from attendance or fallback to scheduled) */ + actualHours: { type: Number, default: 0, min: 0 }, + + /** Regular (non-overtime) hours */ + regularHours: { type: Number, default: 0, min: 0 }, + + /** Overtime hours for this shift (daily OT + weekly OT apportionment) */ + overtimeHours: { type: Number, default: 0, min: 0 }, + + /** Hourly rate used (from Shift.payRate or system default) */ + payRate: { type: Number, default: 0, min: 0 }, + + regularPay: { type: Number, default: 0, min: 0 }, + overtimePay: { type: Number, default: 0, min: 0 }, + totalPay: { type: Number, default: 0, min: 0 }, + + /** Whether an attendance record existed for this shift */ + hasAttendanceRecord: { type: Boolean, default: false }, + + /** + * present | absent | incomplete | scheduled | no_record + * "no_record" = shift completed but no ShiftAttendance document exists + */ + attendanceStatus: { + type: String, + enum: ['present', 'absent', 'incomplete', 'scheduled', 'no_record'], + default: 'no_record', + }, + }, + { _id: false } +); + +/** + * Top-level Payroll document — one per guard per period. + * + * Status workflow: + * PENDING → APPROVED → PROCESSED + * + * Only PENDING payrolls can be regenerated / overwritten. + * Only PENDING can be approved, only APPROVED can be processed. + */ +const payrollSchema = new Schema( + { + guard: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + + period: { + type: { + type: String, + enum: ['daily', 'weekly', 'monthly'], + required: true, + }, + startDate: { type: Date, required: true }, + endDate: { type: Date, required: true }, + }, + + /** Per-shift breakdown */ + entries: { type: [payrollEntrySchema], default: [] }, + + // ── Aggregated totals ────────────────────────────────────────────────── + totalScheduledHours: { type: Number, default: 0, min: 0 }, + totalWorkedHours: { type: Number, default: 0, min: 0 }, + totalRegularHours: { type: Number, default: 0, min: 0 }, + totalOvertimeHours: { type: Number, default: 0, min: 0 }, + grossPay: { type: Number, default: 0, min: 0 }, + + // ── Workflow ─────────────────────────────────────────────────────────── + status: { + type: String, + enum: ['PENDING', 'APPROVED', 'PROCESSED'], + default: 'PENDING', + index: true, + }, + + approvedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, + approvedAt: { type: Date, default: null }, + processedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, + processedAt: { type: Date, default: null }, + + // ── Denormalised guard snapshot (for fast export / reporting) ────────── + guardName: { type: String, trim: true }, + guardEmail: { type: String, trim: true }, + guardRole: { type: String, trim: true }, + guardDepartment: { type: String, trim: true, default: null }, + }, + { timestamps: true } +); + +// One payroll record per guard × period +payrollSchema.index( + { guard: 1, 'period.type': 1, 'period.startDate': 1, 'period.endDate': 1 }, + { unique: true } +); + +const Payroll = model('Payroll', payrollSchema); +export default Payroll; diff --git a/app-backend/src/models/Role.js b/app-backend/src/models/Role.js index 2b362c3fa..8c4839ebf 100644 --- a/app-backend/src/models/Role.js +++ b/app-backend/src/models/Role.js @@ -17,7 +17,7 @@ const roleSchema = new mongoose.Schema( { timestamps: true } ); -roleSchema.index({ name: 1 }, { unique: true }); +// index removed — `unique: true` on the field already creates this index const Role = mongoose.model('Role', roleSchema); export default Role; \ No newline at end of file diff --git a/app-backend/src/models/ShiftAttendance.js b/app-backend/src/models/ShiftAttendance.js index 5da3c90c7..64df2064e 100644 --- a/app-backend/src/models/ShiftAttendance.js +++ b/app-backend/src/models/ShiftAttendance.js @@ -1,40 +1,112 @@ -import mongoose from "mongoose"; +import mongoose from 'mongoose'; -const shiftAttendanceSchema = new mongoose.Schema( +const { Schema, model } = mongoose; + +/** + * ShiftAttendance records actual clock-in / clock-out data for a guard on a shift. + * Used by the payroll engine to calculate actual hours worked vs. scheduled hours. + * + * Status lifecycle: + * scheduled → guard assigned but hasn't clocked in yet + * present → guard clocked in AND out (hoursWorked computed) + * incomplete → guard clocked in but never clocked out + * absent → explicitly marked absent (or guard never clocked in) + */ +const shiftAttendanceSchema = new Schema( { - guardId: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", + shift: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + + guard: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + + clockIn: { + type: Date, + default: null, + }, + + clockOut: { + type: Date, + default: null, + }, + + /** Scheduled start (denormalised from Shift for quick queries) */ + scheduledStart: { + type: Date, required: true, }, - shiftId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Shift", + + /** Scheduled end (denormalised from Shift) */ + scheduledEnd: { + type: Date, required: true, }, - siteLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { - type: [Number], // [longitude, latitude] - required: true, - }, + + /** Computed hours worked; set automatically in pre-save hook */ + hoursWorked: { + type: Number, + default: 0, + min: 0, }, - checkInTime: { type: Date, default: null }, - checkOutTime: { type: Date, default: null }, - checkInLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { type: [Number], default: [0, 0] }, + + status: { + type: String, + enum: ['scheduled', 'present', 'incomplete', 'absent'], + default: 'scheduled', + index: true, }, - checkOutLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { type: [Number], default: [0, 0] }, + + notes: { + type: String, + trim: true, + maxlength: 500, + }, + + /** Who recorded this attendance (admin/guard) */ + recordedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, }, - locationVerified: { type: Boolean, default: false }, }, { timestamps: true } ); -shiftAttendanceSchema.index({ siteLocation: "2dsphere" }); +// Prevent duplicate attendance records per shift/guard pair +shiftAttendanceSchema.index({ shift: 1, guard: 1 }, { unique: true }); + +/** + * Auto-compute hoursWorked and status based on clockIn / clockOut. + */ +shiftAttendanceSchema.pre('save', function (next) { + if (this.clockIn && this.clockOut) { + if (this.clockOut <= this.clockIn) { + return next(new Error('clockOut must be after clockIn')); + } + const diffMs = this.clockOut.getTime() - this.clockIn.getTime(); + this.hoursWorked = Math.round((diffMs / (1000 * 60 * 60)) * 100) / 100; + this.status = 'present'; + } else if (this.clockIn && !this.clockOut) { + this.status = 'incomplete'; + // Partial hours from clock-in to now (capped at scheduled end) + const now = new Date(); + const cap = this.scheduledEnd || now; + const diffMs = Math.min(now.getTime(), cap.getTime()) - this.clockIn.getTime(); + this.hoursWorked = diffMs > 0 ? Math.round((diffMs / (1000 * 60 * 60)) * 100) / 100 : 0; + } else if (!this.clockIn) { + this.status = 'absent'; + this.hoursWorked = 0; + } + next(); +}); -const ShiftAttendance = mongoose.model("ShiftAttendance", shiftAttendanceSchema); -export default ShiftAttendance; +const ShiftAttendance = model('ShiftAttendance', shiftAttendanceSchema); +export default ShiftAttendance; \ No newline at end of file diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index a09c65626..0c4d9cf3f 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -4,11 +4,10 @@ import healthRoutes from './health.routes.js'; import authRoutes from './auth.routes.js'; import shiftRoutes from './shift.routes.js'; import messageRoutes from './message.routes.js'; -import userRoutes from './user.routes.js'; +import userRoutes from './user.routes.js'; import adminRoutes from './admin.routes.js'; -import availabilityRoutes from './availability.routes.js'; +import availabilityRoutes from './availability.routes.js'; import rbacRoutes from './rbac.routes.js'; -import branchRoutes from './branch.routes.js'; import notificationRoutes from './notification.routes.js' import branchRoutes from './branch.routes.js' @@ -21,8 +20,8 @@ router.use('/auth', authRoutes); router.use('/shifts', shiftRoutes); router.use('/messages', messageRoutes); router.use('/admin', adminRoutes); -router.use('/availability', availabilityRoutes); -router.use('/users', userRoutes); +router.use('/availability', availabilityRoutes); +router.use('/users', userRoutes); router.use('/rbac', rbacRoutes); router.use('/branch', branchRoutes); router.use('/notifications', notificationRoutes); diff --git a/app-backend/src/routes/payroll.routes.js b/app-backend/src/routes/payroll.routes.js index b04d33a34..2cf9709c5 100644 --- a/app-backend/src/routes/payroll.routes.js +++ b/app-backend/src/routes/payroll.routes.js @@ -1,28 +1,50 @@ -import express from "express"; -import auth from "../middleware/auth.js"; -import { getPayrollSummary } from "../controllers/payroll.controller.js"; +/** + * @file routes/payroll.routes.js + * @description Payroll API routes for SecureShift. + * + * Base path: /api/v1/payroll + * + * ┌──────────────────────────────────────────────────────────────────────────┐ + * │ METHOD PATH ROLES ALLOWED │ + * ├──────────────────────────────────────────────────────────────────────────┤ + * │ GET / admin, branch_admin, employer, │ + * │ super_admin, guard (own only) │ + * │ POST /approve admin, branch_admin, super_admin │ + * │ POST /process admin, super_admin │ + * │ GET /export admin, branch_admin, super_admin, │ + * │ employer, guard (own only) │ + * │ POST /attendance admin, branch_admin, guard (self) │ + * │ GET /attendance/:shiftId admin, branch_admin, employer, │ + * │ guard (own only) │ + * └──────────────────────────────────────────────────────────────────────────┘ + */ + +import { Router } from 'express'; +import auth from '../middleware/auth.js'; +import { authorizeRoles } from '../middleware/rbac.js'; +import { auditMiddleware } from '../middleware/logger.js'; +import { + getPayroll, + approvePayroll, + processPayroll, + exportPayroll, + recordAttendance, + getAttendanceForShift, +} from '../controllers/payroll.controller.js'; -const router = express.Router(); +const router = Router(); -const authorizeRole = (...allowedRoles) => (req, res, next) => { - if (!req.user || !allowedRoles.includes(req.user.role)) { - return res.status(403).json({ - message: "Forbidden: insufficient permissions", - }); - } - next(); -}; +// All payroll routes require a valid JWT +router.use(auth); + +// Attach audit logging helper to every request +router.use(auditMiddleware); /** * @swagger * /api/v1/payroll: * get: - * summary: Retrieve payroll summary for guards and employees - * description: | - * Role access: - * - Admin: can fetch payroll summaries for all guards, optionally filtered - * - Employer: can fetch payroll summaries only for completed shifts they created - * - Guard: can fetch only their own payroll summary + * summary: Retrieve and generate payroll summaries * tags: [Payroll] * security: * - bearerAuth: [] @@ -30,54 +52,208 @@ const authorizeRole = (...allowedRoles) => (req, res, next) => { * - in: query * name: startDate * required: true - * schema: - * type: string - * format: date - * description: Start date in ISO format YYYY-MM-DD + * schema: { type: string, format: date } + * example: "2025-06-01" * - in: query * name: endDate * required: true - * schema: - * type: string - * format: date - * description: End date in ISO format YYYY-MM-DD + * schema: { type: string, format: date } + * example: "2025-06-30" * - in: query * name: periodType * required: true - * schema: - * type: string - * enum: [daily, weekly, monthly] - * description: Aggregation type for payroll summaries + * schema: { type: string, enum: [daily, weekly, monthly] } * - in: query * name: guardId - * required: false - * schema: - * type: string - * description: Optional filter for a specific guard + * schema: { type: string } + * description: Filter by a specific guard (optional; guards are automatically scoped to self) * - in: query - * name: site - * required: false - * schema: - * type: string - * description: Optional filter for a specific site + * name: department + * schema: { type: string } + * description: Filter by branch/department (optional) + * responses: + * 200: + * description: Payroll summary with per-guard breakdown + * 400: + * description: Validation error + */ +router.get( + '/', + authorizeRoles('super_admin', 'admin', 'branch_admin', 'employer', 'guard'), + getPayroll +); + +/** + * @swagger + * /api/v1/payroll/approve: + * post: + * summary: Approve one or more PENDING payroll records + * tags: [Payroll] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [payrollIds] + * properties: + * payrollIds: + * type: array + * items: { type: string } + * example: ["665f1a2b3c4d5e6f7a8b9c0d"] + * responses: + * 200: + * description: Records approved + * 400: + * description: Validation error or wrong status + * 404: + * description: One or more records not found + */ +router.post( + '/approve', + authorizeRoles('super_admin', 'admin', 'branch_admin'), + approvePayroll +); + +/** + * @swagger + * /api/v1/payroll/process: + * post: + * summary: Mark one or more APPROVED payroll records as PROCESSED + * tags: [Payroll] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [payrollIds] + * properties: + * payrollIds: + * type: array + * items: { type: string } + * responses: + * 200: + * description: Records processed + * 400: + * description: Validation error or wrong status + */ +router.post( + '/process', + authorizeRoles('super_admin', 'admin'), + processPayroll +); + +/** + * @swagger + * /api/v1/payroll/export: + * get: + * summary: Export payroll data as CSV or PDF + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: startDate + * required: true + * schema: { type: string, format: date } + * - in: query + * name: endDate + * required: true + * schema: { type: string, format: date } + * - in: query + * name: periodType + * required: true + * schema: { type: string, enum: [daily, weekly, monthly] } + * - in: query + * name: format + * schema: { type: string, enum: [csv, pdf], default: csv } + * - in: query + * name: guardId + * schema: { type: string } * - in: query * name: department - * required: false - * schema: - * type: string - * description: Optional filter for a specific department + * schema: { type: string } + * - in: query + * name: status + * schema: { type: string, enum: [PENDING, APPROVED, PROCESSED] } + * responses: + * 200: + * description: File download (text/csv or application/pdf) + * 400: + * description: Validation error + */ +router.get( + '/export', + authorizeRoles('super_admin', 'admin', 'branch_admin', 'employer', 'guard'), + exportPayroll +); + +/** + * @swagger + * /api/v1/payroll/attendance: + * post: + * summary: Record clock-in / clock-out attendance for a shift + * tags: [Payroll] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [shiftId] + * properties: + * shiftId: { type: string } + * guardId: { type: string, description: "Required for admin; guards default to self" } + * clockIn: { type: string, format: date-time } + * clockOut: { type: string, format: date-time } + * status: { type: string, enum: [absent, scheduled] } + * notes: { type: string } + * responses: + * 200: + * description: Attendance recorded / updated + * 400: + * description: Validation error + * 404: + * description: Shift not found + */ +router.post( + '/attendance', + authorizeRoles('super_admin', 'admin', 'branch_admin', 'guard'), + recordAttendance +); + +/** + * @swagger + * /api/v1/payroll/attendance/{shiftId}: + * get: + * summary: Retrieve attendance records for a specific shift + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: shiftId + * required: true + * schema: { type: string } * responses: * 200: - * description: Payroll summary retrieved successfully + * description: List of attendance records * 400: - * description: Invalid request parameters - * 401: - * description: Unauthorised - * 403: - * description: Forbidden - * 500: - * description: Server error + * description: Invalid shiftId + * 404: + * description: Shift not found */ -router.get("/", auth, authorizeRole("admin", "employer", "guard"), getPayrollSummary); +router.get( + '/attendance/:shiftId', + authorizeRoles('super_admin', 'admin', 'branch_admin', 'employer', 'guard'), + getAttendanceForShift +); export default router; \ No newline at end of file diff --git a/app-backend/src/services/payroll.service.js b/app-backend/src/services/payroll.service.js index 55e638ef7..250b2367d 100644 --- a/app-backend/src/services/payroll.service.js +++ b/app-backend/src/services/payroll.service.js @@ -1,279 +1,402 @@ -import Shift from "../models/Shift.js"; -import ShiftAttendance from "../models/ShiftAttendance.js"; - -const ISO_DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; - -const isValidISODateOnly = (value) => { - if (!ISO_DATE_ONLY_REGEX.test(value)) { - return false; - } - - const date = new Date(`${value}T00:00:00.000Z`); - if (Number.isNaN(date.getTime())) { - return false; - } - - return date.toISOString().slice(0, 10) === value; -}; - -const getWeekStart = (dateValue) => { - const date = new Date(dateValue); - const day = date.getUTCDay(); - const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1); - - date.setUTCDate(diff); - date.setUTCHours(0, 0, 0, 0); - - return date; +/** + * @file services/payroll.service.js + * @description Core payroll calculation engine for SecureShift. + * + * Overtime rules (Australian Fair Work standard): + * - Daily : hours worked > 8 h/day → excess is overtime + * - Weekly : cumulative regular hours > 38 h/week → excess is overtime + * Both rules are applied together; the more-generous overtime wins per shift. + * + * Fallback behaviour: + * If no ShiftAttendance record exists for a completed shift, the engine + * falls back to the shift's scheduled hours (startTime → endTime). + */ + +import mongoose from 'mongoose'; +import Shift from '../models/Shift.js'; +import ShiftAttendance from '../models/ShiftAttendance.js'; +import User from '../models/User.js'; +import Payroll from '../models/Payroll.js'; + +// ─── Configuration constants ────────────────────────────────────────────────── + +export const PAYROLL_CONFIG = { + /** Default hourly rate (AUD) when Shift.payRate is not set */ + DEFAULT_HOURLY_RATE: 25, + + /** Overtime pay multiplier (1.5× = time-and-a-half) */ + OVERTIME_MULTIPLIER: 1.5, + + /** Hours per day before overtime kicks in */ + DAILY_OT_THRESHOLD: 8, + + /** Cumulative regular hours per week before weekly overtime kicks in (AU: 38 h) */ + WEEKLY_OT_THRESHOLD: 38, }; -const formatPeriodLabel = (dateValue, periodType) => { +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** Round to 2 decimal places */ +const r2 = (n) => Math.round((n || 0) * 100) / 100; + +/** + * Convert "HH:MM" (24-hour) string → fractional hours since midnight. + * Returns 0 on bad input. + */ +function hhmmToHours(hhmm) { + if (typeof hhmm !== 'string') return 0; + const m = hhmm.match(/^([0-1]\d|2[0-3]):([0-5]\d)$/); + if (!m) return 0; + return parseInt(m[1], 10) + parseInt(m[2], 10) / 60; +} + +/** + * Calculate scheduled duration of a shift in hours. + * Correctly handles overnight shifts (e.g. 22:00 → 06:00 = 8 h). + * + * @param {object} shift – Shift document (needs startTime, endTime) + * @returns {number} Duration in fractional hours + */ +export function calcScheduledHours(shift) { + const startH = hhmmToHours(shift.startTime); + const endH = hhmmToHours(shift.endTime); + let duration = endH - startH; + if (duration <= 0) duration += 24; // overnight span + return r2(duration); +} + +/** + * Build inclusive UTC date boundaries for a query. + * start → midnight of startDate, end → 23:59:59.999 of endDate. + */ +export function buildDateRange(startDate, endDate) { + const start = new Date(startDate); + start.setHours(0, 0, 0, 0); + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + return { start, end }; +} + +/** + * Get the ISO week key (Monday-start) for a given date. + * @param {string|Date} dateValue – Date or ISO date string + * @returns {string} ISO date string for the Monday of the week + */ +function getWeekKey(dateValue) { const date = new Date(dateValue); + const utcDay = date.getUTCDay(); // 0 = Sun, 1 = Mon, ... + const diffToMonday = utcDay === 0 ? -6 : 1 - utcDay; + + const monday = new Date(date); + monday.setUTCDate(date.getUTCDate() + diffToMonday); + monday.setUTCHours(0, 0, 0, 0); + + return monday.toISOString().slice(0, 10); +} + +// ─── Overtime engine ────────────────────────────────────────────────────────── + +/** + * Apply daily and weekly overtime rules to an array of per-shift entries. + * + * Entries MUST be sorted by shiftDate ascending before calling this function. + * Weekly accumulation resets per calendar week (Monday-start). + * + * @param {Array} rawEntries – Each entry needs: actualHours, payRate, shiftDate + * @returns {Array} New array with regularHours, overtimeHours, regularPay, + * overtimePay, totalPay filled in. + */ +function applyOvertimeRules(rawEntries) { + const { + OVERTIME_MULTIPLIER, + DAILY_OT_THRESHOLD, + WEEKLY_OT_THRESHOLD, + } = PAYROLL_CONFIG; + + let currentWeekKey = null; + let weeklyRegularAccum = 0; + + return rawEntries.map((entry) => { + const { actualHours, payRate, shiftDate } = entry; + const weekKey = getWeekKey(shiftDate); + + if (weekKey !== currentWeekKey) { + currentWeekKey = weekKey; + weeklyRegularAccum = 0; + } - if (periodType === "daily") { - return date.toISOString().split("T")[0]; - } - - if (periodType === "weekly") { - const weekStart = getWeekStart(date); - return `week-of-${weekStart.toISOString().split("T")[0]}`; - } - - if (periodType === "monthly") { - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; - } - - return "unknown"; -}; - -const calculateScheduledHours = (shift) => { - if (!shift.startTime || !shift.endTime || !shift.date) { - return 0; - } - - const [startHour, startMinute] = String(shift.startTime).split(":").map(Number); - const [endHour, endMinute] = String(shift.endTime).split(":").map(Number); - - if ( - Number.isNaN(startHour) || - Number.isNaN(startMinute) || - Number.isNaN(endHour) || - Number.isNaN(endMinute) - ) { - return 0; - } - - const scheduledStart = new Date(shift.date); - const scheduledEnd = new Date(shift.date); - - scheduledStart.setHours(startHour, startMinute, 0, 0); - scheduledEnd.setHours(endHour, endMinute, 0, 0); + // Daily overtime calculation + const dailyRegular = Math.min(actualHours, DAILY_OT_THRESHOLD); + const dailyOT = Math.max(0, actualHours - DAILY_OT_THRESHOLD); - if (scheduledEnd <= scheduledStart) { - scheduledEnd.setDate(scheduledEnd.getDate() + 1); - } + // Weekly overtime calculation + const remainingWeeklyCap = Math.max(0, WEEKLY_OT_THRESHOLD - weeklyRegularAccum); + const weeklyRegular = Math.min(dailyRegular, remainingWeeklyCap); + const weeklyOT = dailyRegular - weeklyRegular; - return (scheduledEnd - scheduledStart) / (1000 * 60 * 60); -}; + // Final combination: daily + weekly OT, with the more-generous rule winning + const finalOT = r2(dailyOT + weeklyOT); + const finalRegular = r2(actualHours - finalOT); -export const buildPayrollSummary = async (query, user) => { - const { startDate, endDate, periodType, guardId, site, department } = query; + weeklyRegularAccum += weeklyRegular; - if (!user?._id || !user?.role) { - throw new Error("Unauthorised user context"); - } + const regularPay = r2(finalRegular * payRate); + const overtimePay = r2(finalOT * payRate * OVERTIME_MULTIPLIER); + const totalPay = r2(regularPay + overtimePay); - if (!startDate || !endDate || !periodType) { - throw new Error("startDate, endDate, and periodType are required"); - } - - if (!isValidISODateOnly(startDate) || !isValidISODateOnly(endDate)) { - throw new Error("startDate and endDate must be valid ISO dates in YYYY-MM-DD format"); + return { + ...entry, + regularHours: finalRegular, + overtimeHours: finalOT, + regularPay, + overtimePay, + totalPay, + }; + }); +} + +// ─── Guard payroll calculation ──────────────────────────────────────────────── + +/** + * Calculate payroll data for a single guard over a date range. + * Does NOT persist anything – pure calculation. + * + * @param {string|ObjectId} guardId + * @param {Date} startDate + * @param {Date} endDate + * @param {string} periodType – 'daily' | 'weekly' | 'monthly' + * @returns {object} Payroll data object + */ +export async function calculateGuardPayroll(guardId, startDate, endDate, periodType) { + const guard = await User.findById(guardId) + .select('name email role branch') + .lean(); + + if (!guard) throw new Error(`Guard not found: ${guardId}`); + + // Find all completed shifts for this guard in the period + const shifts = await Shift.find({ + acceptedBy: guardId, + status: 'completed', + date: { $gte: startDate, $lte: endDate }, + }) + .sort({ date: 1 }) + .lean(); + + if (!shifts.length) { + return buildEmptyPayroll(guard, periodType, startDate, endDate); } - const allowedPeriods = ["daily", "weekly", "monthly"]; - if (!allowedPeriods.includes(periodType)) { - throw new Error("periodType must be daily, weekly, or monthly"); + // Find all attendance records for these shifts + guard (should be ≤ shifts.length) + const shiftIds = shifts.map((s) => s._id); + const attendances = await ShiftAttendance.find({ + shift: { $in: shiftIds }, + guard: guardId, + }).lean(); + + // Index attendance records by shift for easy lookup + const attByShift = {}; + for (const att of attendances) { + attByShift[att.shift.toString()] = att; } - const start = new Date(`${startDate}T00:00:00.000Z`); - const end = new Date(`${endDate}T23:59:59.999Z`); + // Build raw entries with scheduled hours, actual hours (from attendance or fallback), + // and pay rate (from shift or default). Overtime fields are filled in later. + const rawEntries = shifts.map((shift) => { + const scheduledHours = calcScheduledHours(shift); + const payRate = + shift.payRate != null && shift.payRate >= 0 + ? shift.payRate + : PAYROLL_CONFIG.DEFAULT_HOURLY_RATE; + const att = attByShift[shift._id.toString()] ?? null; + + let actualHours; + const hasAttendanceRecord = Boolean(att); + let attendanceStatus; + + if (!att) { + actualHours = scheduledHours; + attendanceStatus = 'no_record'; + } else if (att.status === 'present' && att.hoursWorked > 0) { + actualHours = att.hoursWorked; + attendanceStatus = 'present'; + } else if (att.status === 'absent') { + actualHours = 0; + attendanceStatus = 'absent'; + } else if (att.status === 'incomplete') { + actualHours = att.hoursWorked > 0 ? att.hoursWorked : scheduledHours; + attendanceStatus = 'incomplete'; + } else { + actualHours = scheduledHours; + attendanceStatus = att.status || 'scheduled'; + } - if (start > end) { - throw new Error("startDate cannot be after endDate"); - } + return { + shift: shift._id, + attendance: att?._id ?? null, + shiftDate: shift.date, + scheduledHours: r2(scheduledHours), + actualHours: r2(actualHours), + payRate, + regularHours: 0, + overtimeHours: 0, + regularPay: 0, + overtimePay: 0, + totalPay: 0, + hasAttendanceRecord, + attendanceStatus, + }; + }); - const shiftQuery = { - status: "completed", - date: { - $gte: start, - $lte: end, + // Apply overtime rules to the raw entries to get final regular/overtime split and pay calculations + const entries = applyOvertimeRules(rawEntries); + + const totals = entries.reduce( + (acc, e) => { + acc.totalScheduledHours += e.scheduledHours; + acc.totalWorkedHours += e.actualHours; + acc.totalRegularHours += e.regularHours; + acc.totalOvertimeHours += e.overtimeHours; + acc.grossPay += e.totalPay; + return acc; }, - }; - - // Role-based access rules - if (user.role === "admin") { - if (guardId) { - shiftQuery.acceptedBy = guardId; + { + totalScheduledHours: 0, + totalWorkedHours: 0, + totalRegularHours: 0, + totalOvertimeHours: 0, + grossPay: 0, } - } else if (user.role === "employer") { - // current scoping uses shift ownership - shiftQuery.createdBy = user._id; + ); - if (guardId) { - shiftQuery.acceptedBy = guardId; - } - } else if (user.role === "guard") { - shiftQuery.acceptedBy = user._id; + Object.keys(totals).forEach((k) => { + totals[k] = r2(totals[k]); + }); - if (guardId && String(guardId) !== String(user._id)) { - throw new Error("Guards can only access their own payroll summary"); - } - } else { - throw new Error("Forbidden: unsupported role"); - } + return { + guardId: guard._id, + guardName: guard.name, + guardEmail: guard.email, + guardRole: guard.role, + guardDepartment: guard.branch?.toString() ?? null, + period: { type: periodType, startDate, endDate }, + entries, + ...totals, + }; +} - if (site) { - shiftQuery.location = site; +/** Produces an empty payroll shell (guard has no completed shifts in the period) */ +function buildEmptyPayroll(guard, periodType, startDate, endDate) { + return { + guardId: guard._id, + guardName: guard.name, + guardEmail: guard.email, + guardRole: guard.role, + guardDepartment: guard.branch?.toString() ?? null, + period: { type: periodType, startDate, endDate }, + entries: [], + totalScheduledHours: 0, + totalWorkedHours: 0, + totalRegularHours: 0, + totalOvertimeHours: 0, + grossPay: 0, + }; +} + +// ─── Batch generation + persistence ────────────────────────────────────────── + +/** + * Generate payroll for all matching guards and upsert PENDING records. + * Already-APPROVED or -PROCESSED records are returned as-is (never overwritten). + * + * @param {object} filters – { startDate, endDate, periodType, guardId?, department? } + * @returns {Promise} Array of Payroll documents (saved or pre-existing) + */ +export async function generateAndPersistPayroll(filters) { + const { startDate, endDate, periodType, guardId, department } = filters; + const { start, end } = buildDateRange(startDate, endDate); + + // Find all guards matching the filters (and not deleted) + const guardFilter = { role: 'guard', isDeleted: { $ne: true } }; + + if (guardId) { + if (!mongoose.isValidObjectId(guardId)) { + throw new Error('Invalid guardId format'); + } + guardFilter._id = new mongoose.Types.ObjectId(guardId); } - // depends on current Shift model support if (department) { - shiftQuery.field = department; + guardFilter.branch = mongoose.isValidObjectId(department) + ? new mongoose.Types.ObjectId(department) + : department; } - const shifts = await Shift.find(shiftQuery); - const shiftIds = shifts.map((shift) => shift._id); + const guards = await User.find(guardFilter).select('_id').lean(); - const attendanceRecords = await ShiftAttendance.find({ - shiftId: { $in: shiftIds }, - }); - - const attendanceMap = new Map(); - for (const record of attendanceRecords) { - attendanceMap.set(String(record.shiftId), record); - } + if (!guards.length) return []; - const payrollDetails = shifts.map((shift) => { - const attendance = attendanceMap.get(String(shift._id)); + const results = []; - const checkInTime = attendance?.checkInTime ? new Date(attendance.checkInTime) : null; - const checkOutTime = attendance?.checkOutTime ? new Date(attendance.checkOutTime) : null; + for (const { _id } of guards) { + try { + const existingLocked = await Payroll.findOne({ + guard: _id, + 'period.type': periodType, + 'period.startDate': start, + 'period.endDate': end, + status: { $in: ['APPROVED', 'PROCESSED'] }, + }).lean(); - let totalHours = 0; - let overtimeHours = 0; - let pendingApproval = 0; - let underworkedShift = 0; - - const scheduledHours = calculateScheduledHours(shift); - - if (checkInTime && checkOutTime) { - totalHours = (checkOutTime - checkInTime) / (1000 * 60 * 60); - overtimeHours = Math.max(0, totalHours - 8); - - if (scheduledHours > 0 && totalHours < scheduledHours) { - underworkedShift = 1; + if (existingLocked) { + results.push(existingLocked); + continue; } - } else { - pendingApproval = 1; - } - - return { - shiftId: shift._id, - guardId: shift.acceptedBy || null, - guardName: shift.guardName || null, - employerId: shift.createdBy || null, - location: shift.location || null, - department: shift.field || null, - date: shift.date || null, - scheduledHours, - totalHours, - overtimeHours, - underworkedShift, - pendingApproval, - attendanceStatus: checkInTime && checkOutTime ? "complete" : "pending_review", - }; - }); - const guardSummaryMap = new Map(); - - for (const item of payrollDetails) { - const key = String(item.guardId || "unassigned"); - - if (!guardSummaryMap.has(key)) { - guardSummaryMap.set(key, { - guardId: item.guardId, - guardName: item.guardName, - totalShifts: 0, - totalHours: 0, - overtimeHours: 0, - underworkedShifts: 0, - pendingApproval: 0, - }); + // Calculate payroll data for this guard + period + const data = await calculateGuardPayroll(_id, start, end, periodType); + + if (!data.entries.length && !guardId) continue; + + // Upsert a PENDING payroll record with the calculated data, or update the existing PENDING one. + const saved = await Payroll.findOneAndUpdate( + { + guard: _id, + 'period.type': periodType, + 'period.startDate': start, + 'period.endDate': end, + }, + { + $set: { + entries: data.entries, + totalScheduledHours: data.totalScheduledHours, + totalWorkedHours: data.totalWorkedHours, + totalRegularHours: data.totalRegularHours, + totalOvertimeHours: data.totalOvertimeHours, + grossPay: data.grossPay, + guardName: data.guardName, + guardEmail: data.guardEmail, + guardRole: data.guardRole, + guardDepartment: data.guardDepartment, + 'period.type': periodType, + 'period.startDate': start, + 'period.endDate': end, + status: 'PENDING', + approvedBy: null, + approvedAt: null, + processedBy: null, + processedAt: null, + }, + $setOnInsert: { guard: _id }, + }, + { upsert: true, new: true } + ); + + results.push(saved); + } catch (err) { + console.error(`Payroll calculation failed for guard ${_id}:`, err.message); } - - const summary = guardSummaryMap.get(key); - summary.totalShifts += 1; - summary.totalHours += item.totalHours; - summary.overtimeHours += item.overtimeHours; - summary.underworkedShifts += item.underworkedShift; - summary.pendingApproval += item.pendingApproval; } - const guardSummaries = Array.from(guardSummaryMap.values()); - - const periodSummaryMap = new Map(); - - for (const item of payrollDetails) { - const label = formatPeriodLabel(item.date, periodType); - - if (!periodSummaryMap.has(label)) { - periodSummaryMap.set(label, { - periodLabel: label, - totalShifts: 0, - totalHours: 0, - overtimeHours: 0, - underworkedShifts: 0, - pendingApproval: 0, - }); - } - - const periodSummary = periodSummaryMap.get(label); - periodSummary.totalShifts += 1; - periodSummary.totalHours += item.totalHours; - periodSummary.overtimeHours += item.overtimeHours; - periodSummary.underworkedShifts += item.underworkedShift; - periodSummary.pendingApproval += item.pendingApproval; - } - - const periods = Array.from(periodSummaryMap.values()); - - return { - message: "Payroll data retrieved successfully", - accessScope: { - requestedBy: user._id, - role: user.role, - guardRestrictedToSelf: user.role === "guard", - employerRestrictedToOwnShifts: user.role === "employer", - }, - filters: { - startDate, - endDate, - periodType, - guardId: guardId || null, - site: site || null, - department: department || null, - }, - summary: { - totalCompletedShifts: shifts.length, - totalAttendanceRecords: attendanceRecords.length, - totalGuards: guardSummaries.length, - totalHours: guardSummaries.reduce((sum, guard) => sum + guard.totalHours, 0), - totalOvertimeHours: guardSummaries.reduce((sum, guard) => sum + guard.overtimeHours, 0), - totalPendingApproval: guardSummaries.reduce((sum, guard) => sum + guard.pendingApproval, 0), - }, - guards: guardSummaries, - periods, - payrollDetails, - }; -}; \ No newline at end of file + return results; +} \ No newline at end of file diff --git a/app-backend/src/utils/sendEmail.js b/app-backend/src/utils/sendEmail.js index b947996fc..e26fa0cde 100644 --- a/app-backend/src/utils/sendEmail.js +++ b/app-backend/src/utils/sendEmail.js @@ -1,6 +1,8 @@ import nodemailer from 'nodemailer'; -if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { +const EMAIL_ENABLED = process.env.EMAIL_ENABLED === 'true'; + +if (EMAIL_ENABLED && (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS)) { console.warn('⚠️ SMTP configuration is missing. Email sending will fail.'); console.warn(' Please set SMTP_HOST, SMTP_USER, and SMTP_PASS in your .env file'); } @@ -90,6 +92,17 @@ const getOtpHtmlTemplate = ({ otp, name }) => { let transporter = createTransporter(); export const sendOTP = async (email, otp, recipientName = '') => { + if (!EMAIL_ENABLED) { + console.log(''); + console.log('┌─────────────────────────────────────────┐'); + console.log(`│ 📬 OTP for ${email.padEnd(28)}│`); + console.log(`│ 🔑 Code : ${String(otp).padEnd(30)}│`); + console.log('│ ⏰ Expires in 5 minutes │'); + console.log('└─────────────────────────────────────────┘'); + console.log(''); + return; + } + transporter = createTransporter(); const fromEmail = process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER; @@ -117,7 +130,7 @@ export const sendOTP = async (email, otp, recipientName = '') => { console.error(' SMTP Config - Secure:', process.env.SMTP_SECURE || 'false'); console.error(' SMTP Config - From Email:', fromEmail || 'NOT SET'); console.error(' SMTP Config - Password Set:', process.env.SMTP_PASS ? 'YES' : 'NO'); - + if (!process.env.SMTP_HOST) { throw new Error('SMTP_HOST is not configured. Please set it in your .env file'); } @@ -127,45 +140,43 @@ export const sendOTP = async (email, otp, recipientName = '') => { if (!process.env.SMTP_FROM_EMAIL) { throw new Error('SMTP_FROM_EMAIL is not configured. For SendGrid, this must be a verified sender email. Please set it in your .env file or Email Settings UI'); } - + if (errorMsg.includes('Authentication failed') || errorMsg.includes('535')) { if (process.env.SMTP_HOST === 'smtp.sendgrid.net' && process.env.SMTP_USER !== 'apikey') { throw new Error('For SendGrid, SMTP_USER must be exactly "apikey" (not an email address). SMTP_PASS should be your SendGrid API key.'); } throw new Error(`SMTP Authentication failed. Check your SMTP_USER and SMTP_PASS credentials. Error: ${errorMsg}`); } - + if (errorMsg.includes('550') || errorMsg.includes('verified Sender Identity')) { throw new Error(`The FROM email address (${fromEmail}) is not verified in SendGrid. Please verify this email in SendGrid Dashboard → Settings → Sender Authentication. Error: ${errorMsg}`); } - + throw new Error(`Failed to send OTP email: ${errorMsg}`); } }; export const sendEmployerCredentials = async (email, tempPassword, contactPerson, companyName) => { - transporter = createTransporter(); - - const subject = 'Your SecureShift Employer Account Credentials'; - const greetingName = contactPerson || companyName || 'there'; - const text = `Hello ${greetingName}, - -An employer account has been created for you on SecureShift. - -Email: ${email} -Temporary Password: ${tempPassword} - -For security, please log in and change your password immediately. + if (!EMAIL_ENABLED) { + console.log(''); + console.log('┌─────────────────────────────────────────┐'); + console.log(`│ 📬 Employer credentials for ${email.substring(0, 12).padEnd(12)}│`); + console.log(`│ 🔑 Temp Password : ${String(tempPassword).padEnd(21)}│`); + console.log('└─────────────────────────────────────────┘'); + console.log(''); + return; + } -Best regards, -SecureShift Team`; + transporter = createTransporter(); + const greetingName = contactPerson || companyName || 'there'; const fromEmail = process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER; + const mailOptions = { from: `"SecureShift" <${fromEmail}>`, to: email, - subject, - text, + subject: 'Your SecureShift Employer Account Credentials', + text: `Hello ${greetingName},\n\nAn employer account has been created for you on SecureShift.\n\nEmail: ${email}\nTemporary Password: ${tempPassword}\n\nFor security, please log in and change your password immediately.\n\nBest regards,\nSecureShift Team`, }; try {