diff --git a/app-backend/src/controllers/shift.controller.js b/app-backend/src/controllers/shift.controller.js index 02716c9ef..b0de72567 100644 --- a/app-backend/src/controllers/shift.controller.js +++ b/app-backend/src/controllers/shift.controller.js @@ -4,6 +4,7 @@ import Branch from '../models/Branch.js'; import Guard from '../models/Guard.js'; import Availability from '../models/Availability.js'; import ShiftAttendance from '../models/ShiftAttendance.js'; +import { generateTimesheetForCompletedShift } from '../services/timesheet.service.js'; import { ACTIONS } from "../middleware/logger.js"; @@ -685,11 +686,13 @@ export const completeShift = async (req, res) => { shift.status = 'completed'; await shift.save(); + const timesheet = await generateTimesheetForCompletedShift(shift, shift.attendance); await req.audit.log(req.user._id, ACTIONS.SHIFT_COMPLETED, { - shiftId: shift._id + shiftId: shift._id, + timesheetId: timesheet._id, }); - return res.json({ message: 'Shift completed', shift }); + return res.json({ message: 'Shift completed', shift, timesheet }); } catch (e) { return res.status(500).json({ message: e.message }); } diff --git a/app-backend/src/controllers/shiftattendance.controller.js b/app-backend/src/controllers/shiftattendance.controller.js index 4d7ddd810..883ea7001 100644 --- a/app-backend/src/controllers/shiftattendance.controller.js +++ b/app-backend/src/controllers/shiftattendance.controller.js @@ -1,4 +1,5 @@ import ShiftAttendance from "../models/ShiftAttendance.js"; +import Shift from "../models/Shift.js"; // Utility: calculate distance using the Haversine formula function calculateDistance(lat1, lon1, lat2, lon2) { @@ -140,4 +141,4 @@ export const getAttendanceByUserId = async (req, res) => { } catch (error) { res.status(500).json({ message: error.message }); } -}; \ No newline at end of file +}; diff --git a/app-backend/src/controllers/timesheet.controller.js b/app-backend/src/controllers/timesheet.controller.js new file mode 100644 index 000000000..48b259efa --- /dev/null +++ b/app-backend/src/controllers/timesheet.controller.js @@ -0,0 +1,127 @@ +import mongoose from 'mongoose'; +import Timesheet from '../models/Timesheet.js'; + +const buildScopedQuery = (user) => { + if (!user?._id || !user?.role) { + throw Object.assign(new Error('Unauthorised'), { statusCode: 401 }); + } + + if (user.role === 'admin') { + return {}; + } + + if (user.role === 'employer') { + return { employerId: user._id }; + } + + if (user.role === 'guard') { + return { guardId: user._id }; + } + + throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); +}; + +const applyFilters = (query, filters, user) => { + const { guardId, status, startDate, endDate } = filters; + + if (guardId) { + if (!mongoose.isValidObjectId(guardId)) { + throw Object.assign(new Error('Invalid guardId'), { statusCode: 400 }); + } + + if (user.role === 'guard' && String(guardId) !== String(user._id)) { + throw Object.assign(new Error('Guards can only access their own timesheets'), { statusCode: 403 }); + } + + query.guardId = guardId; + } + + if (status) { + query.status = status; + } + + if (startDate || endDate) { + query.shiftDate = {}; + + if (startDate) { + const start = new Date(`${startDate}T00:00:00.000Z`); + if (Number.isNaN(start.getTime())) { + throw Object.assign(new Error('Invalid startDate'), { statusCode: 400 }); + } + query.shiftDate.$gte = start; + } + + if (endDate) { + const end = new Date(`${endDate}T23:59:59.999Z`); + if (Number.isNaN(end.getTime())) { + throw Object.assign(new Error('Invalid endDate'), { statusCode: 400 }); + } + query.shiftDate.$lte = end; + } + } + + return query; +}; + +const sendError = (res, error) => { + const statusCode = error.statusCode || 500; + return res.status(statusCode).json({ + message: statusCode === 500 ? 'Failed to retrieve timesheets' : error.message, + }); +}; + +export const listTimesheets = async (req, res) => { + try { + const query = applyFilters(buildScopedQuery(req.user), req.query, req.user); + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + Timesheet.find(query) + .sort({ shiftDate: -1, createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate('guardId', 'name email') + .populate('employerId', 'name email') + .populate('shiftId', 'title date startTime endTime location payRate status') + .lean(), + Timesheet.countDocuments(query), + ]); + + return res.status(200).json({ + page, + limit, + total, + items, + }); + } catch (error) { + return sendError(res, error); + } +}; + +export const getTimesheetById = async (req, res) => { + try { + const { id } = req.params; + if (!mongoose.isValidObjectId(id)) { + return res.status(400).json({ message: 'Invalid timesheet id' }); + } + + const query = buildScopedQuery(req.user); + query._id = id; + + const timesheet = await Timesheet.findOne(query) + .populate('guardId', 'name email') + .populate('employerId', 'name email') + .populate('shiftId', 'title date startTime endTime location payRate status') + .lean(); + + if (!timesheet) { + return res.status(404).json({ message: 'Timesheet not found' }); + } + + return res.status(200).json(timesheet); + } catch (error) { + return sendError(res, error); + } +}; diff --git a/app-backend/src/models/Timesheet.js b/app-backend/src/models/Timesheet.js new file mode 100644 index 000000000..2f1287cb8 --- /dev/null +++ b/app-backend/src/models/Timesheet.js @@ -0,0 +1,93 @@ +import mongoose from 'mongoose'; + +const { Schema, model } = mongoose; + +const timesheetSchema = new Schema( + { + shiftId: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + guardId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + employerId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + attendanceId: { + type: Schema.Types.ObjectId, + ref: 'ShiftAttendance', + required: true, + }, + shiftDate: { + type: Date, + required: true, + index: true, + }, + scheduledStart: { + type: Date, + required: true, + }, + scheduledEnd: { + type: Date, + required: true, + }, + actualStart: { + type: Date, + required: true, + }, + actualEnd: { + type: Date, + required: true, + }, + scheduledHours: { + type: Number, + required: true, + min: 0, + }, + workedHours: { + type: Number, + required: true, + min: 0, + }, + breakMinutes: { + type: Number, + default: 0, + min: 0, + }, + payRate: { + type: Number, + default: 0, + min: 0, + }, + grossPay: { + type: Number, + default: 0, + min: 0, + }, + status: { + type: String, + enum: ['generated', 'pending_review'], + default: 'generated', + index: true, + }, + generatedAt: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +timesheetSchema.index({ shiftId: 1, guardId: 1 }, { unique: true }); + +const Timesheet = model('Timesheet', timesheetSchema); +export default Timesheet; diff --git a/app-backend/src/routes/index.js b/app-backend/src/routes/index.js index 33371cfbd..8a99733ec 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -15,6 +15,7 @@ import notificationRoutes from './notification.routes.js' import equipmentRoutes from './equipment.routes.js'; import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; +import timesheetRoutes from './timesheet.routes.js'; const router = express.Router(); router.use('/documents', documentRoutes); router.use('/health', healthRoutes); @@ -30,5 +31,6 @@ router.use('/attendance', shiftAttendanceRoutes); router.use("/incidents", incidentRoutes); router.use('/notifications', notificationRoutes); router.use('/payroll', payrollRoutes); +router.use('/timesheets', timesheetRoutes); router.use('/equipment', equipmentRoutes); -export default router; \ No newline at end of file +export default router; diff --git a/app-backend/src/routes/timesheet.routes.js b/app-backend/src/routes/timesheet.routes.js new file mode 100644 index 000000000..b99c29aea --- /dev/null +++ b/app-backend/src/routes/timesheet.routes.js @@ -0,0 +1,119 @@ +import express from 'express'; +import auth from '../middleware/auth.js'; +import { getTimesheetById, listTimesheets } from '../controllers/timesheet.controller.js'; + +const router = express.Router(); + +const authorizeRole = (...allowedRoles) => (req, res, next) => { + if (!req.user) { + return res.status(401).json({ message: 'Unauthorised' }); + } + + if (!allowedRoles.includes(req.user.role)) { + return res.status(403).json({ message: 'Forbidden: insufficient permissions' }); + } + + next(); +}; + +/** + * @swagger + * tags: + * name: Timesheets + * description: Read-only generated timesheet records + */ + +/** + * @swagger + * /api/v1/timesheets: + * get: + * summary: List generated timesheets + * description: | + * Returns generated timesheets scoped by role. + * - Admin: all timesheets + * - Employer: timesheets for shifts they created + * - Guard: their own timesheets + * tags: [Timesheets] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: startDate + * schema: + * type: string + * format: date + * required: false + * description: Filter from this shift date, in YYYY-MM-DD format + * - in: query + * name: endDate + * schema: + * type: string + * format: date + * required: false + * description: Filter to this shift date, in YYYY-MM-DD format + * - in: query + * name: guardId + * schema: + * type: string + * required: false + * description: Filter by guard. Guards remain restricted to their own authorised records. + * - in: query + * name: status + * schema: + * type: string + * enum: [generated, pending_review] + * required: false + * description: Filter by timesheet status + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * required: false + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * maximum: 50 + * required: false + * responses: + * 200: + * description: Timesheets retrieved successfully + * 401: + * description: Unauthorised + * 403: + * description: Forbidden + */ +router.get('/', auth, authorizeRole('admin', 'employer', 'guard'), listTimesheets); + +/** + * @swagger + * /api/v1/timesheets/{id}: + * get: + * summary: Get a generated timesheet by id + * tags: [Timesheets] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Timesheet ID + * responses: + * 200: + * description: Timesheet retrieved successfully + * 400: + * description: Invalid timesheet id + * 401: + * description: Unauthorised + * 403: + * description: Forbidden + * 404: + * description: Timesheet not found + */ +router.get('/:id', auth, authorizeRole('admin', 'employer', 'guard'), getTimesheetById); + +export default router; diff --git a/app-backend/src/services/timesheet.service.js b/app-backend/src/services/timesheet.service.js new file mode 100644 index 000000000..a7bbc7327 --- /dev/null +++ b/app-backend/src/services/timesheet.service.js @@ -0,0 +1,94 @@ +import Timesheet from '../models/Timesheet.js'; + +const roundHours = (hours) => Math.round(hours * 100) / 100; +const roundMoney = (amount) => Math.round(amount * 100) / 100; + +const buildShiftDateTime = (date, time) => { + const [hour, minute] = String(time).split(':').map(Number); + const value = new Date(date); + value.setHours(hour, minute, 0, 0); + return value; +}; + +export const calculateTimesheetValues = (shift, attendance) => { + if (!shift?.date || !shift?.startTime || !shift?.endTime) { + throw new Error('Shift schedule is incomplete'); + } + + if (!attendance?.checkInTime || !attendance?.checkOutTime) { + throw new Error('Attendance must include check-in and check-out times'); + } + + const scheduledStart = buildShiftDateTime(shift.date, shift.startTime); + const scheduledEnd = buildShiftDateTime(shift.date, shift.endTime); + + if (scheduledEnd <= scheduledStart) { + scheduledEnd.setDate(scheduledEnd.getDate() + 1); + } + + const actualStart = new Date(attendance.checkInTime); + const actualEnd = new Date(attendance.checkOutTime); + + if (Number.isNaN(actualStart.getTime()) || Number.isNaN(actualEnd.getTime())) { + throw new Error('Attendance times are invalid'); + } + + if (actualEnd < actualStart) { + throw new Error('Check-out time cannot be before check-in time'); + } + + const breakMinutes = Math.max(0, Number(shift.breakTime || 0)); + const scheduledHours = roundHours((scheduledEnd - scheduledStart) / (1000 * 60 * 60)); + const rawWorkedHours = (actualEnd - actualStart) / (1000 * 60 * 60); + const workedHours = roundHours(Math.max(0, rawWorkedHours - breakMinutes / 60)); + const payRate = Math.max(0, Number(shift.payRate || 0)); + const grossPay = roundMoney(workedHours * payRate); + + return { + scheduledStart, + scheduledEnd, + actualStart, + actualEnd, + scheduledHours, + workedHours, + breakMinutes, + payRate, + grossPay, + }; +}; + +export const generateTimesheetForCompletedShift = async (shift, attendance) => { + const guardId = shift?.acceptedBy || shift?.assignedGuard; + const employerId = shift?.createdBy; + + if (!shift?._id || !guardId || !employerId || !attendance?._id) { + throw new Error('Shift, guard, employer, and attendance are required'); + } + + const values = calculateTimesheetValues(shift, attendance); + + return Timesheet.findOneAndUpdate( + { + shiftId: shift._id, + guardId, + }, + { + $set: { + employerId, + attendanceId: attendance._id, + shiftDate: shift.date, + ...values, + status: 'generated', + }, + $setOnInsert: { + generatedAt: new Date(), + }, + }, + { + new: true, + upsert: true, + runValidators: true, + setDefaultsOnInsert: true, + } + ); +}; diff --git a/app-backend/tests/shift.complete-timesheet.test.js b/app-backend/tests/shift.complete-timesheet.test.js new file mode 100644 index 000000000..f87ab3b62 --- /dev/null +++ b/app-backend/tests/shift.complete-timesheet.test.js @@ -0,0 +1,79 @@ +import mongoose from 'mongoose'; +import Shift from '../src/models/Shift.js'; +import { completeShift } from '../src/controllers/shift.controller.js'; +import { generateTimesheetForCompletedShift } from '../src/services/timesheet.service.js'; + +jest.mock('../src/models/Shift.js', () => ({ + __esModule: true, + default: { + findById: jest.fn(), + }, +})); + +jest.mock('../src/services/timesheet.service.js', () => ({ + __esModule: true, + generateTimesheetForCompletedShift: jest.fn(), +})); + +const createResponse = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), +}); + +describe('completeShift timesheet generation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('generates a timesheet when a shift is completed', async () => { + const shiftId = new mongoose.Types.ObjectId(); + const employerId = new mongoose.Types.ObjectId(); + const attendance = { + _id: new mongoose.Types.ObjectId(), + checkInTime: new Date('2026-05-01T09:00:00.000Z'), + checkOutTime: new Date('2026-05-01T17:00:00.000Z'), + }; + const shift = { + _id: shiftId, + createdBy: employerId, + assignedGuard: new mongoose.Types.ObjectId(), + status: 'assigned', + attendance, + hasCheckedIn: true, + hasCheckedOut: true, + save: jest.fn().mockResolvedValue(undefined), + }; + const timesheet = { _id: new mongoose.Types.ObjectId(), shiftId }; + + Shift.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(shift), + }); + generateTimesheetForCompletedShift.mockResolvedValue(timesheet); + + const req = { + params: { id: String(shiftId) }, + user: { _id: employerId, role: 'employer' }, + audit: { log: jest.fn().mockResolvedValue(undefined) }, + }; + const res = createResponse(); + + await completeShift(req, res); + + expect(shift.status).toBe('completed'); + expect(shift.save).toHaveBeenCalled(); + expect(generateTimesheetForCompletedShift).toHaveBeenCalledWith(shift, attendance); + expect(req.audit.log).toHaveBeenCalledWith( + employerId, + 'SHIFT_COMPLETED', + expect.objectContaining({ + shiftId, + timesheetId: timesheet._id, + }) + ); + expect(res.json).toHaveBeenCalledWith({ + message: 'Shift completed', + shift, + timesheet, + }); + }); +}); diff --git a/app-backend/tests/timesheet.controller.test.js b/app-backend/tests/timesheet.controller.test.js new file mode 100644 index 000000000..b3d734866 --- /dev/null +++ b/app-backend/tests/timesheet.controller.test.js @@ -0,0 +1,113 @@ +import mongoose from 'mongoose'; +import Timesheet from '../src/models/Timesheet.js'; +import { getTimesheetById, listTimesheets } from '../src/controllers/timesheet.controller.js'; + +jest.mock('../src/models/Timesheet.js', () => ({ + __esModule: true, + default: { + find: jest.fn(), + findOne: jest.fn(), + countDocuments: jest.fn(), + }, +})); + +const createResponse = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), +}); + +const createListQuery = (items = []) => { + const query = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(items), + }; + + return query; +}; + +const createOneQuery = (item = null) => { + const query = { + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(item), + }; + + return query; +}; + +describe('Timesheet Controller', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists timesheets scoped to an employer', async () => { + const employerId = new mongoose.Types.ObjectId(); + const listQuery = createListQuery([{ _id: 'timesheet1' }]); + + Timesheet.find.mockReturnValue(listQuery); + Timesheet.countDocuments.mockResolvedValue(1); + + const req = { + user: { _id: employerId, role: 'employer' }, + query: { page: '1', limit: '10' }, + }; + const res = createResponse(); + + await listTimesheets(req, res); + + expect(Timesheet.find).toHaveBeenCalledWith({ employerId }); + expect(Timesheet.countDocuments).toHaveBeenCalledWith({ employerId }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + limit: 10, + total: 1, + items: [{ _id: 'timesheet1' }], + }) + ); + }); + + it('prevents guards from filtering to another guard', async () => { + const guardId = new mongoose.Types.ObjectId(); + const otherGuardId = new mongoose.Types.ObjectId(); + const req = { + user: { _id: guardId, role: 'guard' }, + query: { guardId: String(otherGuardId) }, + }; + const res = createResponse(); + + await listTimesheets(req, res); + + expect(Timesheet.find).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + message: 'Guards can only access their own timesheets', + }); + }); + + it('gets one timesheet using role scope', async () => { + const guardId = new mongoose.Types.ObjectId(); + const timesheetId = new mongoose.Types.ObjectId(); + const oneQuery = createOneQuery({ _id: timesheetId, guardId }); + + Timesheet.findOne.mockReturnValue(oneQuery); + + const req = { + user: { _id: guardId, role: 'guard' }, + params: { id: String(timesheetId) }, + }; + const res = createResponse(); + + await getTimesheetById(req, res); + + expect(Timesheet.findOne).toHaveBeenCalledWith({ + _id: String(timesheetId), + guardId, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ _id: timesheetId, guardId }); + }); +}); diff --git a/app-backend/tests/timesheet.service.test.js b/app-backend/tests/timesheet.service.test.js new file mode 100644 index 000000000..4f41aa559 --- /dev/null +++ b/app-backend/tests/timesheet.service.test.js @@ -0,0 +1,90 @@ +import mongoose from 'mongoose'; +import Timesheet from '../src/models/Timesheet.js'; +import { + calculateTimesheetValues, + generateTimesheetForCompletedShift, +} from '../src/services/timesheet.service.js'; + +jest.mock('../src/models/Timesheet.js', () => ({ + __esModule: true, + default: { + findOneAndUpdate: jest.fn(), + }, +})); + +describe('Timesheet Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calculates worked hours, break time, and gross pay', () => { + const values = calculateTimesheetValues( + { + date: new Date('2026-05-01T00:00:00.000Z'), + startTime: '09:00', + endTime: '17:00', + breakTime: 30, + payRate: 35, + }, + { + checkInTime: new Date('2026-05-01T09:05:00.000Z'), + checkOutTime: new Date('2026-05-01T17:20:00.000Z'), + } + ); + + expect(values.scheduledHours).toBe(8); + expect(values.workedHours).toBe(7.75); + expect(values.breakMinutes).toBe(30); + expect(values.grossPay).toBe(271.25); + }); + + it('upserts one timesheet for the completed shift and guard', async () => { + const shiftId = new mongoose.Types.ObjectId(); + const guardId = new mongoose.Types.ObjectId(); + const employerId = new mongoose.Types.ObjectId(); + const attendanceId = new mongoose.Types.ObjectId(); + const savedTimesheet = { _id: new mongoose.Types.ObjectId(), shiftId, guardId }; + + Timesheet.findOneAndUpdate.mockResolvedValue(savedTimesheet); + + const result = await generateTimesheetForCompletedShift( + { + _id: shiftId, + acceptedBy: guardId, + createdBy: employerId, + date: new Date('2026-05-01T00:00:00.000Z'), + startTime: '09:00', + endTime: '17:00', + breakTime: 0, + payRate: 30, + }, + { + _id: attendanceId, + checkInTime: new Date('2026-05-01T09:00:00.000Z'), + checkOutTime: new Date('2026-05-01T17:00:00.000Z'), + } + ); + + expect(result).toBe(savedTimesheet); + expect(Timesheet.findOneAndUpdate).toHaveBeenCalledWith( + { shiftId, guardId }, + expect.objectContaining({ + $set: expect.objectContaining({ + employerId, + attendanceId, + workedHours: 8, + grossPay: 240, + status: 'generated', + }), + $setOnInsert: expect.objectContaining({ + generatedAt: expect.any(Date), + }), + }), + expect.objectContaining({ + new: true, + upsert: true, + runValidators: true, + }) + ); + }); +});