From 143f85ffa75dfa66d186a44c66ac2a437f6b81ce Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Tue, 14 Jul 2026 13:26:25 +0930 Subject: [PATCH 1/2] Add automatic timesheet generation --- .../src/controllers/timesheet.controller.js | 37 +++ app-backend/src/models/Timesheet.js | 76 +++++ app-backend/src/routes/index.js | 4 +- app-backend/src/routes/timesheet.routes.js | 27 ++ app-backend/src/services/payroll.service.js | 4 +- app-backend/src/services/timesheet.service.js | 288 ++++++++++++++++++ app-backend/tests/timesheet.service.test.js | 267 ++++++++++++++++ 7 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 app-backend/src/controllers/timesheet.controller.js create mode 100644 app-backend/src/models/Timesheet.js create mode 100644 app-backend/src/routes/timesheet.routes.js create mode 100644 app-backend/src/services/timesheet.service.js create mode 100644 app-backend/tests/timesheet.service.test.js diff --git a/app-backend/src/controllers/timesheet.controller.js b/app-backend/src/controllers/timesheet.controller.js new file mode 100644 index 000000000..5b5fb7ec8 --- /dev/null +++ b/app-backend/src/controllers/timesheet.controller.js @@ -0,0 +1,37 @@ +import { + generateTimesheets, + getTimesheetForUser, + listTimesheetsForUser, +} from '../services/timesheet.service.js'; + +const sendError = (res, error, fallbackMessage) => + res.status(error.statusCode || 500).json({ + message: error.message || fallbackMessage, + }); + +export const generateTimesheetsForRange = async (req, res) => { + try { + const result = await generateTimesheets(req.body, req.user); + return res.status(200).json(result); + } catch (error) { + return sendError(res, error, 'Failed to generate timesheets'); + } +}; + +export const listTimesheets = async (req, res) => { + try { + const result = await listTimesheetsForUser(req.query, req.user); + return res.status(200).json(result); + } catch (error) { + return sendError(res, error, 'Failed to retrieve timesheets'); + } +}; + +export const getTimesheetById = async (req, res) => { + try { + const result = await getTimesheetForUser(req.params.id, req.user); + return res.status(200).json(result); + } catch (error) { + return sendError(res, error, 'Failed to retrieve timesheet'); + } +}; diff --git a/app-backend/src/models/Timesheet.js b/app-backend/src/models/Timesheet.js new file mode 100644 index 000000000..6b01b7a77 --- /dev/null +++ b/app-backend/src/models/Timesheet.js @@ -0,0 +1,76 @@ +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, + }, + checkInTime: { + type: Date, + required: true, + }, + checkOutTime: { + type: Date, + required: true, + }, + scheduledHours: { + type: Number, + required: true, + min: 0, + }, + actualHours: { + type: Number, + required: true, + min: 0, + }, + payableHours: { + type: Number, + required: true, + min: 0, + }, + attendanceBased: { + type: Boolean, + default: true, + }, + generatedAt: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +timesheetSchema.index({ shiftId: 1, guardId: 1 }, { unique: true }); +timesheetSchema.index({ employerId: 1, shiftDate: -1 }); +timesheetSchema.index({ guardId: 1, shiftDate: -1 }); + +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 575d8a897..f81e71e32 100644 --- a/app-backend/src/routes/index.js +++ b/app-backend/src/routes/index.js @@ -16,6 +16,7 @@ import equipmentRoutes from './equipment.routes.js'; import payrollRoutes from './payroll.routes.js'; import documentRoutes from './document.routes.js'; import emergencyRoutes from "./emergency.routes.js"; +import timesheetRoutes from './timesheet.routes.js'; const router = express.Router(); router.use('/documents', documentRoutes); router.use('/health', healthRoutes); @@ -31,6 +32,7 @@ router.use('/attendance', shiftAttendanceRoutes); router.use("/incidents", incidentRoutes); router.use('/notifications', notificationRoutes); router.use('/payroll', payrollRoutes); +router.use('/timesheets', timesheetRoutes); router.use('/equipment', equipmentRoutes); router.use("/emergency", emergencyRoutes); -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..5bae025df --- /dev/null +++ b/app-backend/src/routes/timesheet.routes.js @@ -0,0 +1,27 @@ +import express from 'express'; +import auth from '../middleware/auth.js'; +import { + generateTimesheetsForRange, + 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: 'Unauthorized' }); + } + + if (!allowedRoles.includes(req.user.role)) { + return res.status(403).json({ message: 'Forbidden: insufficient permissions' }); + } + + next(); +}; + +router.post('/generate', auth, authorizeRole('admin', 'employer', 'guard'), generateTimesheetsForRange); +router.get('/', auth, authorizeRole('admin', 'employer', 'guard'), listTimesheets); +router.get('/:id', auth, authorizeRole('admin', 'employer', 'guard'), getTimesheetById); + +export default router; diff --git a/app-backend/src/services/payroll.service.js b/app-backend/src/services/payroll.service.js index 9431682b8..86de93fb2 100644 --- a/app-backend/src/services/payroll.service.js +++ b/app-backend/src/services/payroll.service.js @@ -111,7 +111,7 @@ const getShiftStartDateTime = (shift) => { return start; }; -const calculateScheduledHours = (shift) => { +export const calculateScheduledHours = (shift) => { if (!shift?.date || !shift?.startTime || !shift?.endTime) { return 0; } @@ -144,7 +144,7 @@ const calculateScheduledHours = (shift) => { return roundHours(hours); }; -const calculateAttendanceHours = (attendance) => { +export const calculateAttendanceHours = (attendance) => { if (!attendance?.checkInTime || !attendance?.checkOutTime) { return null; } diff --git a/app-backend/src/services/timesheet.service.js b/app-backend/src/services/timesheet.service.js new file mode 100644 index 000000000..dc2b77ff6 --- /dev/null +++ b/app-backend/src/services/timesheet.service.js @@ -0,0 +1,288 @@ +import mongoose from 'mongoose'; +import Shift from '../models/Shift.js'; +import ShiftAttendance from '../models/ShiftAttendance.js'; +import Timesheet from '../models/Timesheet.js'; +import { + calculateAttendanceHours, + calculateScheduledHours, +} from './payroll.service.js'; + +const ISO_DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +const createHttpError = (statusCode, message) => { + const error = new Error(message); + error.statusCode = statusCode; + return error; +}; + +const isValidISODateOnly = (value) => { + if (!ISO_DATE_ONLY_REGEX.test(value)) return false; + + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +}; + +const parseDateRange = ({ startDate, endDate }, { required = false } = {}) => { + if (!startDate && !endDate && !required) return null; + + if (!startDate || !endDate) { + throw createHttpError(400, 'startDate and endDate are required'); + } + + if (!isValidISODateOnly(startDate) || !isValidISODateOnly(endDate)) { + throw createHttpError(400, 'startDate and endDate must be valid ISO dates in YYYY-MM-DD format'); + } + + const start = new Date(`${startDate}T00:00:00.000Z`); + const end = new Date(`${endDate}T23:59:59.999Z`); + + if (start > end) { + throw createHttpError(400, 'startDate cannot be after endDate'); + } + + return { start, end }; +}; + +const getUserContext = (user) => { + const userId = user?._id || user?.id; + const role = user?.role; + + if (!userId || !role) { + throw createHttpError(401, 'Unauthorized'); + } + + return { userId, role }; +}; + +const ensureObjectId = (value, fieldName) => { + if (value && !mongoose.isValidObjectId(value)) { + throw createHttpError(400, `${fieldName} must be a valid id`); + } +}; + +const buildShiftQuery = (filters, user) => { + const { userId, role } = getUserContext(user); + const range = parseDateRange(filters, { required: true }); + const { guardId } = filters; + + ensureObjectId(guardId, 'guardId'); + + const query = { + status: 'completed', + acceptedBy: { $ne: null }, + date: { + $gte: range.start, + $lte: range.end, + }, + }; + + if (role === 'guard') { + if (guardId && String(guardId) !== String(userId)) { + throw createHttpError(403, 'Guards can only generate their own timesheets'); + } + query.acceptedBy = userId; + } else if (role === 'employer') { + query.createdBy = userId; + if (guardId) query.acceptedBy = guardId; + } else if (role === 'admin') { + if (guardId) query.acceptedBy = guardId; + } else { + throw createHttpError(403, 'Forbidden: unsupported role'); + } + + return query; +}; + +const buildTimesheetQuery = (filters, user) => { + const { userId, role } = getUserContext(user); + const { guardId } = filters; + + ensureObjectId(guardId, 'guardId'); + + const query = {}; + + if (role === 'guard') { + if (guardId && String(guardId) !== String(userId)) { + throw createHttpError(403, 'Guards can only access their own timesheets'); + } + query.guardId = userId; + } else if (role === 'employer') { + query.employerId = userId; + if (guardId) query.guardId = guardId; + } else if (role === 'admin') { + if (guardId) query.guardId = guardId; + } else { + throw createHttpError(403, 'Forbidden: unsupported role'); + } + + const range = parseDateRange(filters); + if (range) { + query.shiftDate = { + $gte: range.start, + $lte: range.end, + }; + } + + return query; +}; + +const serializeTimesheet = (timesheet) => ({ + id: String(timesheet._id), + shiftId: String(timesheet.shiftId?._id || timesheet.shiftId), + guardId: String(timesheet.guardId?._id || timesheet.guardId), + employerId: String(timesheet.employerId?._id || timesheet.employerId), + attendanceId: String(timesheet.attendanceId?._id || timesheet.attendanceId), + shiftDate: timesheet.shiftDate, + checkInTime: timesheet.checkInTime, + checkOutTime: timesheet.checkOutTime, + scheduledHours: timesheet.scheduledHours, + actualHours: timesheet.actualHours, + payableHours: timesheet.payableHours, + attendanceBased: timesheet.attendanceBased, + generatedAt: timesheet.generatedAt, +}); + +const buildValidTimesheetRecord = (shift, attendance) => { + const guardId = shift.acceptedBy?._id || shift.acceptedBy; + const employerId = shift.createdBy?._id || shift.createdBy; + const actualHours = calculateAttendanceHours(attendance); + + if ( + shift.status !== 'completed' || + !guardId || + !employerId || + !attendance?._id || + String(attendance.guardId) !== String(guardId) || + String(attendance.shiftId) !== String(shift._id) || + actualHours == null + ) { + return null; + } + + return { + shiftId: shift._id, + guardId, + employerId, + attendanceId: attendance._id, + shiftDate: shift.date, + checkInTime: attendance.checkInTime, + checkOutTime: attendance.checkOutTime, + scheduledHours: calculateScheduledHours(shift), + actualHours, + payableHours: actualHours, + attendanceBased: true, + }; +}; + +const populateTimesheetQuery = (query) => + query + .populate('guardId', 'name email') + .populate('employerId', 'name email') + .populate('shiftId', 'title date startTime endTime status') + .populate('attendanceId', 'checkInTime checkOutTime'); + +export const generateTimesheets = async (filters, user) => { + const shiftQuery = buildShiftQuery(filters, user); + const shifts = await Shift.find(shiftQuery).sort({ date: 1, startTime: 1 }); + + if (!shifts.length) { + return { generated: 0, skipped: 0, timesheets: [] }; + } + + const attendanceRecords = await ShiftAttendance.find({ + shiftId: { $in: shifts.map((shift) => shift._id) }, + checkInTime: { $ne: null }, + checkOutTime: { $ne: null }, + }); + const attendanceMap = new Map(); + + for (const attendance of attendanceRecords) { + attendanceMap.set(`${String(attendance.shiftId)}:${String(attendance.guardId)}`, attendance); + } + + const records = shifts + .map((shift) => { + const guardId = shift.acceptedBy?._id || shift.acceptedBy; + return buildValidTimesheetRecord( + shift, + attendanceMap.get(`${String(shift._id)}:${String(guardId)}`) + ); + }) + .filter(Boolean); + + if (!records.length) { + return { generated: 0, skipped: shifts.length, timesheets: [] }; + } + + await Timesheet.bulkWrite( + records.map((record) => ({ + updateOne: { + filter: { + shiftId: record.shiftId, + guardId: record.guardId, + }, + update: { + $set: record, + $setOnInsert: { + generatedAt: new Date(), + }, + }, + upsert: true, + }, + })), + { ordered: false } + ); + + const docs = await populateTimesheetQuery( + Timesheet.find({ + $or: records.map((record) => ({ + shiftId: record.shiftId, + guardId: record.guardId, + })), + }).sort({ shiftDate: 1, createdAt: 1 }) + ); + + return { + generated: docs.length, + skipped: shifts.length - records.length, + timesheets: docs.map(serializeTimesheet), + }; +}; + +export const listTimesheetsForUser = async (filters, user) => { + const query = buildTimesheetQuery(filters, user); + const page = Math.max(1, parseInt(filters.page, 10) || 1); + const limit = Math.min(50, Math.max(1, parseInt(filters.limit, 10) || 20)); + const skip = (page - 1) * limit; + + const [docs, total] = await Promise.all([ + populateTimesheetQuery( + Timesheet.find(query).sort({ shiftDate: -1, createdAt: -1 }).skip(skip).limit(limit) + ), + Timesheet.countDocuments(query), + ]); + + return { + page, + limit, + total, + timesheets: docs.map(serializeTimesheet), + }; +}; + +export const getTimesheetForUser = async (timesheetId, user) => { + if (!mongoose.isValidObjectId(timesheetId)) { + throw createHttpError(400, 'Invalid timesheet id'); + } + + const query = buildTimesheetQuery({}, user); + query._id = timesheetId; + + const timesheet = await populateTimesheetQuery(Timesheet.findOne(query)); + + if (!timesheet) { + throw createHttpError(404, 'Timesheet not found'); + } + + return serializeTimesheet(timesheet); +}; diff --git a/app-backend/tests/timesheet.service.test.js b/app-backend/tests/timesheet.service.test.js new file mode 100644 index 000000000..73d026a30 --- /dev/null +++ b/app-backend/tests/timesheet.service.test.js @@ -0,0 +1,267 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from '@jest/globals'; +import mongoose from 'mongoose'; + +import Branch from '../src/models/Branch.js'; +import Shift from '../src/models/Shift.js'; +import ShiftAttendance from '../src/models/ShiftAttendance.js'; +import Timesheet from '../src/models/Timesheet.js'; +import User from '../src/models/User.js'; +import { + generateTimesheets, + getTimesheetForUser, + listTimesheetsForUser, +} from '../src/services/timesheet.service.js'; + +describe('Timesheet service', () => { + let employer; + let otherEmployer; + let guard; + let otherGuard; + let branch; + + const siteLocation = { + type: 'Point', + coordinates: [144.9631, -37.8136], + }; + + const createShift = (overrides = {}) => + Shift.create({ + title: overrides.title || 'Completed Timesheet Shift', + date: overrides.date || new Date('2026-12-10T00:00:00.000Z'), + startTime: overrides.startTime || '09:00', + endTime: overrides.endTime || '17:00', + breakTime: overrides.breakTime ?? 30, + createdBy: overrides.createdBy || employer._id, + acceptedBy: overrides.acceptedBy || guard._id, + siteId: branch._id, + location: { + street: 'Main', + suburb: 'CBD', + state: 'VIC', + postcode: '3000', + latitude: -37.8136, + longitude: 144.9631, + }, + payRate: overrides.payRate ?? 45, + shiftType: 'Day', + status: overrides.status || 'completed', + }); + + const createAttendance = (shift, overrides = {}) => + ShiftAttendance.create({ + guardId: overrides.guardId || shift.acceptedBy, + shiftId: shift._id, + siteLocation, + checkInTime: + overrides.checkInTime === undefined + ? new Date('2026-12-10T09:05:00.000Z') + : overrides.checkInTime, + checkOutTime: + overrides.checkOutTime === undefined + ? new Date('2026-12-10T17:20:00.000Z') + : overrides.checkOutTime, + locationVerified: true, + }); + + beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); + await Promise.all([ + Shift.deleteMany({}), + ShiftAttendance.deleteMany({}), + Timesheet.deleteMany({}), + Branch.deleteMany({ code: 'TS001' }), + User.deleteMany({ + email: { + $in: [ + 'timesheet.employer@test.com', + 'timesheet.other.employer@test.com', + 'timesheet.guard@test.com', + 'timesheet.other.guard@test.com', + ], + }, + }), + ]); + + employer = await User.create({ + name: 'Timesheet Employer', + email: 'timesheet.employer@test.com', + role: 'employer', + password: 'Password1!', + }); + + otherEmployer = await User.create({ + name: 'Other Timesheet Employer', + email: 'timesheet.other.employer@test.com', + role: 'employer', + password: 'Password1!', + }); + + guard = await User.create({ + name: 'Timesheet Guard', + email: 'timesheet.guard@test.com', + role: 'guard', + password: 'Password1!', + }); + + otherGuard = await User.create({ + name: 'Other Timesheet Guard', + email: 'timesheet.other.guard@test.com', + role: 'guard', + password: 'Password1!', + }); + + branch = await Branch.create({ + name: 'Timesheet Site', + code: 'TS001', + employerId: employer._id, + isActive: true, + }); + }); + + beforeEach(async () => { + await Promise.all([ + Shift.deleteMany({}), + ShiftAttendance.deleteMany({}), + Timesheet.deleteMany({}), + ]); + }); + + afterAll(async () => { + await Promise.all([ + Shift.deleteMany({}), + ShiftAttendance.deleteMany({}), + Timesheet.deleteMany({}), + Branch.deleteMany({ code: 'TS001' }), + User.deleteMany({ + email: { + $in: [ + 'timesheet.employer@test.com', + 'timesheet.other.employer@test.com', + 'timesheet.guard@test.com', + 'timesheet.other.guard@test.com', + ], + }, + }), + ]); + await mongoose.connection.close(); + }); + + test('generates timesheet data from a valid completed shift and completed attendance', async () => { + const shift = await createShift(); + const attendance = await createAttendance(shift); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.generated).toBe(1); + expect(result.skipped).toBe(0); + expect(result.timesheets[0]).toMatchObject({ + shiftId: String(shift._id), + guardId: String(guard._id), + employerId: String(employer._id), + attendanceId: String(attendance._id), + scheduledHours: 7.5, + actualHours: 8.25, + payableHours: 8.25, + attendanceBased: true, + }); + }); + + test('skips completed shifts with missing checkout', async () => { + const shift = await createShift(); + await createAttendance(shift, { checkOutTime: null }); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.generated).toBe(0); + expect(result.skipped).toBe(1); + expect(await Timesheet.countDocuments()).toBe(0); + }); + + test('does not generate timesheets for incomplete shifts', async () => { + const shift = await createShift({ status: 'assigned' }); + await createAttendance(shift); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.generated).toBe(0); + expect(await Timesheet.countDocuments()).toBe(0); + }); + + test('is idempotent for duplicate generation requests', async () => { + const shift = await createShift(); + await createAttendance(shift); + + await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + const second = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(second.generated).toBe(1); + expect(await Timesheet.countDocuments({ shiftId: shift._id, guardId: guard._id })).toBe(1); + }); + + test('scopes retrieval to employer-owned timesheets', async () => { + const ownShift = await createShift(); + await createAttendance(ownShift); + const otherShift = await createShift({ + title: 'Other Employer Shift', + createdBy: otherEmployer._id, + acceptedBy: otherGuard._id, + }); + await createAttendance(otherShift, { + guardId: otherGuard._id, + }); + + await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: otherEmployer._id, role: 'employer' } + ); + + const result = await listTimesheetsForUser({}, { _id: employer._id, role: 'employer' }); + + expect(result.total).toBe(1); + expect(result.timesheets[0].employerId).toBe(String(employer._id)); + }); + + test('scopes retrieval to guard-owned timesheets', async () => { + const shift = await createShift(); + await createAttendance(shift); + + await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + const guardResult = await listTimesheetsForUser({}, { _id: guard._id, role: 'guard' }); + const timesheet = await getTimesheetForUser(guardResult.timesheets[0].id, { + _id: guard._id, + role: 'guard', + }); + + expect(guardResult.total).toBe(1); + expect(timesheet.guardId).toBe(String(guard._id)); + await expect( + listTimesheetsForUser({ guardId: String(otherGuard._id) }, { _id: guard._id, role: 'guard' }) + ).rejects.toMatchObject({ + message: 'Guards can only access their own timesheets', + statusCode: 403, + }); + }); +}); From 69e098fcfe9274a4002ef81083018db9109e5c35 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Tue, 14 Jul 2026 16:50:44 +0930 Subject: [PATCH 2/2] Address timesheet review feedback --- app-backend/README.md | 32 ++++++++++ app-backend/src/routes/timesheet.routes.js | 19 ++---- app-backend/src/services/timesheet.service.js | 9 ++- app-backend/tests/timesheet.service.test.js | 61 ++++++++++++++++++- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/app-backend/README.md b/app-backend/README.md index 8816ee781..4125d8b13 100644 --- a/app-backend/README.md +++ b/app-backend/README.md @@ -134,6 +134,38 @@ npm run test Unit and integration tests are managed via Jest (or Mocha/Chai if used). +## Timesheet API + +Generated timesheets are exposed under `/api/v1/timesheets`. + +| Method | Path | Roles | Description | +| --- | --- | --- | --- | +| `POST` | `/api/v1/timesheets/generate` | `admin`, `employer`, `guard` | Generate or refresh timesheets for a completed-shift date range. Body requires `startDate` and `endDate` in `YYYY-MM-DD` format. | +| `GET` | `/api/v1/timesheets` | `admin`, `employer`, `guard` | List generated timesheets visible to the current user. Supports optional `startDate`, `endDate`, `guardId`, `page`, and `limit` query parameters. | +| `GET` | `/api/v1/timesheets/:id` | `admin`, `employer`, `guard` | Retrieve one generated timesheet in the current user's scope. | + +Timesheet generation only uses shifts that are `completed`, assigned through `acceptedBy`, +and have completed attendance with `guardId`, `shiftId`, `checkInTime`, and `checkOutTime`. +Generation is idempotent: each `shiftId` and `guardId` pair has one timesheet, and repeated +generation updates that record rather than creating duplicates. + +RBAC visibility follows the current backend ownership rules: + +- Admins can generate and view all timesheets. +- Guards can generate and view only their own timesheets. +- Employers can generate and view timesheets for shifts where `Shift.createdBy` is their user ID. + +The standard shift creation flow validates `siteId` against an active `Branch.employerId` owned by +the creating employer, then stores that employer in `Shift.createdBy`. A branch-based ownership +case remains possible for imported/admin-created data: if a shift is attached to a branch owned by +an employer but `createdBy` is a different user, the timesheet scope follows `createdBy` and will +not treat the branch owner as the timesheet employer. + +Payable hours are attendance-based. `actualHours` is the raw check-in to check-out duration. +`Shift.breakTime` is stored in minutes and is treated as an unpaid break, so timesheets calculate +`payableHours` as `max(actualHours - breakTime / 60, 0)`. Payable hours are not capped at scheduled +hours, preserving legitimate overtime. + ## Local development seed data The seed commands are for local development only. They refuse production, Atlas/SRV, remote hosts, diff --git a/app-backend/src/routes/timesheet.routes.js b/app-backend/src/routes/timesheet.routes.js index 5bae025df..a78ad6e69 100644 --- a/app-backend/src/routes/timesheet.routes.js +++ b/app-backend/src/routes/timesheet.routes.js @@ -1,5 +1,6 @@ import express from 'express'; import auth from '../middleware/auth.js'; +import { authorizeRoles } from '../middleware/rbac.js'; import { generateTimesheetsForRange, getTimesheetById, @@ -8,20 +9,8 @@ import { const router = express.Router(); -const authorizeRole = (...allowedRoles) => (req, res, next) => { - if (!req.user) { - return res.status(401).json({ message: 'Unauthorized' }); - } - - if (!allowedRoles.includes(req.user.role)) { - return res.status(403).json({ message: 'Forbidden: insufficient permissions' }); - } - - next(); -}; - -router.post('/generate', auth, authorizeRole('admin', 'employer', 'guard'), generateTimesheetsForRange); -router.get('/', auth, authorizeRole('admin', 'employer', 'guard'), listTimesheets); -router.get('/:id', auth, authorizeRole('admin', 'employer', 'guard'), getTimesheetById); +router.post('/generate', auth, authorizeRoles('admin', 'employer', 'guard'), generateTimesheetsForRange); +router.get('/', auth, authorizeRoles('admin', 'employer', 'guard'), listTimesheets); +router.get('/:id', auth, authorizeRoles('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 index dc2b77ff6..bd3a45beb 100644 --- a/app-backend/src/services/timesheet.service.js +++ b/app-backend/src/services/timesheet.service.js @@ -60,6 +60,13 @@ const ensureObjectId = (value, fieldName) => { } }; +const roundHours = (value) => Math.round((Math.max(0, value) + Number.EPSILON) * 100) / 100; + +const calculatePayableHours = (actualHours, shift) => { + const breakMinutes = Number.isFinite(shift?.breakTime) ? shift.breakTime : 0; + return roundHours(actualHours - breakMinutes / 60); +}; + const buildShiftQuery = (filters, user) => { const { userId, role } = getUserContext(user); const range = parseDateRange(filters, { required: true }); @@ -169,7 +176,7 @@ const buildValidTimesheetRecord = (shift, attendance) => { checkOutTime: attendance.checkOutTime, scheduledHours: calculateScheduledHours(shift), actualHours, - payableHours: actualHours, + payableHours: calculatePayableHours(actualHours, shift), attendanceBased: true, }; }; diff --git a/app-backend/tests/timesheet.service.test.js b/app-backend/tests/timesheet.service.test.js index 73d026a30..e0c5f72da 100644 --- a/app-backend/tests/timesheet.service.test.js +++ b/app-backend/tests/timesheet.service.test.js @@ -164,11 +164,70 @@ describe('Timesheet service', () => { attendanceId: String(attendance._id), scheduledHours: 7.5, actualHours: 8.25, - payableHours: 8.25, + payableHours: 7.75, attendanceBased: true, }); }); + test('preserves overtime by not capping payable hours at scheduled hours', async () => { + const shift = await createShift({ + breakTime: 30, + startTime: '09:00', + endTime: '17:00', + }); + await createAttendance(shift, { + checkInTime: new Date('2026-12-10T08:30:00.000Z'), + checkOutTime: new Date('2026-12-10T19:00:00.000Z'), + }); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.timesheets[0]).toMatchObject({ + scheduledHours: 7.5, + actualHours: 10.5, + payableHours: 10, + }); + }); + + test('deducts shift break time from payable hours when attendance is complete', async () => { + const shift = await createShift({ breakTime: 60 }); + await createAttendance(shift, { + checkInTime: new Date('2026-12-10T09:00:00.000Z'), + checkOutTime: new Date('2026-12-10T17:00:00.000Z'), + }); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.timesheets[0]).toMatchObject({ + actualHours: 8, + payableHours: 7, + }); + }); + + test('does not allow payable hours to become negative after break deduction', async () => { + const shift = await createShift({ breakTime: 60 }); + await createAttendance(shift, { + checkInTime: new Date('2026-12-10T09:00:00.000Z'), + checkOutTime: new Date('2026-12-10T09:30:00.000Z'), + }); + + const result = await generateTimesheets( + { startDate: '2026-12-01', endDate: '2026-12-31' }, + { _id: employer._id, role: 'employer' } + ); + + expect(result.timesheets[0]).toMatchObject({ + actualHours: 0.5, + payableHours: 0, + }); + }); + test('skips completed shifts with missing checkout', async () => { const shift = await createShift(); await createAttendance(shift, { checkOutTime: null });