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

@@ -17,6 +17,7 @@ import {
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
listGeofenceIncidents,
getReportSummary,
getSavings,
getStaffDashboard,
@@ -77,6 +78,7 @@ const defaultQueryService = {
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
listGeofenceIncidents,
getReportSummary,
getSavings,
getSpendBreakdown,
@@ -242,6 +244,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/coverage/incidents', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listGeofenceIncidents(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubs(req.actor.uid);

View File

@@ -416,7 +416,10 @@ export async function listHubs(actorUid) {
cp.address AS "fullAddress",
cp.latitude,
cp.longitude,
cp.geofence_radius_meters AS "geofenceRadiusMeters",
cp.nfc_tag_uid AS "nfcTagId",
cp.default_clock_in_mode AS "clockInMode",
cp.allow_clock_in_override AS "allowClockInOverride",
cp.metadata->>'city' AS city,
cp.metadata->>'state' AS state,
cp.metadata->>'zipCode' AS "zipCode",
@@ -631,6 +634,10 @@ export async function listTodayShifts(actorUid) {
COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode",
COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride",
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters",
cp.nfc_tag_uid AS "nfcTagId",
COALESCE(attendance_sessions.status, 'NOT_CLOCKED_IN') AS "attendanceStatus",
attendance_sessions.check_in_at AS "clockInAt"
FROM assignments a
@@ -902,6 +909,10 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
s.starts_at AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode",
COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride",
COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters",
cp.nfc_tag_uid AS "nfcTagId",
sr.id AS "roleId",
sr.role_name AS "roleName",
sr.pay_rate_cents AS "hourlyRateCents",
@@ -1566,6 +1577,39 @@ export async function getNoShowReport(actorUid, { startDate, endDate }) {
};
}
export async function listGeofenceIncidents(actorUid, { startDate, endDate, status } = {}) {
const context = await requireClientContext(actorUid);
const range = parseDateRange(startDate, endDate, 14);
const result = await query(
`
SELECT
gi.id AS "incidentId",
gi.assignment_id AS "assignmentId",
gi.shift_id AS "shiftId",
st.full_name AS "staffName",
gi.incident_type AS "incidentType",
gi.severity,
gi.status,
gi.effective_clock_in_mode AS "clockInMode",
gi.override_reason AS "overrideReason",
gi.message,
gi.distance_to_clock_point_meters AS "distanceToClockPointMeters",
gi.within_geofence AS "withinGeofence",
gi.occurred_at AS "occurredAt"
FROM geofence_incidents gi
LEFT JOIN staffs st ON st.id = gi.staff_id
WHERE gi.tenant_id = $1
AND gi.business_id = $2
AND gi.occurred_at >= $3::timestamptz
AND gi.occurred_at <= $4::timestamptz
AND ($5::text IS NULL OR gi.status = $5)
ORDER BY gi.occurred_at DESC
`,
[context.tenant.tenantId, context.business.businessId, range.start, range.end, status || null]
);
return result.rows;
}
export async function listEmergencyContacts(actorUid) {
const context = await requireStaffContext(actorUid);
const result = await query(

View File

@@ -40,6 +40,7 @@ function createMobileQueryService() {
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]),
listHubManagers: async () => ([{ managerId: 'm1' }]),
listHubs: async () => ([{ hubId: 'hub-1' }]),
listIndustries: async () => (['CATERING']),
@@ -127,6 +128,16 @@ test('GET /query/client/coverage/core-team returns injected core team list', asy
assert.equal(res.body.items[0].staffId, 'core-1');
});
test('GET /query/client/coverage/incidents returns injected incidents list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/incidents?startDate=2026-03-01&endDate=2026-03-16')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].incidentId, 'incident-1');
});
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)