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

@@ -2,6 +2,10 @@ import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { query, withTransaction } from './db.js';
import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js';
import { recordGeofenceIncident } from './attendance-monitoring.js';
import { distanceMeters, resolveEffectiveClockInPolicy } from './clock-in-policy.js';
import { uploadLocationBatch } from './location-log-storage.js';
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
import {
cancelOrder as cancelOrderCommand,
clockIn as clockInCommand,
@@ -30,6 +34,17 @@ function ensureArray(value) {
return Array.isArray(value) ? value : [];
}
function buildAssignmentReferencePayload(assignment) {
return {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
businessId: assignment.business_id,
vendorId: assignment.vendor_id,
staffId: assignment.staff_id,
clockPointId: assignment.clock_point_id,
};
}
function generateOrderNumber(prefix = 'ORD') {
const stamp = Date.now().toString().slice(-8);
const random = crypto.randomInt(100, 999);
@@ -460,6 +475,51 @@ async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { req
throw new AppError('NOT_FOUND', 'No assignment found for the current staff clock action', 404, payload);
}
async function loadAssignmentMonitoringContext(client, tenantId, assignmentId, actorUid) {
const result = await client.query(
`
SELECT
a.id,
a.tenant_id,
a.business_id,
a.vendor_id,
a.shift_id,
a.shift_role_id,
a.staff_id,
a.status,
s.clock_point_id,
s.title AS shift_title,
s.starts_at,
s.ends_at,
s.clock_in_mode,
s.allow_clock_in_override,
cp.default_clock_in_mode,
cp.allow_clock_in_override AS default_allow_clock_in_override,
cp.nfc_tag_uid AS expected_nfc_tag_uid,
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
FROM assignments a
JOIN staffs st ON st.id = a.staff_id
JOIN shifts s ON s.id = a.shift_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE a.tenant_id = $1
AND a.id = $2
AND st.user_id = $3
FOR UPDATE OF a, s
`,
[tenantId, assignmentId, actorUid]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Assignment not found in staff scope', 404, {
assignmentId,
});
}
return result.rows[0];
}
async function ensureActorUser(client, actor, fields = {}) {
await client.query(
`
@@ -541,7 +601,20 @@ async function requireBusinessMembership(client, businessId, userId) {
async function requireClockPoint(client, tenantId, businessId, hubId, { forUpdate = false } = {}) {
const result = await client.query(
`
SELECT id, tenant_id, business_id, label, status, cost_center_id, nfc_tag_uid, metadata
SELECT
id,
tenant_id,
business_id,
label,
status,
cost_center_id,
nfc_tag_uid,
latitude,
longitude,
geofence_radius_meters,
default_clock_in_mode,
allow_clock_in_override,
metadata
FROM clock_points
WHERE id = $1
AND tenant_id = $2
@@ -868,11 +941,13 @@ export async function createHub(actor, payload) {
longitude,
geofence_radius_meters,
nfc_tag_uid,
default_clock_in_mode,
allow_clock_in_override,
cost_center_id,
status,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, $9, 'ACTIVE', $10::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, COALESCE($9, 'EITHER'), COALESCE($10, TRUE), $11, 'ACTIVE', $12::jsonb)
RETURNING id
`,
[
@@ -884,6 +959,8 @@ export async function createHub(actor, payload) {
payload.longitude ?? null,
payload.geofenceRadiusMeters ?? null,
payload.nfcTagId || null,
payload.clockInMode || null,
payload.allowClockInOverride ?? null,
costCenterId,
JSON.stringify({
placeId: payload.placeId || null,
@@ -892,6 +969,7 @@ export async function createHub(actor, payload) {
state: payload.state || null,
country: payload.country || null,
zipCode: payload.zipCode || null,
clockInPolicyConfiguredBy: businessMembership.id,
createdByMembershipId: businessMembership.id,
}),
]
@@ -954,7 +1032,9 @@ export async function updateHub(actor, payload) {
longitude = COALESCE($5, longitude),
geofence_radius_meters = COALESCE($6, geofence_radius_meters),
cost_center_id = COALESCE($7, cost_center_id),
metadata = $8::jsonb,
default_clock_in_mode = COALESCE($8, default_clock_in_mode),
allow_clock_in_override = COALESCE($9, allow_clock_in_override),
metadata = $10::jsonb,
updated_at = NOW()
WHERE id = $1
`,
@@ -966,6 +1046,8 @@ export async function updateHub(actor, payload) {
payload.longitude ?? null,
payload.geofenceRadiusMeters ?? null,
costCenterId || null,
payload.clockInMode || null,
payload.allowClockInOverride ?? null,
JSON.stringify(nextMetadata),
]
);
@@ -1309,9 +1391,22 @@ export async function cancelLateWorker(actor, payload) {
await ensureActorUser(client, actor);
const result = await client.query(
`
SELECT a.id, a.shift_id, a.shift_role_id, a.staff_id, a.status, s.required_workers, s.assigned_workers, s.tenant_id
SELECT
a.id,
a.shift_id,
a.shift_role_id,
a.staff_id,
a.status,
s.required_workers,
s.assigned_workers,
s.tenant_id,
s.clock_point_id,
s.starts_at,
s.title AS shift_title,
st.user_id AS "staffUserId"
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
JOIN staffs st ON st.id = a.staff_id
WHERE a.id = $1
AND a.tenant_id = $2
AND a.business_id = $3
@@ -1325,6 +1420,34 @@ export async function cancelLateWorker(actor, payload) {
});
}
const assignment = result.rows[0];
if (['CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) {
throw new AppError('LATE_WORKER_CANCEL_BLOCKED', 'Worker is already checked in or completed and cannot be cancelled as late', 409, {
assignmentId: assignment.id,
});
}
const hasRecentIncident = await client.query(
`
SELECT 1
FROM geofence_incidents
WHERE assignment_id = $1
AND incident_type IN ('OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'CLOCK_IN_OVERRIDE')
AND occurred_at >= $2::timestamptz - INTERVAL '30 minutes'
LIMIT 1
`,
[assignment.id, assignment.starts_at]
);
const shiftStartTime = assignment.starts_at ? new Date(assignment.starts_at).getTime() : null;
const startGraceElapsed = shiftStartTime != null
? Date.now() >= shiftStartTime + (10 * 60 * 1000)
: false;
if (!startGraceElapsed && hasRecentIncident.rowCount === 0) {
throw new AppError('LATE_WORKER_NOT_CONFIRMED', 'Late worker cancellation requires either a geofence incident or a started shift window', 409, {
assignmentId: assignment.id,
});
}
await client.query(
`
UPDATE assignments
@@ -1349,6 +1472,43 @@ export async function cancelLateWorker(actor, payload) {
actorUserId: actor.uid,
payload,
});
await enqueueHubManagerAlert(client, {
tenantId: context.tenant.tenantId,
businessId: context.business.businessId,
shiftId: assignment.shift_id,
assignmentId: assignment.id,
hubId: assignment.clock_point_id,
notificationType: 'LATE_WORKER_CANCELLED',
priority: 'HIGH',
subject: 'Late worker was removed from shift',
body: `${assignment.shift_title}: a late worker was cancelled and replacement search should begin`,
payload: {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
reason: payload.reason || 'Cancelled for lateness',
},
dedupeScope: assignment.id,
});
await enqueueUserAlert(client, {
tenantId: context.tenant.tenantId,
businessId: context.business.businessId,
shiftId: assignment.shift_id,
assignmentId: assignment.id,
recipientUserId: assignment.staffUserId,
notificationType: 'SHIFT_ASSIGNMENT_CANCELLED_LATE',
priority: 'HIGH',
subject: 'Shift assignment cancelled',
body: `${assignment.shift_title}: your assignment was cancelled because you were marked late`,
payload: {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
reason: payload.reason || 'Cancelled for lateness',
},
dedupeScope: assignment.id,
});
return {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
@@ -1453,6 +1613,7 @@ export async function staffClockIn(actor, payload) {
longitude: payload.longitude,
accuracyMeters: payload.accuracyMeters,
capturedAt: payload.capturedAt,
overrideReason: payload.overrideReason || null,
rawPayload: {
notes: payload.notes || null,
...(payload.rawPayload || {}),
@@ -1478,6 +1639,7 @@ export async function staffClockOut(actor, payload) {
longitude: payload.longitude,
accuracyMeters: payload.accuracyMeters,
capturedAt: payload.capturedAt,
overrideReason: payload.overrideReason || null,
rawPayload: {
notes: payload.notes || null,
breakMinutes: payload.breakMinutes ?? null,
@@ -1487,6 +1649,256 @@ export async function staffClockOut(actor, payload) {
});
}
function summarizeLocationPoints(points, assignment) {
let outOfGeofenceCount = 0;
let missingCoordinateCount = 0;
let maxDistance = null;
let latestOutsidePoint = null;
let latestMissingPoint = null;
for (const point of points) {
if (point.latitude == null || point.longitude == null) {
missingCoordinateCount += 1;
latestMissingPoint = point;
continue;
}
const distance = distanceMeters(
{
latitude: point.latitude,
longitude: point.longitude,
},
{
latitude: assignment.expected_latitude,
longitude: assignment.expected_longitude,
}
);
if (distance != null) {
maxDistance = maxDistance == null ? distance : Math.max(maxDistance, distance);
if (
assignment.geofence_radius_meters != null
&& distance > assignment.geofence_radius_meters
) {
outOfGeofenceCount += 1;
latestOutsidePoint = { ...point, distanceToClockPointMeters: distance };
}
}
}
return {
outOfGeofenceCount,
missingCoordinateCount,
maxDistanceToClockPointMeters: maxDistance,
latestOutsidePoint,
latestMissingPoint,
};
}
export async function submitLocationStreamBatch(actor, payload) {
const context = await requireStaffContext(actor.uid);
const { assignmentId } = await resolveStaffAssignmentForClock(
actor.uid,
context.tenant.tenantId,
payload,
{ requireOpenSession: true }
);
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
const assignment = await loadAssignmentMonitoringContext(
client,
context.tenant.tenantId,
assignmentId,
actor.uid
);
const policy = resolveEffectiveClockInPolicy(assignment);
const points = [...payload.points]
.map((point) => ({
...point,
capturedAt: toIsoOrNull(point.capturedAt),
}))
.sort((left, right) => new Date(left.capturedAt).getTime() - new Date(right.capturedAt).getTime());
const batchId = crypto.randomUUID();
const summary = summarizeLocationPoints(points, assignment);
const objectUri = await uploadLocationBatch({
tenantId: assignment.tenant_id,
staffId: assignment.staff_id,
assignmentId: assignment.id,
batchId,
payload: {
...buildAssignmentReferencePayload(assignment),
effectiveClockInMode: policy.mode,
points,
metadata: payload.metadata || {},
},
});
await client.query(
`
INSERT INTO location_stream_batches (
id,
tenant_id,
business_id,
vendor_id,
shift_id,
assignment_id,
staff_id,
actor_user_id,
source_type,
device_id,
object_uri,
point_count,
out_of_geofence_count,
missing_coordinate_count,
max_distance_to_clock_point_meters,
started_at,
ended_at,
metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17::timestamptz, $18::jsonb
)
`,
[
batchId,
assignment.tenant_id,
assignment.business_id,
assignment.vendor_id,
assignment.shift_id,
assignment.id,
assignment.staff_id,
actor.uid,
payload.sourceType,
payload.deviceId || null,
objectUri,
points.length,
summary.outOfGeofenceCount,
summary.missingCoordinateCount,
summary.maxDistanceToClockPointMeters,
points[0]?.capturedAt || null,
points[points.length - 1]?.capturedAt || null,
JSON.stringify(payload.metadata || {}),
]
);
const incidentIds = [];
if (summary.outOfGeofenceCount > 0) {
const incidentId = await recordGeofenceIncident(client, {
assignment,
actorUserId: actor.uid,
locationStreamBatchId: batchId,
incidentType: 'OUTSIDE_GEOFENCE',
severity: 'CRITICAL',
effectiveClockInMode: policy.mode,
sourceType: payload.sourceType,
deviceId: payload.deviceId || null,
latitude: summary.latestOutsidePoint?.latitude ?? null,
longitude: summary.latestOutsidePoint?.longitude ?? null,
accuracyMeters: summary.latestOutsidePoint?.accuracyMeters ?? null,
distanceToClockPointMeters: summary.latestOutsidePoint?.distanceToClockPointMeters ?? null,
withinGeofence: false,
message: `${summary.outOfGeofenceCount} location points were outside the configured geofence`,
occurredAt: summary.latestOutsidePoint?.capturedAt || points[points.length - 1]?.capturedAt || null,
metadata: {
pointCount: points.length,
outOfGeofenceCount: summary.outOfGeofenceCount,
objectUri,
},
});
incidentIds.push(incidentId);
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: 'GEOFENCE_BREACH_ALERT',
priority: 'CRITICAL',
subject: 'Worker left the workplace geofence',
body: `${assignment.shift_title}: location stream shows the worker outside the geofence`,
payload: {
...buildAssignmentReferencePayload(assignment),
batchId,
objectUri,
outOfGeofenceCount: summary.outOfGeofenceCount,
},
dedupeScope: batchId,
});
}
if (summary.missingCoordinateCount > 0) {
const incidentId = await recordGeofenceIncident(client, {
assignment,
actorUserId: actor.uid,
locationStreamBatchId: batchId,
incidentType: 'LOCATION_UNAVAILABLE',
severity: 'WARNING',
effectiveClockInMode: policy.mode,
sourceType: payload.sourceType,
deviceId: payload.deviceId || null,
message: `${summary.missingCoordinateCount} location points were missing coordinates`,
occurredAt: summary.latestMissingPoint?.capturedAt || points[points.length - 1]?.capturedAt || null,
metadata: {
pointCount: points.length,
missingCoordinateCount: summary.missingCoordinateCount,
objectUri,
},
});
incidentIds.push(incidentId);
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: 'LOCATION_SIGNAL_WARNING',
priority: 'HIGH',
subject: 'Worker location signal unavailable',
body: `${assignment.shift_title}: background location tracking reported missing coordinates`,
payload: {
...buildAssignmentReferencePayload(assignment),
batchId,
objectUri,
missingCoordinateCount: summary.missingCoordinateCount,
},
dedupeScope: `${batchId}:missing`,
});
}
await insertDomainEvent(client, {
tenantId: assignment.tenant_id,
aggregateType: 'location_stream_batch',
aggregateId: batchId,
eventType: 'LOCATION_STREAM_BATCH_RECORDED',
actorUserId: actor.uid,
payload: {
...buildAssignmentReferencePayload(assignment),
batchId,
objectUri,
pointCount: points.length,
outOfGeofenceCount: summary.outOfGeofenceCount,
missingCoordinateCount: summary.missingCoordinateCount,
},
});
return {
batchId,
assignmentId: assignment.id,
shiftId: assignment.shift_id,
effectiveClockInMode: policy.mode,
pointCount: points.length,
outOfGeofenceCount: summary.outOfGeofenceCount,
missingCoordinateCount: summary.missingCoordinateCount,
objectUri,
incidentIds,
};
});
}
export async function updateStaffAvailabilityDay(actor, payload) {
const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => {