feat(attendance): add notification delivery and NFC security foundation
This commit is contained in:
@@ -17,6 +17,8 @@ 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 pushProviderSchema = z.enum(['FCM', 'APNS', 'WEB_PUSH']);
|
||||
const pushPlatformSchema = z.enum(['IOS', 'ANDROID', 'WEB']);
|
||||
|
||||
const shiftPositionSchema = z.object({
|
||||
roleId: z.string().uuid().optional(),
|
||||
@@ -205,6 +207,11 @@ export const staffClockInSchema = z.object({
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
proofNonce: z.string().min(8).max(255).optional(),
|
||||
proofTimestamp: z.string().datetime().optional(),
|
||||
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
|
||||
attestationToken: z.string().min(16).max(20000).optional(),
|
||||
isMockLocation: z.boolean().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
@@ -224,6 +231,11 @@ export const staffClockOutSchema = z.object({
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
proofNonce: z.string().min(8).max(255).optional(),
|
||||
proofTimestamp: z.string().datetime().optional(),
|
||||
attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(),
|
||||
attestationToken: z.string().min(16).max(20000).optional(),
|
||||
isMockLocation: z.boolean().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
overrideReason: z.string().max(2000).optional(),
|
||||
breakMinutes: z.number().int().nonnegative().optional(),
|
||||
@@ -253,6 +265,27 @@ export const staffLocationBatchSchema = z.object({
|
||||
message: 'assignmentId or shiftId is required',
|
||||
});
|
||||
|
||||
export const pushTokenRegisterSchema = z.object({
|
||||
provider: pushProviderSchema.default('FCM'),
|
||||
platform: pushPlatformSchema,
|
||||
pushToken: z.string().min(16).max(4096),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
appVersion: z.string().max(80).optional(),
|
||||
appBuild: z.string().max(80).optional(),
|
||||
locale: z.string().max(32).optional(),
|
||||
timezone: z.string().max(64).optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const pushTokenDeleteSchema = z.object({
|
||||
tokenId: z.string().uuid().optional(),
|
||||
pushToken: z.string().min(16).max(4096).optional(),
|
||||
reason: z.string().max(255).optional(),
|
||||
}).refine((value) => value.tokenId || value.pushToken, {
|
||||
message: 'tokenId or pushToken is required',
|
||||
});
|
||||
|
||||
export const staffProfileSetupSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
bio: z.string().max(5000).optional(),
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
@@ -62,6 +66,8 @@ import {
|
||||
preferredLocationsUpdateSchema,
|
||||
privacyUpdateSchema,
|
||||
profileExperienceSchema,
|
||||
pushTokenDeleteSchema,
|
||||
pushTokenRegisterSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
@@ -91,6 +97,8 @@ const defaultHandlers = {
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
registerClientPushToken,
|
||||
registerStaffPushToken,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
@@ -98,6 +106,8 @@ const defaultHandlers = {
|
||||
staffClockOut,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
unregisterStaffPushToken,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
@@ -285,6 +295,26 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
handler: handlers.setupStaffProfile,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/devices/push-tokens', {
|
||||
schema: pushTokenRegisterSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.registerClientPushToken,
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/client/devices/push-tokens', {
|
||||
schema: pushTokenDeleteSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.unregisterClientPushToken,
|
||||
paramShape: (req) => ({
|
||||
...req.body,
|
||||
tokenId: req.body?.tokenId || req.query.tokenId,
|
||||
pushToken: req.body?.pushToken || req.query.pushToken,
|
||||
reason: req.body?.reason || req.query.reason,
|
||||
}),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/clock-in', {
|
||||
schema: staffClockInSchema,
|
||||
policyAction: 'attendance.clock-in',
|
||||
@@ -306,6 +336,26 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
handler: handlers.submitLocationStreamBatch,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/devices/push-tokens', {
|
||||
schema: pushTokenRegisterSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.registerStaffPushToken,
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/staff/devices/push-tokens', {
|
||||
schema: pushTokenDeleteSchema,
|
||||
policyAction: 'notifications.device.write',
|
||||
resource: 'device_push_token',
|
||||
handler: handlers.unregisterStaffPushToken,
|
||||
paramShape: (req) => ({
|
||||
...req.body,
|
||||
tokenId: req.body?.tokenId || req.query.tokenId,
|
||||
pushToken: req.body?.pushToken || req.query.pushToken,
|
||||
reason: req.body?.reason || req.query.reason,
|
||||
}),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/availability', {
|
||||
schema: availabilityDayUpdateSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
|
||||
@@ -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 uploadAttendanceSecurityLog({
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
proofId,
|
||||
payload,
|
||||
}) {
|
||||
const bucket = resolvePrivateBucket();
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectPath = [
|
||||
'attendance-security',
|
||||
tenantId,
|
||||
staffId,
|
||||
assignmentId,
|
||||
`${proofId}.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}`;
|
||||
}
|
||||
285
backend/command-api/src/services/attendance-security.js
Normal file
285
backend/command-api/src/services/attendance-security.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { uploadAttendanceSecurityLog } from './attendance-security-log-storage.js';
|
||||
|
||||
function parseBooleanEnv(name, fallback = false) {
|
||||
const value = process.env[name];
|
||||
if (value == null) return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function parseIntEnv(name, fallback) {
|
||||
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
if (!value) return null;
|
||||
return crypto.createHash('sha256').update(`${value}`).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function buildRequestFingerprint({ assignmentId, actorUserId, eventType, sourceType, deviceId, nfcTagUid, capturedAt }) {
|
||||
const fingerprintSource = [assignmentId, actorUserId, eventType, sourceType, deviceId || '', nfcTagUid || '', capturedAt || ''].join('|');
|
||||
return hashValue(fingerprintSource);
|
||||
}
|
||||
|
||||
async function persistProofRecord(client, {
|
||||
proofId,
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
metadata,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO attendance_security_proofs (
|
||||
id,
|
||||
tenant_id,
|
||||
assignment_id,
|
||||
shift_id,
|
||||
staff_id,
|
||||
actor_user_id,
|
||||
event_type,
|
||||
source_type,
|
||||
device_id,
|
||||
nfc_tag_uid,
|
||||
proof_nonce,
|
||||
proof_timestamp,
|
||||
request_fingerprint,
|
||||
attestation_provider,
|
||||
attestation_token_hash,
|
||||
attestation_status,
|
||||
attestation_reason,
|
||||
object_uri,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13, $14, $15, $16, $17, $18, $19::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
proofId,
|
||||
assignment.tenant_id,
|
||||
assignment.id,
|
||||
assignment.shift_id,
|
||||
assignment.staff_id,
|
||||
actor.uid,
|
||||
eventType,
|
||||
payload.sourceType,
|
||||
payload.deviceId || null,
|
||||
payload.nfcTagUid || null,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseMetadata({ payload, capturedAt, securityCode = null, securityReason = null }) {
|
||||
return {
|
||||
capturedAt,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
rawPayload: payload.rawPayload || {},
|
||||
securityCode,
|
||||
securityReason,
|
||||
notes: payload.notes || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordAttendanceSecurityProof(client, {
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
capturedAt,
|
||||
}) {
|
||||
const proofId = crypto.randomUUID();
|
||||
const proofNonce = payload.proofNonce || null;
|
||||
const proofTimestamp = normalizeTimestamp(payload.proofTimestamp || payload.capturedAt || capturedAt);
|
||||
const requestFingerprint = buildRequestFingerprint({
|
||||
assignmentId: assignment.id,
|
||||
actorUserId: actor.uid,
|
||||
eventType,
|
||||
sourceType: payload.sourceType,
|
||||
deviceId: payload.deviceId,
|
||||
nfcTagUid: payload.nfcTagUid,
|
||||
capturedAt,
|
||||
});
|
||||
const attestationProvider = payload.attestationProvider || null;
|
||||
const attestationTokenHash = hashValue(payload.attestationToken || null);
|
||||
const requiresNonce = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_PROOF_NONCE', false);
|
||||
const requiresDeviceId = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_DEVICE_ID', false);
|
||||
const requiresAttestation = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_ATTESTATION', false);
|
||||
const maxAgeSeconds = parseIntEnv('NFC_PROOF_MAX_AGE_SECONDS', 120);
|
||||
const baseMetadata = buildBaseMetadata({ payload, capturedAt });
|
||||
|
||||
let securityCode = null;
|
||||
let securityReason = null;
|
||||
let attestationStatus = payload.sourceType === 'NFC' ? 'NOT_PROVIDED' : 'BYPASSED';
|
||||
let attestationReason = null;
|
||||
|
||||
if (requiresDeviceId && !payload.deviceId) {
|
||||
securityCode = 'DEVICE_ID_REQUIRED';
|
||||
securityReason = 'NFC proof must include a deviceId';
|
||||
} else if (requiresNonce && !proofNonce) {
|
||||
securityCode = 'NFC_PROOF_NONCE_REQUIRED';
|
||||
securityReason = 'NFC proof must include a proofNonce';
|
||||
} else if (proofTimestamp) {
|
||||
const skewSeconds = Math.abs(new Date(capturedAt).getTime() - new Date(proofTimestamp).getTime()) / 1000;
|
||||
if (skewSeconds > maxAgeSeconds) {
|
||||
securityCode = 'NFC_PROOF_TIMESTAMP_EXPIRED';
|
||||
securityReason = `NFC proof timestamp exceeded the ${maxAgeSeconds}-second window`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!securityCode && proofNonce) {
|
||||
const replayCheck = await client.query(
|
||||
`
|
||||
SELECT id
|
||||
FROM attendance_security_proofs
|
||||
WHERE tenant_id = $1
|
||||
AND proof_nonce = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[assignment.tenant_id, proofNonce]
|
||||
);
|
||||
if (replayCheck.rowCount > 0) {
|
||||
securityCode = 'NFC_REPLAY_DETECTED';
|
||||
securityReason = 'This NFC proof nonce was already used';
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.sourceType === 'NFC') {
|
||||
if (attestationProvider || payload.attestationToken) {
|
||||
if (!attestationProvider || !payload.attestationToken) {
|
||||
securityCode = securityCode || 'ATTESTATION_PAYLOAD_INVALID';
|
||||
securityReason = securityReason || 'attestationProvider and attestationToken must be provided together';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Incomplete attestation payload';
|
||||
} else {
|
||||
attestationStatus = 'RECORDED_UNVERIFIED';
|
||||
attestationReason = 'Attestation payload recorded; server-side verifier not yet enabled';
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresAttestation && attestationStatus !== 'RECORDED_UNVERIFIED' && attestationStatus !== 'VERIFIED') {
|
||||
securityCode = securityCode || 'ATTESTATION_REQUIRED';
|
||||
securityReason = securityReason || 'NFC proof requires device attestation';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Device attestation is required for NFC proof';
|
||||
}
|
||||
|
||||
if (requiresAttestation && attestationStatus === 'RECORDED_UNVERIFIED') {
|
||||
securityCode = securityCode || 'ATTESTATION_NOT_VERIFIED';
|
||||
securityReason = securityReason || 'NFC proof attestation cannot be trusted until verifier is enabled';
|
||||
attestationStatus = 'REJECTED';
|
||||
attestationReason = 'Recorded attestation is not yet verified';
|
||||
}
|
||||
}
|
||||
|
||||
const objectUri = await uploadAttendanceSecurityLog({
|
||||
tenantId: assignment.tenant_id,
|
||||
staffId: assignment.staff_id,
|
||||
assignmentId: assignment.id,
|
||||
proofId,
|
||||
payload: {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
staffId: assignment.staff_id,
|
||||
actorUserId: actor.uid,
|
||||
eventType,
|
||||
sourceType: payload.sourceType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
deviceId: payload.deviceId || null,
|
||||
nfcTagUid: payload.nfcTagUid || null,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
capturedAt,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
securityCode,
|
||||
securityReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await persistProofRecord(client, {
|
||||
proofId,
|
||||
assignment,
|
||||
actor,
|
||||
payload,
|
||||
eventType,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
requestFingerprint,
|
||||
attestationProvider,
|
||||
attestationTokenHash,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
securityCode,
|
||||
securityReason,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === '23505' && proofNonce) {
|
||||
throw new AppError('ATTENDANCE_SECURITY_FAILED', 'This NFC proof nonce was already used', 409, {
|
||||
assignmentId: assignment.id,
|
||||
proofNonce,
|
||||
securityCode: 'NFC_REPLAY_DETECTED',
|
||||
objectUri,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (securityCode) {
|
||||
throw new AppError('ATTENDANCE_SECURITY_FAILED', securityReason, 409, {
|
||||
assignmentId: assignment.id,
|
||||
proofId,
|
||||
proofNonce,
|
||||
securityCode,
|
||||
objectUri,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
proofId,
|
||||
proofNonce,
|
||||
proofTimestamp,
|
||||
attestationStatus,
|
||||
attestationReason,
|
||||
objectUri,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
19
backend/command-api/src/services/firebase-admin.js
Normal file
19
backend/command-api/src/services/firebase-admin.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
|
||||
export function ensureFirebaseAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export function getFirebaseAdminAuth() {
|
||||
ensureFirebaseAdminApp();
|
||||
return getAuth();
|
||||
}
|
||||
|
||||
export function getFirebaseAdminMessaging() {
|
||||
ensureFirebaseAdminApp();
|
||||
return getMessaging();
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
import { getFirebaseAdminAuth } from './firebase-admin.js';
|
||||
|
||||
export async function verifyFirebaseToken(token) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token);
|
||||
return getFirebaseAdminAuth().verifyIdToken(token);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
||||
import {
|
||||
cancelOrder as cancelOrderCommand,
|
||||
clockIn as clockInCommand,
|
||||
@@ -1614,8 +1615,13 @@ export async function staffClockIn(actor, payload) {
|
||||
accuracyMeters: payload.accuracyMeters,
|
||||
capturedAt: payload.capturedAt,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
proofNonce: payload.proofNonce || null,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
attestationProvider: payload.attestationProvider || null,
|
||||
attestationToken: payload.attestationToken || null,
|
||||
rawPayload: {
|
||||
notes: payload.notes || null,
|
||||
isMockLocation: payload.isMockLocation ?? null,
|
||||
...(payload.rawPayload || {}),
|
||||
},
|
||||
});
|
||||
@@ -1640,15 +1646,116 @@ export async function staffClockOut(actor, payload) {
|
||||
accuracyMeters: payload.accuracyMeters,
|
||||
capturedAt: payload.capturedAt,
|
||||
overrideReason: payload.overrideReason || null,
|
||||
proofNonce: payload.proofNonce || null,
|
||||
proofTimestamp: payload.proofTimestamp || null,
|
||||
attestationProvider: payload.attestationProvider || null,
|
||||
attestationToken: payload.attestationToken || null,
|
||||
rawPayload: {
|
||||
notes: payload.notes || null,
|
||||
breakMinutes: payload.breakMinutes ?? null,
|
||||
applicationId: payload.applicationId || null,
|
||||
isMockLocation: payload.isMockLocation ?? null,
|
||||
...(payload.rawPayload || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerClientPushToken(actor, payload) {
|
||||
const context = await requireClientContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const token = await registerPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
businessMembershipId: context.business.membershipId,
|
||||
provider: payload.provider,
|
||||
platform: payload.platform,
|
||||
pushToken: payload.pushToken,
|
||||
deviceId: payload.deviceId || null,
|
||||
appVersion: payload.appVersion || null,
|
||||
appBuild: payload.appBuild || null,
|
||||
locale: payload.locale || null,
|
||||
timezone: payload.timezone || null,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
metadata: payload.metadata || {},
|
||||
});
|
||||
|
||||
return {
|
||||
tokenId: token.id,
|
||||
provider: token.provider,
|
||||
platform: token.platform,
|
||||
notificationsEnabled: token.notificationsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterClientPushToken(actor, payload) {
|
||||
const context = await requireClientContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const removed = await unregisterPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
tokenId: payload.tokenId || null,
|
||||
pushToken: payload.pushToken || null,
|
||||
reason: payload.reason || 'CLIENT_SIGN_OUT',
|
||||
});
|
||||
|
||||
return {
|
||||
removedCount: removed.length,
|
||||
removed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerStaffPushToken(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const token = await registerPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
staffId: context.staff.staffId,
|
||||
provider: payload.provider,
|
||||
platform: payload.platform,
|
||||
pushToken: payload.pushToken,
|
||||
deviceId: payload.deviceId || null,
|
||||
appVersion: payload.appVersion || null,
|
||||
appBuild: payload.appBuild || null,
|
||||
locale: payload.locale || null,
|
||||
timezone: payload.timezone || null,
|
||||
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||
metadata: payload.metadata || {},
|
||||
});
|
||||
|
||||
return {
|
||||
tokenId: token.id,
|
||||
provider: token.provider,
|
||||
platform: token.platform,
|
||||
notificationsEnabled: token.notificationsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterStaffPushToken(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const removed = await unregisterPushToken(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
userId: actor.uid,
|
||||
tokenId: payload.tokenId || null,
|
||||
pushToken: payload.pushToken || null,
|
||||
reason: payload.reason || 'STAFF_SIGN_OUT',
|
||||
});
|
||||
|
||||
return {
|
||||
removedCount: removed.length,
|
||||
removed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeLocationPoints(points, assignment) {
|
||||
let outOfGeofenceCount = 0;
|
||||
let missingCoordinateCount = 0;
|
||||
|
||||
220
backend/command-api/src/services/notification-device-tokens.js
Normal file
220
backend/command-api/src/services/notification-device-tokens.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const PUSH_PROVIDERS = {
|
||||
FCM: 'FCM',
|
||||
APNS: 'APNS',
|
||||
WEB_PUSH: 'WEB_PUSH',
|
||||
};
|
||||
|
||||
export const PUSH_PLATFORMS = {
|
||||
IOS: 'IOS',
|
||||
ANDROID: 'ANDROID',
|
||||
WEB: 'WEB',
|
||||
};
|
||||
|
||||
export function hashPushToken(pushToken) {
|
||||
return crypto.createHash('sha256').update(`${pushToken || ''}`).digest('hex');
|
||||
}
|
||||
|
||||
export async function registerPushToken(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
staffId = null,
|
||||
businessMembershipId = null,
|
||||
vendorMembershipId = null,
|
||||
provider = PUSH_PROVIDERS.FCM,
|
||||
platform,
|
||||
pushToken,
|
||||
deviceId = null,
|
||||
appVersion = null,
|
||||
appBuild = null,
|
||||
locale = null,
|
||||
timezone = null,
|
||||
notificationsEnabled = true,
|
||||
metadata = {},
|
||||
}) {
|
||||
const tokenHash = hashPushToken(pushToken);
|
||||
const result = await client.query(
|
||||
`
|
||||
INSERT INTO device_push_tokens (
|
||||
tenant_id,
|
||||
user_id,
|
||||
staff_id,
|
||||
business_membership_id,
|
||||
vendor_membership_id,
|
||||
provider,
|
||||
platform,
|
||||
push_token,
|
||||
token_hash,
|
||||
device_id,
|
||||
app_version,
|
||||
app_build,
|
||||
locale,
|
||||
timezone,
|
||||
notifications_enabled,
|
||||
invalidated_at,
|
||||
invalidation_reason,
|
||||
last_registered_at,
|
||||
last_seen_at,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, NULL, NOW(), NOW(), $16::jsonb
|
||||
)
|
||||
ON CONFLICT (provider, token_hash) DO UPDATE
|
||||
SET tenant_id = EXCLUDED.tenant_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
staff_id = EXCLUDED.staff_id,
|
||||
business_membership_id = EXCLUDED.business_membership_id,
|
||||
vendor_membership_id = EXCLUDED.vendor_membership_id,
|
||||
platform = EXCLUDED.platform,
|
||||
push_token = EXCLUDED.push_token,
|
||||
device_id = EXCLUDED.device_id,
|
||||
app_version = EXCLUDED.app_version,
|
||||
app_build = EXCLUDED.app_build,
|
||||
locale = EXCLUDED.locale,
|
||||
timezone = EXCLUDED.timezone,
|
||||
notifications_enabled = EXCLUDED.notifications_enabled,
|
||||
invalidated_at = NULL,
|
||||
invalidation_reason = NULL,
|
||||
last_registered_at = NOW(),
|
||||
last_seen_at = NOW(),
|
||||
metadata = COALESCE(device_push_tokens.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
RETURNING id,
|
||||
tenant_id AS "tenantId",
|
||||
user_id AS "userId",
|
||||
staff_id AS "staffId",
|
||||
business_membership_id AS "businessMembershipId",
|
||||
vendor_membership_id AS "vendorMembershipId",
|
||||
provider,
|
||||
platform,
|
||||
device_id AS "deviceId",
|
||||
notifications_enabled AS "notificationsEnabled"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
staffId,
|
||||
businessMembershipId,
|
||||
vendorMembershipId,
|
||||
provider,
|
||||
platform,
|
||||
pushToken,
|
||||
tokenHash,
|
||||
deviceId,
|
||||
appVersion,
|
||||
appBuild,
|
||||
locale,
|
||||
timezone,
|
||||
notificationsEnabled,
|
||||
JSON.stringify(metadata || {}),
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function unregisterPushToken(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
tokenId = null,
|
||||
pushToken = null,
|
||||
reason = 'USER_REQUESTED',
|
||||
}) {
|
||||
const tokenHash = pushToken ? hashPushToken(pushToken) : null;
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET notifications_enabled = FALSE,
|
||||
invalidated_at = NOW(),
|
||||
invalidation_reason = $4,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND user_id = $2
|
||||
AND (
|
||||
($3::uuid IS NOT NULL AND id = $3::uuid)
|
||||
OR
|
||||
($5::text IS NOT NULL AND token_hash = $5::text)
|
||||
)
|
||||
RETURNING id,
|
||||
provider,
|
||||
platform,
|
||||
device_id AS "deviceId"
|
||||
`,
|
||||
[tenantId, userId, tokenId, reason, tokenHash]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function resolveNotificationTargetTokens(client, notification) {
|
||||
const result = await client.query(
|
||||
`
|
||||
WITH recipient_users AS (
|
||||
SELECT $2::text AS user_id
|
||||
WHERE $2::text IS NOT NULL
|
||||
UNION
|
||||
SELECT bm.user_id
|
||||
FROM business_memberships bm
|
||||
WHERE $3::uuid IS NOT NULL
|
||||
AND bm.id = $3::uuid
|
||||
UNION
|
||||
SELECT s.user_id
|
||||
FROM staffs s
|
||||
WHERE $4::uuid IS NOT NULL
|
||||
AND s.id = $4::uuid
|
||||
)
|
||||
SELECT
|
||||
dpt.id,
|
||||
dpt.user_id AS "userId",
|
||||
dpt.staff_id AS "staffId",
|
||||
dpt.provider,
|
||||
dpt.platform,
|
||||
dpt.push_token AS "pushToken",
|
||||
dpt.device_id AS "deviceId",
|
||||
dpt.metadata
|
||||
FROM device_push_tokens dpt
|
||||
JOIN recipient_users ru ON ru.user_id = dpt.user_id
|
||||
WHERE dpt.tenant_id = $1
|
||||
AND dpt.notifications_enabled = TRUE
|
||||
AND dpt.invalidated_at IS NULL
|
||||
ORDER BY dpt.last_seen_at DESC, dpt.created_at DESC
|
||||
`,
|
||||
[
|
||||
notification.tenant_id,
|
||||
notification.recipient_user_id,
|
||||
notification.recipient_business_membership_id,
|
||||
notification.recipient_staff_id,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function markPushTokenInvalid(client, tokenId, reason) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET notifications_enabled = FALSE,
|
||||
invalidated_at = NOW(),
|
||||
invalidation_reason = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[tokenId, reason]
|
||||
);
|
||||
}
|
||||
|
||||
export async function touchPushTokenDelivery(client, tokenId) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE device_push_tokens
|
||||
SET last_delivery_at = NOW(),
|
||||
last_seen_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[tokenId]
|
||||
);
|
||||
}
|
||||
348
backend/command-api/src/services/notification-dispatcher.js
Normal file
348
backend/command-api/src/services/notification-dispatcher.js
Normal file
@@ -0,0 +1,348 @@
|
||||
import { query, withTransaction } from './db.js';
|
||||
import { enqueueNotification } from './notification-outbox.js';
|
||||
import {
|
||||
markPushTokenInvalid,
|
||||
resolveNotificationTargetTokens,
|
||||
touchPushTokenDelivery,
|
||||
} from './notification-device-tokens.js';
|
||||
import { createPushSender } from './notification-fcm.js';
|
||||
|
||||
function parseIntEnv(name, fallback) {
|
||||
const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseBooleanEnv(name, fallback = false) {
|
||||
const value = process.env[name];
|
||||
if (value == null) return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function parseListEnv(name, fallback = []) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
return raw.split(',').map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value) && value >= 0);
|
||||
}
|
||||
|
||||
export function computeRetryDelayMinutes(attemptNumber) {
|
||||
return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60);
|
||||
}
|
||||
|
||||
async function recordDeliveryAttempt(client, {
|
||||
notificationId,
|
||||
devicePushTokenId = null,
|
||||
provider,
|
||||
deliveryStatus,
|
||||
providerMessageId = null,
|
||||
attemptNumber,
|
||||
errorCode = null,
|
||||
errorMessage = null,
|
||||
responsePayload = {},
|
||||
sentAt = null,
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO notification_deliveries (
|
||||
notification_outbox_id,
|
||||
device_push_token_id,
|
||||
provider,
|
||||
delivery_status,
|
||||
provider_message_id,
|
||||
attempt_number,
|
||||
error_code,
|
||||
error_message,
|
||||
response_payload,
|
||||
sent_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::timestamptz)
|
||||
`,
|
||||
[
|
||||
notificationId,
|
||||
devicePushTokenId,
|
||||
provider,
|
||||
deliveryStatus,
|
||||
providerMessageId,
|
||||
attemptNumber,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
JSON.stringify(responsePayload || {}),
|
||||
sentAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function claimDueNotifications(limit) {
|
||||
return withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
WITH due AS (
|
||||
SELECT id
|
||||
FROM notification_outbox
|
||||
WHERE (
|
||||
status = 'PENDING'
|
||||
OR (
|
||||
status = 'PROCESSING'
|
||||
AND updated_at <= NOW() - INTERVAL '10 minutes'
|
||||
)
|
||||
)
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'CRITICAL' THEN 1
|
||||
WHEN 'HIGH' THEN 2
|
||||
WHEN 'NORMAL' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
scheduled_at ASC,
|
||||
created_at ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE notification_outbox n
|
||||
SET status = 'PROCESSING',
|
||||
attempts = n.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM due
|
||||
WHERE n.id = due.id
|
||||
RETURNING n.*
|
||||
`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function markNotificationSent(notificationId) {
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'SENT',
|
||||
sent_at = NOW(),
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId]
|
||||
);
|
||||
}
|
||||
|
||||
async function markNotificationFailed(notificationId, lastError) {
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'FAILED',
|
||||
last_error = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId, lastError]
|
||||
);
|
||||
}
|
||||
|
||||
async function requeueNotification(notificationId, attemptNumber, lastError) {
|
||||
const delayMinutes = computeRetryDelayMinutes(attemptNumber);
|
||||
await query(
|
||||
`
|
||||
UPDATE notification_outbox
|
||||
SET status = 'PENDING',
|
||||
last_error = $2,
|
||||
scheduled_at = NOW() + (($3::text || ' minutes')::interval),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[notificationId, lastError, String(delayMinutes)]
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueueDueShiftReminders() {
|
||||
const enabled = parseBooleanEnv('SHIFT_REMINDERS_ENABLED', true);
|
||||
if (!enabled) {
|
||||
return { enqueued: 0 };
|
||||
}
|
||||
|
||||
const leadMinutesList = parseListEnv('SHIFT_REMINDER_LEAD_MINUTES', [60, 15]);
|
||||
const reminderWindowMinutes = parseIntEnv('SHIFT_REMINDER_WINDOW_MINUTES', 5);
|
||||
let enqueued = 0;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const leadMinutes of leadMinutesList) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT
|
||||
a.id,
|
||||
a.tenant_id,
|
||||
a.business_id,
|
||||
a.shift_id,
|
||||
a.staff_id,
|
||||
s.title AS shift_title,
|
||||
s.starts_at,
|
||||
cp.label AS hub_label,
|
||||
st.user_id
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN staffs st ON st.id = a.staff_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.status IN ('ASSIGNED', 'ACCEPTED')
|
||||
AND st.user_id IS NOT NULL
|
||||
AND s.starts_at >= NOW() + (($1::int - $2::int) * INTERVAL '1 minute')
|
||||
AND s.starts_at < NOW() + (($1::int + $2::int) * INTERVAL '1 minute')
|
||||
`,
|
||||
[leadMinutes, reminderWindowMinutes]
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const dedupeKey = [
|
||||
'notify',
|
||||
'SHIFT_START_REMINDER',
|
||||
row.id,
|
||||
leadMinutes,
|
||||
].join(':');
|
||||
|
||||
await enqueueNotification(client, {
|
||||
tenantId: row.tenant_id,
|
||||
businessId: row.business_id,
|
||||
shiftId: row.shift_id,
|
||||
assignmentId: row.id,
|
||||
audienceType: 'USER',
|
||||
recipientUserId: row.user_id,
|
||||
channel: 'PUSH',
|
||||
notificationType: 'SHIFT_START_REMINDER',
|
||||
priority: leadMinutes <= 15 ? 'HIGH' : 'NORMAL',
|
||||
dedupeKey,
|
||||
subject: leadMinutes <= 15 ? 'Shift starting soon' : 'Upcoming shift reminder',
|
||||
body: `${row.shift_title || 'Your shift'} at ${row.hub_label || 'the assigned hub'} starts in ${leadMinutes} minutes`,
|
||||
payload: {
|
||||
assignmentId: row.id,
|
||||
shiftId: row.shift_id,
|
||||
leadMinutes,
|
||||
startsAt: row.starts_at,
|
||||
},
|
||||
});
|
||||
enqueued += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { enqueued };
|
||||
}
|
||||
|
||||
async function settleNotification(notification, deliveryResults, maxAttempts) {
|
||||
const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length;
|
||||
const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length;
|
||||
const transientCount = deliveryResults.filter((result) => result.transient).length;
|
||||
const invalidCount = deliveryResults.filter((result) => result.deliveryStatus === 'INVALID_TOKEN').length;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const result of deliveryResults) {
|
||||
await recordDeliveryAttempt(client, {
|
||||
notificationId: notification.id,
|
||||
devicePushTokenId: result.tokenId,
|
||||
provider: result.provider || 'FCM',
|
||||
deliveryStatus: result.deliveryStatus,
|
||||
providerMessageId: result.providerMessageId || null,
|
||||
attemptNumber: notification.attempts,
|
||||
errorCode: result.errorCode || null,
|
||||
errorMessage: result.errorMessage || null,
|
||||
responsePayload: result.responsePayload || {},
|
||||
sentAt: result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED'
|
||||
? new Date().toISOString()
|
||||
: null,
|
||||
});
|
||||
|
||||
if (result.deliveryStatus === 'INVALID_TOKEN' && result.tokenId) {
|
||||
await markPushTokenInvalid(client, result.tokenId, result.errorCode || 'INVALID_TOKEN');
|
||||
}
|
||||
|
||||
if ((result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED') && result.tokenId) {
|
||||
await touchPushTokenDelivery(client, result.tokenId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0 || simulatedCount > 0) {
|
||||
await markNotificationSent(notification.id);
|
||||
return {
|
||||
status: 'SENT',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (transientCount > 0 && notification.attempts < maxAttempts) {
|
||||
const errorSummary = deliveryResults
|
||||
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
await requeueNotification(notification.id, notification.attempts, errorSummary || 'Transient delivery failure');
|
||||
return {
|
||||
status: 'REQUEUED',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
const failureSummary = deliveryResults
|
||||
.map((result) => result.errorCode || result.errorMessage || result.deliveryStatus)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
await markNotificationFailed(notification.id, failureSummary || 'Push delivery failed');
|
||||
return {
|
||||
status: 'FAILED',
|
||||
successCount,
|
||||
simulatedCount,
|
||||
invalidCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchPendingNotifications({
|
||||
limit = parseIntEnv('NOTIFICATION_BATCH_LIMIT', 50),
|
||||
sender = createPushSender(),
|
||||
} = {}) {
|
||||
const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5);
|
||||
const reminderSummary = await enqueueDueShiftReminders();
|
||||
const claimed = await claimDueNotifications(limit);
|
||||
|
||||
const summary = {
|
||||
remindersEnqueued: reminderSummary.enqueued,
|
||||
claimed: claimed.length,
|
||||
sent: 0,
|
||||
requeued: 0,
|
||||
failed: 0,
|
||||
simulated: 0,
|
||||
invalidTokens: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const notification of claimed) {
|
||||
const tokens = await resolveNotificationTargetTokens({ query }, notification);
|
||||
if (tokens.length === 0) {
|
||||
await withTransaction(async (client) => {
|
||||
await recordDeliveryAttempt(client, {
|
||||
notificationId: notification.id,
|
||||
provider: 'FCM',
|
||||
deliveryStatus: 'SKIPPED',
|
||||
attemptNumber: notification.attempts,
|
||||
errorCode: 'NO_ACTIVE_PUSH_TOKENS',
|
||||
errorMessage: 'No active push tokens registered for notification recipient',
|
||||
responsePayload: { recipient: notification.recipient_user_id || notification.recipient_staff_id || notification.recipient_business_membership_id || null },
|
||||
});
|
||||
});
|
||||
await markNotificationFailed(notification.id, 'No active push tokens registered for notification recipient');
|
||||
summary.failed += 1;
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deliveryResults = await sender.send(notification, tokens);
|
||||
const outcome = await settleNotification(notification, deliveryResults, maxAttempts);
|
||||
if (outcome.status === 'SENT') summary.sent += 1;
|
||||
if (outcome.status === 'REQUEUED') summary.requeued += 1;
|
||||
if (outcome.status === 'FAILED') summary.failed += 1;
|
||||
summary.simulated += outcome.simulatedCount || 0;
|
||||
summary.invalidTokens += outcome.invalidCount || 0;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
116
backend/command-api/src/services/notification-fcm.js
Normal file
116
backend/command-api/src/services/notification-fcm.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getFirebaseAdminMessaging } from './firebase-admin.js';
|
||||
|
||||
const INVALID_TOKEN_ERROR_CODES = new Set([
|
||||
'messaging/invalid-registration-token',
|
||||
'messaging/registration-token-not-registered',
|
||||
]);
|
||||
|
||||
const TRANSIENT_ERROR_CODES = new Set([
|
||||
'messaging/internal-error',
|
||||
'messaging/server-unavailable',
|
||||
'messaging/unknown-error',
|
||||
'app/network-error',
|
||||
]);
|
||||
|
||||
function mapPriority(priority) {
|
||||
return priority === 'CRITICAL' || priority === 'HIGH' ? 'high' : 'normal';
|
||||
}
|
||||
|
||||
function buildDataPayload(notification) {
|
||||
return {
|
||||
notificationId: notification.id,
|
||||
notificationType: notification.notification_type,
|
||||
priority: notification.priority,
|
||||
tenantId: notification.tenant_id,
|
||||
businessId: notification.business_id || '',
|
||||
shiftId: notification.shift_id || '',
|
||||
assignmentId: notification.assignment_id || '',
|
||||
payload: JSON.stringify(notification.payload || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyMessagingError(errorCode) {
|
||||
if (!errorCode) return 'FAILED';
|
||||
if (INVALID_TOKEN_ERROR_CODES.has(errorCode)) return 'INVALID_TOKEN';
|
||||
if (TRANSIENT_ERROR_CODES.has(errorCode)) return 'RETRYABLE';
|
||||
return 'FAILED';
|
||||
}
|
||||
|
||||
export function createPushSender({ deliveryMode = process.env.PUSH_DELIVERY_MODE || 'live' } = {}) {
|
||||
return {
|
||||
async send(notification, tokens) {
|
||||
if (tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (deliveryMode === 'log-only') {
|
||||
return tokens.map((token) => ({
|
||||
tokenId: token.id,
|
||||
deliveryStatus: 'SIMULATED',
|
||||
provider: token.provider,
|
||||
providerMessageId: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
},
|
||||
transient: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const messages = tokens.map((token) => ({
|
||||
token: token.pushToken,
|
||||
notification: {
|
||||
title: notification.subject || 'Krow update',
|
||||
body: notification.body || '',
|
||||
},
|
||||
data: buildDataPayload(notification),
|
||||
android: {
|
||||
priority: mapPriority(notification.priority),
|
||||
},
|
||||
apns: {
|
||||
headers: {
|
||||
'apns-priority': mapPriority(notification.priority) === 'high' ? '10' : '5',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const dryRun = deliveryMode === 'dry-run';
|
||||
const response = await getFirebaseAdminMessaging().sendEach(messages, dryRun);
|
||||
return response.responses.map((item, index) => {
|
||||
const token = tokens[index];
|
||||
if (item.success) {
|
||||
return {
|
||||
tokenId: token.id,
|
||||
deliveryStatus: dryRun ? 'SIMULATED' : 'SENT',
|
||||
provider: token.provider,
|
||||
providerMessageId: item.messageId || null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
messageId: item.messageId || null,
|
||||
},
|
||||
transient: false,
|
||||
};
|
||||
}
|
||||
|
||||
const errorCode = item.error?.code || 'messaging/unknown-error';
|
||||
const errorMessage = item.error?.message || 'Push delivery failed';
|
||||
const classification = classifyMessagingError(errorCode);
|
||||
return {
|
||||
tokenId: token.id,
|
||||
deliveryStatus: classification === 'INVALID_TOKEN' ? 'INVALID_TOKEN' : 'FAILED',
|
||||
provider: token.provider,
|
||||
providerMessageId: null,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
responsePayload: {
|
||||
deliveryMode,
|
||||
},
|
||||
transient: classification === 'RETRYABLE',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user