feat(attendance): add geofence monitoring and policy controls
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user