diff --git a/app-backend/src/controllers/shiftattendance.controller.js b/app-backend/src/controllers/shiftattendance.controller.js index 4d7ddd810..f23ac33e9 100644 --- a/app-backend/src/controllers/shiftattendance.controller.js +++ b/app-backend/src/controllers/shiftattendance.controller.js @@ -1,18 +1,8 @@ -import ShiftAttendance from "../models/ShiftAttendance.js"; - -// Utility: calculate distance using the Haversine formula -function calculateDistance(lat1, lon1, lat2, lon2) { - const R = 6371; // km - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLon = ((lon2 - lon1) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos((lat1 * Math.PI) / 180) * - Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; // distance in km -} +import { + checkInForShift, + checkOutForShift, + getAttendanceHistoryForUser, +} from "../services/attendance.service.js"; // POST /api/v1/attendance/checkin/:shiftId export const checkIn = async (req, res) => { @@ -21,101 +11,47 @@ export const checkIn = async (req, res) => { const { shiftId } = req.params; const guardId = req.user.id; - // ✅ Validate inputs - if ( - latitude === undefined || - longitude === undefined || - isNaN(latitude) || - isNaN(longitude) - ) { - return res.status(400).json({ message: "Invalid location coordinates" }); - } - - // ✅ Validate shift - const shift = await Shift.findById(shiftId).populate("siteId"); - if (!shift) { - return res.status(404).json({ message: "Shift not found" }); - } - - // ✅ Check guard is assigned - if (String(shift.assignedGuard) !== String(guardId)) { - return res.status(403).json({ message: "Not assigned to this shift" }); - } - - // ✅ Prevent duplicate check-in - const existing = await ShiftAttendance.findOne({ guardId, shiftId }); - if (existing) { - return res.status(400).json({ message: "Already checked in" }); - } - - // ✅ Get real site location - const siteLocation = shift.siteId?.location; - if (!siteLocation?.latitude || !siteLocation?.longitude) { - return res.status(400).json({ message: "Site location not configured" }); - } - - const distance = calculateDistance( - latitude, - longitude, - siteLocation.latitude, - siteLocation.longitude - ); - - // ✅ Radius check (100m) - if (distance > 0.1) { - return res.status(400).json({ - message: "Not within shift radius (100m)", - }); - } - // ✅ Save attendance - const attendance = new ShiftAttendance({ + const attendance = await checkInForShift({ guardId, shiftId, - checkInTime: new Date(), - siteLocation: { - type: "Point", - coordinates: [siteLocation.longitude, siteLocation.latitude], - }, - checkInLocation: { - type: "Point", - coordinates: [longitude, latitude], - }, - locationVerified: true, + latitude, + longitude, }); - await attendance.save(); - return res.status(201).json({ message: "Check-in recorded", attendance, }); } catch (error) { - return res.status(500).json({ message: error.message }); + return res.status(error.statusCode || 500).json({ + message: error.message, + }); } }; // POST /api/v1/attendance/checkout/:shiftId export const checkOut = async (req, res) => { try { - console.log("Incoming check-in request:", req.params, req.body); - const { latitude, longitude } = req.body; const { shiftId } = req.params; const guardId = req.user.id; - const attendance = await ShiftAttendance.findOne({ guardId, shiftId }); - if (!attendance) - return res.status(404).json({ message: "No check-in record found" }); - - attendance.checkOutTime = new Date(); - attendance.checkOutLocation = { type: "Point", coordinates: [longitude, latitude] }; - await attendance.save(); - - res.status(200).json({ message: "Check-out recorded", attendance }); + const attendance = await checkOutForShift({ + shiftId, + guardId, + latitude, + longitude, + }); + return res.status(200).json({ + message: "Check-out recorded", + attendance, + }); } catch (error) { - res.status(500).json({ message: error.message }); + return res.status(error.statusCode || 500).json({ + message: error.message, + }); } }; // GET /api/v1/attendance/:userId @@ -123,21 +59,16 @@ export const getAttendanceByUserId = async (req, res) => { try { const { userId } = req.params; - const attendanceRecords = await ShiftAttendance.find({ guardId: userId }) - .sort({ checkInTime: -1 }); + const attendanceRecords = await getAttendanceHistoryForUser(userId); - if (!attendanceRecords.length) { - return res.status(404).json({ - message: "No attendance records found for this user", - }); - } - - res.status(200).json({ + return res.status(200).json({ message: "Attendance history retrieved successfully", count: attendanceRecords.length, attendance: attendanceRecords, }); } catch (error) { - res.status(500).json({ message: error.message }); + return res.status(error.statusCode || 500).json({ + message: error.message, + }); } -}; \ No newline at end of file +}; diff --git a/app-backend/src/services/attendance.service.js b/app-backend/src/services/attendance.service.js new file mode 100644 index 000000000..0f316a97a --- /dev/null +++ b/app-backend/src/services/attendance.service.js @@ -0,0 +1,194 @@ +import Shift from "../models/Shift.js"; +import ShiftAttendance from "../models/ShiftAttendance.js"; + +const DEFAULT_ATTENDANCE_RADIUS_KM = 0.1; // 100 meters + +const createServiceError = (message, statusCode = 400) => { + const error = new Error(message); + error.statusCode = statusCode; + return error; +}; + +export const calculateDistance = (from, to) => { + const earthRadiusKm = 6371; + + const fromLatitudeRadians = (from.latitude * Math.PI) / 180; + const toLatitudeRadians = (to.latitude * Math.PI) / 180; + const latitudeDifferenceRadians = + ((to.latitude - from.latitude) * Math.PI) / 180; + const longitudeDifferenceRadians = + ((to.longitude - from.longitude) * Math.PI) / 180; + + const haversineValue = + Math.sin(latitudeDifferenceRadians / 2) ** 2 + + Math.cos(fromLatitudeRadians) * + Math.cos(toLatitudeRadians) * + Math.sin(longitudeDifferenceRadians / 2) ** 2; + + const centralAngle = + 2 * Math.atan2(Math.sqrt(haversineValue), Math.sqrt(1 - haversineValue)); + + return earthRadiusKm * centralAngle; +}; + +export const validateAttendanceCoordinates = (latitude, longitude) => { + const parsedLatitude = Number(latitude); + const parsedLongitude = Number(longitude); + + if ( + Number.isNaN(parsedLatitude) || + Number.isNaN(parsedLongitude) || + parsedLatitude < -90 || + parsedLatitude > 90 || + parsedLongitude < -180 || + parsedLongitude > 180 + ) { + throw createServiceError("Invalid location coordinates", 400); + } + + return { latitude: parsedLatitude, longitude: parsedLongitude }; +}; + +const getShiftAttendanceLocation = (shift) => { + const latitude = shift.location?.latitude; + const longitude = shift.location?.longitude; + + if (latitude === undefined || longitude === undefined) { + throw createServiceError("Shift location not configured", 400); + } + + return { + latitude: Number(latitude), + longitude: Number(longitude), + }; +}; + +export const getAssignedShiftForAttendance = async (shiftId, guardId) => { + const shift = await Shift.findById(shiftId); + + if (!shift) { + throw createServiceError("Shift not found", 404); + } + + if (String(shift.assignedGuard) !== String(guardId)) { + throw createServiceError("Not assigned to this shift", 403); + } + + return shift; +}; + +export const verifyWithinSiteRadius = ({ + guardLocation, + siteLocation, + radiusKm = DEFAULT_ATTENDANCE_RADIUS_KM, +}) => { + const distanceKm = calculateDistance(guardLocation, siteLocation); + + if (distanceKm > radiusKm) { + throw createServiceError("Not within shift radius (100m)", 400); + } + + return { + withinRadius: true, + distanceKm, + }; +}; + +export const checkInForShift = async ({ + guardId, + shiftId, + latitude, + longitude, + now = new Date(), +}) => { + const guardLocation = validateAttendanceCoordinates(latitude, longitude); + const shift = await getAssignedShiftForAttendance(shiftId, guardId); + + const existingAttendance = await ShiftAttendance.findOne({ + guardId, + shiftId, + }); + + if (existingAttendance) { + throw createServiceError("Already checked in", 400); + } + + const siteLocation = getShiftAttendanceLocation(shift); + + verifyWithinSiteRadius({ + guardLocation, + siteLocation, + }); + + const attendance = new ShiftAttendance({ + guardId, + shiftId, + checkInTime: now, + siteLocation: { + type: "Point", + coordinates: [siteLocation.longitude, siteLocation.latitude], + }, + checkInLocation: { + type: "Point", + coordinates: [guardLocation.longitude, guardLocation.latitude], + }, + locationVerified: true, + }); + + await attendance.save(); + + return attendance; +}; + +export const checkOutForShift = async ({ + shiftId, + guardId, + latitude, + longitude, + now = new Date(), +}) => { + const guardLocation = validateAttendanceCoordinates(latitude, longitude); + + const attendance = await ShiftAttendance.findOne({ + guardId, + shiftId, + }); + + if (!attendance) { + throw createServiceError("No check-in record found", 404); + } + + attendance.checkOutTime = now; + attendance.checkOutLocation = { + type: "Point", + coordinates: [guardLocation.longitude, guardLocation.latitude], + }; + + await attendance.save(); + + return attendance; +}; + +export const getAttendanceHistoryForUser = async (userId) => { + const attendanceRecords = await ShiftAttendance.find({ guardId: userId }).sort({ + checkInTime: -1, + }); + + if (!attendanceRecords.length) { + throw createServiceError("No attendance records found for this user", 404); + } + + return attendanceRecords; +}; + +export const buildAttendancePayrollFacts = (attendance, shift) => ({ + shiftId: shift._id, + guardId: attendance.guardId, + scheduledDate: shift.date, + scheduledStartTime: shift.startTime, + scheduledEndTime: shift.endTime, + payRate: shift.payRate, + checkInTime: attendance.checkInTime, + checkOutTime: attendance.checkOutTime, + locationVerified: attendance.locationVerified, +}); \ No newline at end of file diff --git a/app-backend/tests/attendance.controller.test.js b/app-backend/tests/attendance.controller.test.js index 0e438f8bc..f8a60403e 100644 --- a/app-backend/tests/attendance.controller.test.js +++ b/app-backend/tests/attendance.controller.test.js @@ -1,3 +1,4 @@ +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"; import request from "supertest"; import mongoose from "mongoose"; import app from "../src/app.js"; @@ -11,11 +12,8 @@ describe("Shift Attendance Controller", () => { let guard; let employer; let shift; - let attendanceId; const guardToken = "Bearer guard-token"; - const employerToken = "Bearer employer-token"; - beforeAll(async () => { await mongoose.connect(process.env.MONGO_URI); @@ -39,8 +37,11 @@ describe("Shift Attendance Controller", () => { employerId: employer._id, isActive: true, location: { - latitude: -37.8136, - longitude: 144.9631, + line1: "Main", + city: "Melbourne", + state: "VIC", + postcode: "3000", + country: "Australia", }, }); @@ -50,13 +51,15 @@ describe("Shift Attendance Controller", () => { startTime: "09:00", endTime: "17:00", createdBy: employer._id, - assignedGuard: guard._id, + acceptedBy: guard._id, siteId: branch._id, location: { street: "Main", suburb: "CBD", state: "VIC", postcode: "3000", + latitude: -37.8136, + longitude: 144.9631, }, payRate: 25, shiftType: "Day", @@ -85,8 +88,6 @@ describe("Shift Attendance Controller", () => { expect(res.statusCode).toBe(201); expect(res.body.message).toBe("Check-in recorded"); - - attendanceId = res.body.attendance._id; }); test("Reject duplicate check-in", async () => { @@ -102,7 +103,7 @@ describe("Shift Attendance Controller", () => { }); test("Reject check-in when not assigned guard", async () => { - const otherGuard = await User.create({ + await User.create({ name: "Other", email: "other@test.com", role: "guard", @@ -124,7 +125,7 @@ describe("Shift Attendance Controller", () => { const newShift = await Shift.create({ ...shift.toObject(), _id: undefined, - assignedGuard: guard._id, + acceptedBy: guard._id, }); const res = await request(app) diff --git a/app-backend/tests/services/attendance.service.test.js b/app-backend/tests/services/attendance.service.test.js new file mode 100644 index 000000000..86f600cd3 --- /dev/null +++ b/app-backend/tests/services/attendance.service.test.js @@ -0,0 +1,131 @@ +import { describe, expect, it } from "@jest/globals"; +import { + calculateDistance, + validateAttendanceCoordinates, + verifyWithinSiteRadius, + buildAttendancePayrollFacts, +} from "../../src/services/attendance.service.js"; + +describe("attendance.service", () => { + describe("calculateDistance", () => { + it("returns 0 for identical coordinates", () => { + const location = { + latitude: -37.8136, + longitude: 144.9631 + }; + + const distance = calculateDistance(location, location); + expect(distance).toBe(0); + }); + + it("calculates a non-zero distance for different coordinates", () => { + const location1 = { + latitude: -37.8136, + longitude: 144.9631 + }; + const location2 = { + latitude: -37.8140, + longitude: 144.9640 + }; + + const distance = calculateDistance(location1, location2); + expect(distance).toBeGreaterThan(0); + }); + }); + + describe("validateAttendanceCoordinates", () => { + it("accepts valid coordinates", () => { + const result = validateAttendanceCoordinates(-37.8136, 144.9631); + + expect(result).toEqual({ + latitude: -37.8136, + longitude: 144.9631 + }); + }); + + it("rejects missing coordinates", () => { + expect(() => validateAttendanceCoordinates(undefined, 144.9631)).toThrow( + "Invalid location coordinates"); + }); + + it("rejects invalid latitude", () => { + expect(() => validateAttendanceCoordinates(-100, 144.9631)).toThrow( + "Invalid location coordinates"); + }); + + it("rejects invalid longitude", () => { + expect(() => validateAttendanceCoordinates(-37.8136, 200)).toThrow( + "Invalid location coordinates" + ); + }); + }); + + describe("verifyWithinSiteRadius", () => { + it("passes when guard location is within radius", () => { + const siteLocation = { + latitude: -37.8136, + longitude: 144.9631 + }; + const guardLocation = { + latitude: -37.8137, + longitude: 144.9632 + }; + + const result = verifyWithinSiteRadius({ + guardLocation, + siteLocation, + }); + expect(result.distanceKm).toBeGreaterThan(0); + expect(result.distanceKm).toBeLessThan(0.1); + }); + + it("throws error when guard location is outside radius", () => { + const siteLocation = { + latitude: -37.8136, + longitude: 144.9631 + }; + const guardLocation = { + latitude: 0, + longitude: 0, + }; + + expect(() => + verifyWithinSiteRadius({ + guardLocation, + siteLocation, + }) + ).toThrow("Not within shift radius (100m)"); + }); + }); + + describe("buildAttendancePayrollFacts", () => { + it("returns payroll-ready attendance facts for a shift with check-out", () => { + const shift = { + _id: "shift-1", + date: new Date("2026-05-09"), + startTime: "09:00", + endTime: "17:00", + payRate: 30, + }; + const attendance = { + guardId: "guard-1", + checkInTime: new Date("2026-05-09T09:01:00Z"), + checkOutTime: new Date("2026-05-09T17:02:00Z"), + locationVerified: true, + }; + + const result = buildAttendancePayrollFacts(attendance, shift); + expect(result).toEqual({ + shiftId: "shift-1", + guardId: "guard-1", + scheduledDate: shift.date, + scheduledStartTime: "09:00", + scheduledEndTime: "17:00", + payRate: 30, + checkInTime: attendance.checkInTime, + checkOutTime: attendance.checkOutTime, + locationVerified: true, + }); + }); + }); +}); \ No newline at end of file