diff --git a/app-backend/src/controllers/shiftattendance.controller.js b/app-backend/src/controllers/shiftattendance.controller.js index e9905ffbe..5d2e86d45 100644 --- a/app-backend/src/controllers/shiftattendance.controller.js +++ b/app-backend/src/controllers/shiftattendance.controller.js @@ -1,19 +1,8 @@ -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) { - 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) => { @@ -22,73 +11,19 @@ export const checkIn = async (req, res) => { const { shiftId } = req.params; const guardId = req.user?._id || req.user?.id; - if (latitude === undefined || longitude === undefined) { - return res.status(400).json({ message: "Invalid location coordinates" }); - } - - const latNum = Number(latitude); - const lngNum = Number(longitude); - - if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) { - return res.status(400).json({ message: "Invalid location coordinates" }); - } - - const shift = await Shift.findById(shiftId); - if (!shift) { - return res.status(404).json({ message: "Shift not found" }); - } - - const siteLatitude = shift.location?.latitude; - const siteLongitude = shift.location?.longitude; - - if (!Number.isFinite(siteLatitude) || !Number.isFinite(siteLongitude)) { - return res.status(400).json({ message: "Shift location not configured" }); - } - - if (String(shift.assignedGuard) !== String(guardId)) { - return res.status(403).json({ message: "Not assigned to this shift" }); - } - - const existing = await ShiftAttendance.findOne({ guard: guardId, shift: shiftId }); - if (existing) { - return res.status(400).json({ message: "Already checked in" }); - } - - const distance = calculateDistance(latNum, lngNum, siteLatitude, siteLongitude); - if (distance > 0.1) { - return res.status(400).json({ message: "Not within shift radius (100m)" }); - } - - const [startHour, startMinute] = String(shift.startTime).split(":").map(Number); - const [endHour, endMinute] = String(shift.endTime).split(":").map(Number); - - const scheduledStart = new Date(shift.date); - scheduledStart.setHours(startHour, startMinute, 0, 0); - - const scheduledEnd = new Date(shift.date); - scheduledEnd.setHours(endHour, endMinute, 0, 0); - - if (scheduledEnd <= scheduledStart) { - scheduledEnd.setDate(scheduledEnd.getDate() + 1); - } - - const attendance = new ShiftAttendance({ - shift: shiftId, - guard: guardId, - clockIn: new Date(), - scheduledStart, - scheduledEnd, - recordedBy: guardId, + const attendance = await checkInForShift({ + guardId, + shiftId, + 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 }); } }; @@ -99,43 +34,16 @@ export const checkOut = async (req, res) => { const { shiftId } = req.params; const guardId = req.user?._id || req.user?.id; - if (latitude === undefined || longitude === undefined) { - return res.status(400).json({ message: "Invalid location coordinates" }); - } - - const latNum = Number(latitude); - const lngNum = Number(longitude); - - if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) { - return res.status(400).json({ message: "Invalid location coordinates" }); - } - - const shift = await Shift.findById(shiftId); - if (!shift) { - return res.status(404).json({ message: "Shift not found" }); - } - - if (String(shift.assignedGuard) !== String(guardId)) { - return res.status(403).json({ message: "Not assigned to this shift" }); - } - - const attendance = await ShiftAttendance.findOne({ guard: guardId, shift: shiftId }); - if (!attendance) { - return res.status(404).json({ message: "No check-in record found" }); - } - - if (attendance.clockOut) { - return res.status(400).json({ message: "Already checked out" }); - } - - attendance.clockOut = new Date(); - attendance.recordedBy = guardId; - - await attendance.save(); + const attendance = await checkOutForShift({ + guardId, + shiftId, + latitude, + longitude, + }); return res.status(200).json({ message: "Check-out recorded", attendance }); } catch (error) { - return res.status(500).json({ message: error.message }); + return res.status(error.statusCode || 500).json({ message: error.message }); } }; // GET /api/v1/attendance/:userId @@ -143,14 +51,7 @@ export const getAttendanceByUserId = async (req, res) => { try { const { userId } = req.params; - const attendanceRecords = await ShiftAttendance.find({ guard: userId }) - .sort({ clockIn: -1 }); - - if (!attendanceRecords.length) { - return res.status(404).json({ - message: "No attendance records found for this user", - }); - } + const attendanceRecords = await getAttendanceHistoryForUser(userId); res.status(200).json({ message: "Attendance history retrieved successfully", @@ -158,6 +59,6 @@ export const getAttendanceByUserId = async (req, res) => { attendance: attendanceRecords, }); } catch (error) { - res.status(500).json({ message: error.message }); + 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..e58c1ade1 --- /dev/null +++ b/app-backend/src/services/attendance.service.js @@ -0,0 +1,189 @@ +import mongoose from "mongoose"; +import Shift from "../models/Shift.js"; +import ShiftAttendance from "../models/ShiftAttendance.js"; + +const DEFAULT_ATTENDANCE_RADIUS_KM = 0.1; + +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.isFinite(parsedLatitude) || + !Number.isFinite(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 = Number(shift.location?.latitude); + const longitude = Number(shift.location?.longitude); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + throw createServiceError("Shift location not configured", 400); + } + + return { latitude, longitude }; +}; + +export const getAssignedShiftForAttendance = async (shiftId, guardId) => { + if (!mongoose.Types.ObjectId.isValid(shiftId)) { + throw createServiceError("Shift not found", 404); + } + + 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 ({ + guardId, + shiftId, + latitude, + longitude, + now = new Date(), +}) => { + const guardLocation = validateAttendanceCoordinates(latitude, longitude); + + await getAssignedShiftForAttendance(shiftId, guardId); + + const attendance = await ShiftAttendance.findOne({ + guardId, + shiftId, + }); + + if (!attendance) { + throw createServiceError("No check-in record found", 404); + } + + if (attendance.checkOutTime) { + throw createServiceError("Already checked out", 400); + } + + 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; +}; diff --git a/app-backend/tests/attendance.controller.test.js b/app-backend/tests/attendance.controller.test.js index 0e438f8bc..fea7e2f46 100644 --- a/app-backend/tests/attendance.controller.test.js +++ b/app-backend/tests/attendance.controller.test.js @@ -1,67 +1,96 @@ -import request from "supertest"; +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"; import mongoose from "mongoose"; -import app from "../src/app.js"; import Shift from "../src/models/Shift.js"; import User from "../src/models/User.js"; import ShiftAttendance from "../src/models/ShiftAttendance.js"; import Branch from "../src/models/Branch.js"; +import { + checkInForShift, + checkOutForShift, + getAttendanceHistoryForUser, +} from "../src/services/attendance.service.js"; -describe("Shift Attendance Controller", () => { +describe("Shift attendance service", () => { let guard; + let otherGuard; let employer; + let branch; let shift; - let attendanceId; - const guardToken = "Bearer guard-token"; - const employerToken = "Bearer employer-token"; + const siteCoordinates = { + latitude: -37.8136, + longitude: 144.9631, + }; + + const createAssignedShift = (overrides = {}) => + Shift.create({ + title: overrides.title || "Morning Shift", + date: overrides.date || new Date("2026-12-01T00:00:00.000Z"), + startTime: overrides.startTime || "09:00", + endTime: overrides.endTime || "17:00", + createdBy: employer._id, + acceptedBy: overrides.acceptedBy || guard._id, + siteId: branch._id, + location: { + street: "Main", + suburb: "CBD", + state: "VIC", + postcode: "3000", + latitude: siteCoordinates.latitude, + longitude: siteCoordinates.longitude, + ...overrides.location, + }, + payRate: 25, + shiftType: "Day", + status: "assigned", + }); beforeAll(async () => { await mongoose.connect(process.env.MONGO_URI); + await Promise.all([ + Shift.deleteMany({}), + ShiftAttendance.deleteMany({}), + User.deleteMany({}), + Branch.deleteMany({}), + ]); guard = await User.create({ name: "Guard", - email: "guard@test.com", + email: "guard.attendance@test.com", role: "guard", - password: "hashed", + password: "Password1!", + }); + + otherGuard = await User.create({ + name: "Other Guard", + email: "other.attendance@test.com", + role: "guard", + password: "Password1!", }); employer = await User.create({ name: "Employer", - email: "emp@test.com", + email: "employer.attendance@test.com", role: "employer", - password: "hashed", + password: "Password1!", }); - const branch = await Branch.create({ + branch = await Branch.create({ name: "Site A", - code: "A1", + code: "ATT-A1", employerId: employer._id, isActive: true, location: { - latitude: -37.8136, - longitude: 144.9631, - }, - }); - - shift = await Shift.create({ - title: "Morning Shift", - date: new Date(), - startTime: "09:00", - endTime: "17:00", - createdBy: employer._id, - assignedGuard: guard._id, - siteId: branch._id, - location: { - street: "Main", - suburb: "CBD", + line1: "Main", + city: "Melbourne", state: "VIC", postcode: "3000", + country: "Australia", }, - payRate: 25, - shiftType: "Day", - status: "assigned", }); + + shift = await createAssignedShift(); }); afterAll(async () => { @@ -72,122 +101,110 @@ describe("Shift Attendance Controller", () => { await mongoose.connection.close(); }); - /* ---------------- CHECK-IN ---------------- */ - - test("Guard can check in successfully", async () => { - const res = await request(app) - .post(`/api/v1/attendance/checkin/${shift._id}`) - .set("Authorization", guardToken) - .send({ - latitude: -37.8136, - longitude: 144.9631, - }); - - expect(res.statusCode).toBe(201); - expect(res.body.message).toBe("Check-in recorded"); - - attendanceId = res.body.attendance._id; - }); - - test("Reject duplicate check-in", async () => { - const res = await request(app) - .post(`/api/v1/attendance/checkin/${shift._id}`) - .set("Authorization", guardToken) - .send({ - latitude: -37.8136, - longitude: 144.9631, - }); + test("records check-in with current attendance schema fields", async () => { + const attendance = await checkInForShift({ + guardId: guard._id, + shiftId: shift._id, + ...siteCoordinates, + now: new Date("2026-12-01T09:01:00.000Z"), + }); - expect(res.statusCode).toBe(400); + expect(String(attendance.guardId)).toBe(String(guard._id)); + expect(String(attendance.shiftId)).toBe(String(shift._id)); + expect(attendance.checkInTime).toEqual( + new Date("2026-12-01T09:01:00.000Z") + ); + expect(attendance.checkOutTime).toBeNull(); + expect(attendance.siteLocation.coordinates).toEqual([ + siteCoordinates.longitude, + siteCoordinates.latitude, + ]); }); - test("Reject check-in when not assigned guard", async () => { - const otherGuard = await User.create({ - name: "Other", - email: "other@test.com", - role: "guard", - password: "hashed", + test("rejects duplicate check-in", async () => { + await expect( + checkInForShift({ + guardId: guard._id, + shiftId: shift._id, + ...siteCoordinates, + }) + ).rejects.toMatchObject({ + message: "Already checked in", + statusCode: 400, }); - - const res = await request(app) - .post(`/api/v1/attendance/checkin/${shift._id}`) - .set("Authorization", "Bearer other-token") - .send({ - latitude: -37.8136, - longitude: 144.9631, - }); - - expect(res.statusCode).toBe(403); }); - test("Reject check-in when far from site", async () => { - const newShift = await Shift.create({ - ...shift.toObject(), - _id: undefined, - assignedGuard: guard._id, + test("records check-out for an existing attendance record", async () => { + const attendance = await checkOutForShift({ + guardId: guard._id, + shiftId: shift._id, + ...siteCoordinates, + now: new Date("2026-12-01T17:02:00.000Z"), }); - const res = await request(app) - .post(`/api/v1/attendance/checkin/${newShift._id}`) - .set("Authorization", guardToken) - .send({ - latitude: 0, - longitude: 0, - }); - - expect(res.statusCode).toBe(400); + expect(String(attendance.guardId)).toBe(String(guard._id)); + expect(String(attendance.shiftId)).toBe(String(shift._id)); + expect(attendance.checkOutTime).toEqual( + new Date("2026-12-01T17:02:00.000Z") + ); + expect(attendance.checkOutLocation.coordinates).toEqual([ + siteCoordinates.longitude, + siteCoordinates.latitude, + ]); }); - /* ---------------- CHECK-OUT ---------------- */ - - test("Guard can check out", async () => { - const res = await request(app) - .post(`/api/v1/attendance/checkout/${shift._id}`) - .set("Authorization", guardToken) - .send({ - latitude: -37.8136, - longitude: 144.9631, - }); + test("rejects checkout when attendance record is missing", async () => { + const shiftWithoutAttendance = await createAssignedShift({ + title: "No Attendance Shift", + }); - expect(res.statusCode).toBe(200); - expect(res.body.message).toBe("Check-out recorded"); + await expect( + checkOutForShift({ + guardId: guard._id, + shiftId: shiftWithoutAttendance._id, + ...siteCoordinates, + }) + ).rejects.toMatchObject({ + message: "No check-in record found", + statusCode: 404, + }); }); - test("Reject checkout without check-in", async () => { - const newShift = await Shift.create({ - ...shift.toObject(), - _id: undefined, + test("rejects attendance when guard is not assigned through acceptedBy", async () => { + const assignedShift = await createAssignedShift({ + title: "Assigned Guard Only Shift", }); - const res = await request(app) - .post(`/api/v1/attendance/checkout/${newShift._id}`) - .set("Authorization", guardToken) - .send({ - latitude: -37.8136, - longitude: 144.9631, - }); - - expect(res.statusCode).toBe(404); + await expect( + checkInForShift({ + guardId: otherGuard._id, + shiftId: assignedShift._id, + ...siteCoordinates, + }) + ).rejects.toMatchObject({ + message: "Not assigned to this shift", + statusCode: 403, + }); }); - /* ---------------- GET ATTENDANCE ---------------- */ - - test("Get attendance by userId", async () => { - const res = await request(app) - .get(`/api/v1/attendance/${guard._id}`) - .set("Authorization", guardToken); - - expect(res.statusCode).toBe(200); - expect(Array.isArray(res.body.attendance)).toBe(true); + test("rejects check-in when shift is invalid", async () => { + await expect( + checkInForShift({ + guardId: guard._id, + shiftId: "not-a-shift-id", + ...siteCoordinates, + }) + ).rejects.toMatchObject({ + message: "Shift not found", + statusCode: 404, + }); }); - test("Return 404 if no attendance found", async () => { - const fakeUser = new mongoose.Types.ObjectId(); - - const res = await request(app) - .get(`/api/v1/attendance/${fakeUser}`) - .set("Authorization", guardToken); + test("retrieves attendance history by guardId", async () => { + const attendanceRecords = await getAttendanceHistoryForUser(guard._id); - expect(res.statusCode).toBe(404); + expect(attendanceRecords.length).toBeGreaterThan(0); + expect(String(attendanceRecords[0].guardId)).toBe(String(guard._id)); + expect(attendanceRecords[0]).toHaveProperty("shiftId"); }); -}); \ No newline at end of file +});