Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app-backend/src/controllers/shift.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import Branch from '../models/Branch.js';
import Guard from '../models/Guard.js';
import Availability from '../models/Availability.js';
import ShiftAttendance from '../models/ShiftAttendance.js';

Check failure on line 6 in app-backend/src/controllers/shift.controller.js

View workflow job for this annotation

GitHub Actions / lint

'ShiftAttendance' is defined but never used. Allowed unused vars must match /^_/u
import { generateTimesheetForCompletedShift } from '../services/timesheet.service.js';

import { ACTIONS } from "../middleware/logger.js";

Expand Down Expand Up @@ -685,11 +686,13 @@

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 });
}
Expand Down
3 changes: 2 additions & 1 deletion app-backend/src/controllers/shiftattendance.controller.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -140,4 +141,4 @@ export const getAttendanceByUserId = async (req, res) => {
} catch (error) {
res.status(500).json({ message: error.message });
}
};
};
127 changes: 127 additions & 0 deletions app-backend/src/controllers/timesheet.controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
93 changes: 93 additions & 0 deletions app-backend/src/models/Timesheet.js
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion app-backend/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
export default router;
119 changes: 119 additions & 0 deletions app-backend/src/routes/timesheet.routes.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading