feat(attendance): add geofence monitoring and policy controls

This commit is contained in:
zouantchaw
2026-03-16 15:31:13 +01:00
parent b455455a49
commit 5d8240ed51
22 changed files with 1667 additions and 162 deletions

View File

@@ -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,
};
});
}