feat(attendance): add notification delivery and NFC security foundation

This commit is contained in:
zouantchaw
2026-03-16 17:06:17 +01:00
parent 5d8240ed51
commit 73287f42bd
21 changed files with 1734 additions and 36 deletions

View File

@@ -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,
};
});
}