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
129 changes: 30 additions & 99 deletions app-backend/src/controllers/shiftattendance.controller.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -21,123 +11,64 @@ 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
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,
});
}
};
};
194 changes: 194 additions & 0 deletions app-backend/src/services/attendance.service.js
Original file line number Diff line number Diff line change
@@ -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,
});
Loading
Loading