feat(attendance): add geofence monitoring and policy controls
This commit is contained in:
@@ -16,6 +16,7 @@ const preferredLocationSchema = z.object({
|
||||
|
||||
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
|
||||
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
|
||||
const clockInModeSchema = z.enum(['NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER']);
|
||||
|
||||
const shiftPositionSchema = z.object({
|
||||
roleId: z.string().uuid().optional(),
|
||||
@@ -68,6 +69,8 @@ export const hubCreateSchema = z.object({
|
||||
costCenterId: z.string().uuid().optional(),
|
||||
geofenceRadiusMeters: z.number().int().positive().optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
clockInMode: clockInModeSchema.optional(),
|
||||
allowClockInOverride: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const hubUpdateSchema = hubCreateSchema.extend({
|
||||
@@ -203,6 +206,7 @@ export const staffClockInSchema = z.object({
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||
message: 'assignmentId or shiftId is required',
|
||||
@@ -221,12 +225,34 @@ export const staffClockOutSchema = z.object({
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
breakMinutes: z.number().int().nonnegative().optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
|
||||
message: 'assignmentId, shiftId, or applicationId is required',
|
||||
});
|
||||
|
||||
const locationPointSchema = z.object({
|
||||
capturedAt: z.string().datetime(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
speedMps: z.number().nonnegative().optional(),
|
||||
isMocked: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const staffLocationBatchSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).default('GEO'),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
points: z.array(locationPointSchema).min(1).max(96),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||
message: 'assignmentId or shiftId is required',
|
||||
});
|
||||
|
||||
export const staffProfileSetupSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
bio: z.string().max(5000).optional(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffLocationBatchSchema,
|
||||
staffProfileSetupSchema,
|
||||
taxFormDraftSchema,
|
||||
taxFormSubmitSchema,
|
||||
@@ -94,6 +96,7 @@ const defaultHandlers = {
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
@@ -296,6 +299,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
handler: handlers.staffClockOut,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/location-streams', {
|
||||
schema: staffLocationBatchSchema,
|
||||
policyAction: 'attendance.location-stream.write',
|
||||
resource: 'attendance',
|
||||
handler: handlers.submitLocationStreamBatch,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/availability', {
|
||||
schema: availabilityDayUpdateSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
|
||||
84
backend/command-api/src/services/attendance-monitoring.js
Normal file
84
backend/command-api/src/services/attendance-monitoring.js
Normal file
@@ -0,0 +1,84 @@
|
||||
export async function recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId,
|
||||
locationStreamBatchId = null,
|
||||
incidentType,
|
||||
severity = 'WARNING',
|
||||
status = 'OPEN',
|
||||
effectiveClockInMode = null,
|
||||
sourceType = null,
|
||||
nfcTagUid = null,
|
||||
deviceId = null,
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
accuracyMeters = null,
|
||||
distanceToClockPointMeters = null,
|
||||
withinGeofence = null,
|
||||
overrideReason = null,
|
||||
message = null,
|
||||
occurredAt = null,
|
||||
metadata = {},
|
||||
}) {
|
||||
const result = await client.query(
|
||||
`
|
||||
INSERT INTO geofence_incidents (
|
||||
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,
|
||||
nfc_tag_uid,
|
||||
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, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, COALESCE($23::timestamptz, NOW()), $24::jsonb
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
assignment.tenant_id,
|
||||
assignment.business_id,
|
||||
assignment.vendor_id,
|
||||
assignment.shift_id,
|
||||
assignment.id,
|
||||
assignment.staff_id,
|
||||
actorUserId,
|
||||
locationStreamBatchId,
|
||||
incidentType,
|
||||
severity,
|
||||
status,
|
||||
effectiveClockInMode,
|
||||
sourceType,
|
||||
nfcTagUid,
|
||||
deviceId,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyMeters,
|
||||
distanceToClockPointMeters,
|
||||
withinGeofence,
|
||||
overrideReason,
|
||||
message,
|
||||
occurredAt,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0].id;
|
||||
}
|
||||
203
backend/command-api/src/services/clock-in-policy.js
Normal file
203
backend/command-api/src/services/clock-in-policy.js
Normal file
@@ -0,0 +1,203 @@
|
||||
export const CLOCK_IN_MODES = {
|
||||
NFC_REQUIRED: 'NFC_REQUIRED',
|
||||
GEO_REQUIRED: 'GEO_REQUIRED',
|
||||
EITHER: 'EITHER',
|
||||
};
|
||||
|
||||
function toRadians(value) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function resolveEffectiveClockInPolicy(record = {}) {
|
||||
return {
|
||||
mode: record.clock_in_mode
|
||||
|| record.shift_clock_in_mode
|
||||
|| record.default_clock_in_mode
|
||||
|| CLOCK_IN_MODES.EITHER,
|
||||
allowOverride: record.allow_clock_in_override
|
||||
?? record.shift_allow_clock_in_override
|
||||
?? record.default_allow_clock_in_override
|
||||
?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function validateNfc(expectedNfcTag, payload) {
|
||||
if (payload.sourceType !== 'NFC') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_REQUIRED',
|
||||
reason: 'Clock-in requires NFC',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload.nfcTagUid) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_REQUIRED',
|
||||
reason: 'NFC tag is required',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!expectedNfcTag) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_NOT_CONFIGURED',
|
||||
reason: 'Hub is not configured for NFC clock-in',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.nfcTagUid !== expectedNfcTag) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NFC_MISMATCH',
|
||||
reason: 'NFC tag mismatch',
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateGeo(expectedPoint, radius, payload) {
|
||||
if (payload.latitude == null || payload.longitude == null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LOCATION_REQUIRED',
|
||||
reason: 'Location coordinates are required',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
expectedPoint?.latitude == null
|
||||
|| expectedPoint?.longitude == null
|
||||
|| radius == null
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'GEOFENCE_NOT_CONFIGURED',
|
||||
reason: 'Clock-in geofence is not configured',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
const distance = distanceMeters({
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
}, expectedPoint);
|
||||
|
||||
if (distance == null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LOCATION_REQUIRED',
|
||||
reason: 'Location coordinates are required',
|
||||
overrideable: true,
|
||||
distance: null,
|
||||
withinGeofence: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (distance > radius) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'OUTSIDE_GEOFENCE',
|
||||
reason: `Outside geofence by ${distance - radius} meters`,
|
||||
overrideable: true,
|
||||
distance,
|
||||
withinGeofence: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
distance,
|
||||
withinGeofence: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateClockInAttempt(record, payload) {
|
||||
const policy = resolveEffectiveClockInPolicy(record);
|
||||
const expectedPoint = {
|
||||
latitude: record.expected_latitude,
|
||||
longitude: record.expected_longitude,
|
||||
};
|
||||
const radius = record.geofence_radius_meters;
|
||||
const expectedNfcTag = record.expected_nfc_tag_uid;
|
||||
|
||||
let proofResult;
|
||||
if (policy.mode === CLOCK_IN_MODES.NFC_REQUIRED) {
|
||||
proofResult = validateNfc(expectedNfcTag, payload);
|
||||
} else if (policy.mode === CLOCK_IN_MODES.GEO_REQUIRED) {
|
||||
proofResult = validateGeo(expectedPoint, radius, payload);
|
||||
} else {
|
||||
proofResult = payload.sourceType === 'NFC'
|
||||
? validateNfc(expectedNfcTag, payload)
|
||||
: validateGeo(expectedPoint, radius, payload);
|
||||
}
|
||||
|
||||
if (proofResult.ok) {
|
||||
return {
|
||||
effectiveClockInMode: policy.mode,
|
||||
allowOverride: policy.allowOverride,
|
||||
validationStatus: 'ACCEPTED',
|
||||
validationCode: null,
|
||||
validationReason: null,
|
||||
distance: proofResult.distance ?? null,
|
||||
withinGeofence: proofResult.withinGeofence ?? null,
|
||||
overrideUsed: false,
|
||||
overrideable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const rawOverrideReason = payload.overrideReason || payload.notes || null;
|
||||
const overrideReason = typeof rawOverrideReason === 'string' ? rawOverrideReason.trim() : '';
|
||||
const canOverride = policy.allowOverride
|
||||
&& proofResult.overrideable === true
|
||||
&& overrideReason.length > 0;
|
||||
|
||||
return {
|
||||
effectiveClockInMode: policy.mode,
|
||||
allowOverride: policy.allowOverride,
|
||||
validationStatus: canOverride ? 'FLAGGED' : 'REJECTED',
|
||||
validationCode: proofResult.code,
|
||||
validationReason: proofResult.reason,
|
||||
distance: proofResult.distance ?? null,
|
||||
withinGeofence: proofResult.withinGeofence ?? null,
|
||||
overrideUsed: canOverride,
|
||||
overrideReason: overrideReason || null,
|
||||
overrideable: proofResult.overrideable === true,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
38
backend/command-api/src/services/location-log-storage.js
Normal file
38
backend/command-api/src/services/location-log-storage.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Storage } from '@google-cloud/storage';
|
||||
|
||||
const storage = new Storage();
|
||||
|
||||
function resolvePrivateBucket() {
|
||||
return process.env.PRIVATE_BUCKET || null;
|
||||
}
|
||||
|
||||
export async function uploadLocationBatch({
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
batchId,
|
||||
payload,
|
||||
}) {
|
||||
const bucket = resolvePrivateBucket();
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectPath = [
|
||||
'location-streams',
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
`${batchId}.json`,
|
||||
].join('/');
|
||||
|
||||
await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), {
|
||||
resumable: false,
|
||||
contentType: 'application/json',
|
||||
metadata: {
|
||||
cacheControl: 'private, max-age=0',
|
||||
},
|
||||
});
|
||||
|
||||
return `gs://${bucket}/${objectPath}`;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
196
backend/command-api/src/services/notification-outbox.js
Normal file
196
backend/command-api/src/services/notification-outbox.js
Normal file
@@ -0,0 +1,196 @@
|
||||
export async function enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId = null,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
relatedIncidentId = null,
|
||||
audienceType = 'USER',
|
||||
recipientUserId = null,
|
||||
recipientStaffId = null,
|
||||
recipientBusinessMembershipId = null,
|
||||
channel = 'PUSH',
|
||||
notificationType,
|
||||
priority = 'NORMAL',
|
||||
dedupeKey = null,
|
||||
subject = null,
|
||||
body = null,
|
||||
payload = {},
|
||||
scheduledAt = null,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO notification_outbox (
|
||||
tenant_id,
|
||||
business_id,
|
||||
shift_id,
|
||||
assignment_id,
|
||||
related_incident_id,
|
||||
audience_type,
|
||||
recipient_user_id,
|
||||
recipient_staff_id,
|
||||
recipient_business_membership_id,
|
||||
channel,
|
||||
notification_type,
|
||||
priority,
|
||||
dedupe_key,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
scheduled_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, COALESCE($17::timestamptz, NOW())
|
||||
)
|
||||
ON CONFLICT (dedupe_key) DO NOTHING
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType,
|
||||
recipientUserId,
|
||||
recipientStaffId,
|
||||
recipientBusinessMembershipId,
|
||||
channel,
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
JSON.stringify(payload || {}),
|
||||
scheduledAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHubNotificationRecipients(client, { tenantId, businessId, hubId }) {
|
||||
const scoped = await client.query(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
hm.business_membership_id AS "businessMembershipId",
|
||||
bm.user_id AS "userId"
|
||||
FROM hub_managers hm
|
||||
JOIN business_memberships bm ON bm.id = hm.business_membership_id
|
||||
WHERE hm.tenant_id = $1
|
||||
AND hm.hub_id = $2
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
`,
|
||||
[tenantId, hubId]
|
||||
);
|
||||
|
||||
if (scoped.rowCount > 0) {
|
||||
return scoped.rows;
|
||||
}
|
||||
|
||||
const fallback = await client.query(
|
||||
`
|
||||
SELECT id AS "businessMembershipId", user_id AS "userId"
|
||||
FROM business_memberships
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND membership_status = 'ACTIVE'
|
||||
AND business_role IN ('owner', 'manager')
|
||||
`,
|
||||
[tenantId, businessId]
|
||||
);
|
||||
return fallback.rows;
|
||||
}
|
||||
|
||||
export async function enqueueHubManagerAlert(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
hubId = null,
|
||||
relatedIncidentId = null,
|
||||
notificationType,
|
||||
priority = 'HIGH',
|
||||
subject,
|
||||
body,
|
||||
payload = {},
|
||||
dedupeScope,
|
||||
}) {
|
||||
if (!hubId && !businessId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const recipients = await loadHubNotificationRecipients(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
hubId,
|
||||
});
|
||||
|
||||
let createdCount = 0;
|
||||
for (const recipient of recipients) {
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
notificationType,
|
||||
dedupeScope || shiftId || assignmentId || relatedIncidentId || hubId || businessId,
|
||||
recipient.userId || recipient.businessMembershipId,
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType: recipient.userId ? 'USER' : 'BUSINESS_MEMBERSHIP',
|
||||
recipientUserId: recipient.userId || null,
|
||||
recipientBusinessMembershipId: recipient.businessMembershipId || null,
|
||||
channel: 'PUSH',
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
});
|
||||
createdCount += 1;
|
||||
}
|
||||
|
||||
return createdCount;
|
||||
}
|
||||
|
||||
export async function enqueueUserAlert(client, {
|
||||
tenantId,
|
||||
businessId = null,
|
||||
shiftId = null,
|
||||
assignmentId = null,
|
||||
relatedIncidentId = null,
|
||||
recipientUserId,
|
||||
notificationType,
|
||||
priority = 'NORMAL',
|
||||
subject = null,
|
||||
body = null,
|
||||
payload = {},
|
||||
dedupeScope,
|
||||
}) {
|
||||
if (!recipientUserId) return;
|
||||
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
notificationType,
|
||||
dedupeScope || shiftId || assignmentId || relatedIncidentId || recipientUserId,
|
||||
recipientUserId,
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId,
|
||||
businessId,
|
||||
shiftId,
|
||||
assignmentId,
|
||||
relatedIncidentId,
|
||||
audienceType: 'USER',
|
||||
recipientUserId,
|
||||
channel: 'PUSH',
|
||||
notificationType,
|
||||
priority,
|
||||
dedupeKey,
|
||||
subject,
|
||||
body,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user