feat(attendance): add notification delivery and NFC security foundation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { withTransaction } from './db.js';
|
||||
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
||||
import { recordAttendanceSecurityProof } from './attendance-security.js';
|
||||
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
||||
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
||||
|
||||
@@ -1091,32 +1092,40 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||
const validation = evaluateClockInAttempt(assignment, payload);
|
||||
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
||||
let securityProof = null;
|
||||
|
||||
if (validation.validationStatus === 'REJECTED') {
|
||||
const incidentType = validation.validationCode === 'NFC_MISMATCH'
|
||||
? 'NFC_MISMATCH'
|
||||
: 'CLOCK_IN_REJECTED';
|
||||
async function rejectAttendanceAttempt({
|
||||
errorCode,
|
||||
reason,
|
||||
incidentType = 'CLOCK_IN_REJECTED',
|
||||
severity = 'WARNING',
|
||||
effectiveClockInMode = null,
|
||||
distance = null,
|
||||
withinGeofence = null,
|
||||
metadata = {},
|
||||
details = {},
|
||||
}) {
|
||||
const incidentId = await recordGeofenceIncident(client, {
|
||||
assignment,
|
||||
actorUserId: actor.uid,
|
||||
incidentType,
|
||||
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
severity,
|
||||
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,
|
||||
distanceToClockPointMeters: distance,
|
||||
withinGeofence,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
message: reason,
|
||||
occurredAt: capturedAt,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
eventType,
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
const rejectedEvent = await client.query(
|
||||
@@ -1161,11 +1170,15 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
payload.latitude ?? null,
|
||||
payload.longitude ?? null,
|
||||
payload.accuracyMeters ?? null,
|
||||
validation.distance,
|
||||
validation.withinGeofence,
|
||||
validation.validationReason,
|
||||
distance,
|
||||
withinGeofence,
|
||||
reason,
|
||||
capturedAt,
|
||||
JSON.stringify(payload.rawPayload || {}),
|
||||
JSON.stringify({
|
||||
...(payload.rawPayload || {}),
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
securityObjectUri: securityProof?.objectUri || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1178,16 +1191,70 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
sourceType: payload.sourceType,
|
||||
validationReason: validation.validationReason,
|
||||
reason,
|
||||
incidentId,
|
||||
...details,
|
||||
},
|
||||
});
|
||||
|
||||
throw new AppError('ATTENDANCE_VALIDATION_FAILED', validation.validationReason, 409, {
|
||||
throw new AppError(errorCode, reason, 409, {
|
||||
assignmentId: payload.assignmentId,
|
||||
attendanceEventId: rejectedEvent.rows[0].id,
|
||||
distanceToClockPointMeters: validation.distance,
|
||||
distanceToClockPointMeters: distance,
|
||||
effectiveClockInMode,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
securityProof = await recordAttendanceSecurityProof(client, {
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
capturedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AppError) || error.code !== 'ATTENDANCE_SECURITY_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rejectAttendanceAttempt({
|
||||
errorCode: error.code,
|
||||
reason: error.message,
|
||||
incidentType: 'CLOCK_IN_REJECTED',
|
||||
severity: error.details?.securityCode?.startsWith('NFC') ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: assignment.clock_in_mode || assignment.default_clock_in_mode || null,
|
||||
metadata: {
|
||||
securityCode: error.details?.securityCode || null,
|
||||
},
|
||||
details: {
|
||||
securityCode: error.details?.securityCode || null,
|
||||
securityProofId: error.details?.proofId || null,
|
||||
securityObjectUri: error.details?.objectUri || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const validation = evaluateClockInAttempt(assignment, payload);
|
||||
|
||||
if (validation.validationStatus === 'REJECTED') {
|
||||
await rejectAttendanceAttempt({
|
||||
errorCode: 'ATTENDANCE_VALIDATION_FAILED',
|
||||
reason: validation.validationReason,
|
||||
incidentType: validation.validationCode === 'NFC_MISMATCH'
|
||||
? 'NFC_MISMATCH'
|
||||
: 'CLOCK_IN_REJECTED',
|
||||
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING',
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
distance: validation.distance,
|
||||
withinGeofence: validation.withinGeofence,
|
||||
metadata: {
|
||||
validationCode: validation.validationCode,
|
||||
},
|
||||
details: {
|
||||
validationCode: validation.validationCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1259,7 +1326,12 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
validation.validationStatus,
|
||||
validation.overrideUsed ? validation.overrideReason : validation.validationReason,
|
||||
capturedAt,
|
||||
JSON.stringify(payload.rawPayload || {}),
|
||||
JSON.stringify({
|
||||
...(payload.rawPayload || {}),
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
securityAttestationStatus: securityProof?.attestationStatus || null,
|
||||
securityObjectUri: securityProof?.objectUri || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1388,6 +1460,8 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
||||
validationStatus: eventResult.rows[0].validation_status,
|
||||
effectiveClockInMode: validation.effectiveClockInMode,
|
||||
overrideUsed: validation.overrideUsed,
|
||||
securityProofId: securityProof?.proofId || null,
|
||||
attestationStatus: securityProof?.attestationStatus || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user