Skip to content
Merged
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
141 changes: 21 additions & 120 deletions app-backend/src/controllers/shiftattendance.controller.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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 });
}
};

Expand All @@ -99,65 +34,31 @@ 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
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",
count: attendanceRecords.length,
attendance: attendanceRecords,
});
} catch (error) {
res.status(500).json({ message: error.message });
res.status(error.statusCode || 500).json({ message: error.message });
}
};
};
189 changes: 189 additions & 0 deletions app-backend/src/services/attendance.service.js
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading