feat(attendance): add geofence monitoring and policy controls
This commit is contained in:
@@ -1,36 +1,13 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { withTransaction } from './db.js';
|
||||
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
||||
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
||||
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
||||
|
||||
function toIsoOrNull(value) {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function toRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function distanceMeters(from, to) {
|
||||
if (
|
||||
from?.latitude == null
|
||||
|| from?.longitude == null
|
||||
|| to?.latitude == null
|
||||
|| to?.longitude == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const earthRadiusMeters = 6371000;
|
||||
const dLat = toRadians(Number(to.latitude) - Number(from.latitude));
|
||||
const dLon = toRadians(Number(to.longitude) - Number(from.longitude));
|
||||
const lat1 = toRadians(Number(from.latitude));
|
||||
const lat2 = toRadians(Number(to.latitude));
|
||||
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return Math.round(earthRadiusMeters * c);
|
||||
}
|
||||
|
||||
const ACTIVE_ASSIGNMENT_STATUSES = new Set([
|
||||
'ASSIGNED',
|
||||
'ACCEPTED',
|
||||
@@ -179,10 +156,14 @@ async function requireAssignment(client, assignmentId) {
|
||||
s.title AS shift_title,
|
||||
s.starts_at,
|
||||
s.ends_at,
|
||||
s.clock_in_mode,
|
||||
s.allow_clock_in_override,
|
||||
cp.nfc_tag_uid AS expected_nfc_tag_uid,
|
||||
cp.latitude AS expected_latitude,
|
||||
cp.longitude AS expected_longitude,
|
||||
cp.geofence_radius_meters
|
||||
COALESCE(s.latitude, cp.latitude) AS expected_latitude,
|
||||
COALESCE(s.longitude, cp.longitude) AS expected_longitude,
|
||||
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS geofence_radius_meters,
|
||||
cp.default_clock_in_mode,
|
||||
cp.allow_clock_in_override AS default_allow_clock_in_override
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
@@ -1106,53 +1087,38 @@ export async function assignStaffToShift(actor, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildAttendanceValidation(assignment, payload) {
|
||||
const expectedPoint = {
|
||||
latitude: assignment.expected_latitude,
|
||||
longitude: assignment.expected_longitude,
|
||||
};
|
||||
const actualPoint = {
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
};
|
||||
const distance = distanceMeters(actualPoint, expectedPoint);
|
||||
const expectedNfcTag = assignment.expected_nfc_tag_uid;
|
||||
const radius = assignment.geofence_radius_meters;
|
||||
|
||||
let validationStatus = 'ACCEPTED';
|
||||
let validationReason = null;
|
||||
|
||||
if (expectedNfcTag && payload.sourceType === 'NFC' && payload.nfcTagUid !== expectedNfcTag) {
|
||||
validationStatus = 'REJECTED';
|
||||
validationReason = 'NFC tag mismatch';
|
||||
}
|
||||
|
||||
if (
|
||||
validationStatus === 'ACCEPTED'
|
||||
&& distance != null
|
||||
&& radius != null
|
||||
&& distance > radius
|
||||
) {
|
||||
validationStatus = 'REJECTED';
|
||||
validationReason = `Outside geofence by ${distance - radius} meters`;
|
||||
}
|
||||
|
||||
return {
|
||||
distance,
|
||||
validationStatus,
|
||||
validationReason,
|
||||
withinGeofence: distance == null || radius == null ? null : distance <= radius,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAttendanceEvent(actor, payload, eventType) {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||
const validation = buildAttendanceValidation(assignment, payload);
|
||||
const validation = evaluateClockInAttempt(assignment, payload);
|
||||
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
||||
|
||||
if (validation.validationStatus === 'REJECTED') {
|
||||
const incidentType = validation.validationCode === 'NFC_MISMATCH'
|
||||
? 'NFC_MISMATCH'
|
||||
: 'CLOCK_IN_REJECTED';
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
incidentType,
|
||||
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
sourceType: payload.sourceType,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
deviceId: payload.deviceId || null,
|
||||
latitude: payload.latitude ?? null,
|
||||
longitude: payload.longitude ?? null,
|
||||
accuracyMeters: payload.accuracyMeters ?? null,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
withinGeofence: validation.withinGeofence,
|
||||
message: validation.validationReason,
|
||||
occurredAt: capturedAt,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
const rejectedEvent = await client.query(
|
||||
`
|
||||
INSERT INTO attendance_events (
|
||||
@@ -1213,6 +1179,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
assignmentId: assignment.id,
|
||||
sourceType: payload.sourceType,
|
||||
validationReason: validation.validationReason,
|
||||
incidentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1220,6 +1187,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
assignmentId: payload.assignmentId,
|
||||
attendanceEventId: rejectedEvent.rows[0].id,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1289,12 +1257,60 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
validation.distance,
|
||||
validation.withinGeofence,
|
||||
validation.validationStatus,
|
||||
validation.validationReason,
|
||||
validation.overrideUsed ? validation.overrideReason : validation.validationReason,
|
||||
capturedAt,
|
||||
JSON.stringify(payload.rawPayload || {}),
|
||||
]
|
||||
);
|
||||
|
||||
if (validation.overrideUsed) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
incidentType: 'CLOCK_IN_OVERRIDE',
|
||||
severity: 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
sourceType: payload.sourceType,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
deviceId: payload.deviceId || null,
|
||||
latitude: payload.latitude ?? null,
|
||||
longitude: payload.longitude ?? null,
|
||||
accuracyMeters: payload.accuracyMeters ?? null,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
withinGeofence: validation.withinGeofence,
|
||||
overrideReason: validation.overrideReason,
|
||||
message: validation.validationReason,
|
||||
occurredAt: capturedAt,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
eventType,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueHubManagerAlert(client, {
|
||||
tenantId: assignment.tenant_id,
|
||||
businessId: assignment.business_id,
|
||||
shiftId: assignment.shift_id,
|
||||
assignmentId: assignment.id,
|
||||
hubId: assignment.clock_point_id,
|
||||
relatedIncidentId: incidentId,
|
||||
notificationType: 'CLOCK_IN_OVERRIDE_REVIEW',
|
||||
priority: 'HIGH',
|
||||
subject: 'Clock-in override requires review',
|
||||
body: `${assignment.shift_title}: clock-in override submitted by ${actor.email || actor.uid}`,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
staffId: assignment.staff_id,
|
||||
reason: validation.overrideReason,
|
||||
validationReason: validation.validationReason,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
eventType,
|
||||
},
|
||||
dedupeScope: incidentId,
|
||||
});
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
if (eventType === 'CLOCK_IN') {
|
||||
const insertedSession = await client.query(
|
||||
@@ -1360,6 +1376,7 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
assignmentId: assignment.id,
|
||||
sessionId,
|
||||
sourceType: payload.sourceType,
|
||||
validationStatus: validation.validationStatus,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1369,6 +1386,8 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
sessionId,
|
||||
status: eventType,
|
||||
validationStatus: eventResult.rows[0].validation_status,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
overrideUsed: validation.overrideUsed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user