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

@@ -44,8 +44,8 @@ async function main() {
const completedEndsAt = hoursFromNow(-20);
const checkedInAt = hoursFromNow(-27.5);
const checkedOutAt = hoursFromNow(-20.25);
const assignedStartsAt = hoursFromNow(2);
const assignedEndsAt = hoursFromNow(10);
const assignedStartsAt = hoursFromNow(0.1);
const assignedEndsAt = hoursFromNow(8.1);
const availableStartsAt = hoursFromNow(30);
const availableEndsAt = hoursFromNow(38);
const cancelledStartsAt = hoursFromNow(20);
@@ -270,9 +270,9 @@ async function main() {
`
INSERT INTO clock_points (
id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude,
geofence_radius_meters, nfc_tag_uid, status, metadata
geofence_radius_meters, nfc_tag_uid, default_clock_in_mode, allow_clock_in_override, status, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'ACTIVE', $11::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'ACTIVE', $13::jsonb)
`,
[
fixture.clockPoint.id,
@@ -285,6 +285,8 @@ async function main() {
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
fixture.clockPoint.nfcTagUid,
fixture.clockPoint.defaultClockInMode,
fixture.clockPoint.allowClockInOverride,
JSON.stringify({ city: 'Mountain View', state: 'CA', zipCode: '94043', seeded: true }),
]
);
@@ -369,11 +371,12 @@ async function main() {
`
INSERT INTO shifts (
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
required_workers, assigned_workers, notes, metadata
)
VALUES
($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb),
($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, NULL, NULL, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb),
($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, NULL, NULL, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
`,
[
fixture.shifts.open.id,
@@ -407,13 +410,14 @@ async function main() {
`
INSERT INTO shifts (
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata
location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override,
required_workers, assigned_workers, notes, metadata
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb),
($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb),
($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb),
($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb),
($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $30, $31, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb),
($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb),
($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 'GEO_REQUIRED', TRUE, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb)
`,
[
fixture.shifts.available.id,
@@ -445,6 +449,8 @@ async function main() {
fixture.shifts.noShow.title,
noShowStartsAt,
noShowEndsAt,
fixture.shifts.assigned.clockInMode,
fixture.shifts.assigned.allowClockInOverride,
]
);
@@ -833,6 +839,96 @@ async function main() {
]
);
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, 'GEO', 'seed-device',
$9, 4, 2, 0, 910, $10, $11, '{"seeded":true,"source":"seed-v2-demo"}'::jsonb
)
`,
[
fixture.locationStreamBatches.noShowSample.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.staff.ana.id,
fixture.users.staffAna.id,
`gs://krow-workforce-dev-v2-private/location-streams/${fixture.tenant.id}/${fixture.staff.ana.id}/${fixture.assignments.noShowAna.id}/${fixture.locationStreamBatches.noShowSample.id}.json`,
hoursFromNow(-18.25),
hoursFromNow(-17.75),
]
);
await client.query(
`
INSERT INTO geofence_incidents (
id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id, location_stream_batch_id,
incident_type, severity, status, effective_clock_in_mode, source_type, device_id,
latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
override_reason, message, occurred_at, metadata
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'OUTSIDE_GEOFENCE', 'CRITICAL', 'OPEN', 'GEO_REQUIRED', 'GEO', 'seed-device',
$10, $11, 12, 910, FALSE, NULL, 'Worker drifted outside hub geofence during active monitoring',
$12, '{"seeded":true,"outOfGeofenceCount":2}'::jsonb
)
`,
[
fixture.geofenceIncidents.noShowOutsideGeofence.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.staff.ana.id,
fixture.users.staffAna.id,
fixture.locationStreamBatches.noShowSample.id,
fixture.clockPoint.latitude + 0.0065,
fixture.clockPoint.longitude + 0.0065,
hoursFromNow(-17.9),
]
);
await client.query(
`
INSERT INTO notification_outbox (
id, tenant_id, business_id, shift_id, assignment_id, related_incident_id, audience_type,
recipient_user_id, recipient_business_membership_id, channel, notification_type, priority, dedupe_key,
subject, body, payload, status, scheduled_at, created_at, updated_at
)
SELECT
$1, $2, $3, $4, $5, $6, 'USER',
bm.user_id, bm.id, 'PUSH', 'GEOFENCE_BREACH_ALERT', 'CRITICAL', $7,
'Worker left the workplace geofence',
'Seeded alert for coverage incident review',
jsonb_build_object('seeded', TRUE, 'batchId', $8::text),
'PENDING', NOW(), NOW(), NOW()
FROM business_memberships bm
WHERE bm.tenant_id = $2
AND bm.business_id = $3
AND bm.user_id = $9
`,
[
fixture.notificationOutbox.noShowManagerAlert.id,
fixture.tenant.id,
fixture.business.id,
fixture.shifts.noShow.id,
fixture.assignments.noShowAna.id,
fixture.geofenceIncidents.noShowOutsideGeofence.id,
`seed-geofence-breach:${fixture.geofenceIncidents.noShowOutsideGeofence.id}:${fixture.users.operationsManager.id}`,
fixture.locationStreamBatches.noShowSample.id,
fixture.users.operationsManager.id,
]
);
await client.query('COMMIT');
// eslint-disable-next-line no-console

View File

@@ -6,8 +6,8 @@ export const V2DemoFixture = {
},
users: {
businessOwner: {
id: process.env.V2_DEMO_OWNER_UID || 'dvpWnaBjT6UksS5lo04hfMTyq1q1',
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary@krowd.com',
id: process.env.V2_DEMO_OWNER_UID || 'alFf9mYw3uYbm7ZjeLo1KoTgFxq2',
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com',
displayName: 'Legendary Demo Owner',
},
operationsManager: {
@@ -21,7 +21,7 @@ export const V2DemoFixture = {
displayName: 'Vendor Manager',
},
staffAna: {
id: process.env.V2_DEMO_STAFF_UID || 'demo-staff-ana',
id: process.env.V2_DEMO_STAFF_UID || 'vwptrLl5S2Z598WP93cgrQEzqBg1',
email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com',
displayName: 'Ana Barista',
},
@@ -77,6 +77,8 @@ export const V2DemoFixture = {
longitude: -122.0841,
geofenceRadiusMeters: 120,
nfcTagUid: 'NFC-DEMO-ANA-001',
defaultClockInMode: 'GEO_REQUIRED',
allowClockInOverride: true,
},
hubManagers: {
opsLead: {
@@ -134,6 +136,8 @@ export const V2DemoFixture = {
id: '6e7dadad-99e4-45bb-b0da-7bb617954004',
code: 'SHIFT-V2-ASSIGNED-1',
title: 'Assigned espresso shift',
clockInMode: 'GEO_REQUIRED',
allowClockInOverride: true,
},
cancelled: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954005',
@@ -268,4 +272,19 @@ export const V2DemoFixture = {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe002',
},
},
locationStreamBatches: {
noShowSample: {
id: '7184a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
geofenceIncidents: {
noShowOutsideGeofence: {
id: '8174a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
notificationOutbox: {
noShowManagerAlert: {
id: '9174a512-b5b2-46b7-a8e0-f4a04bb8f001',
},
},
};