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

@@ -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(),

View File

@@ -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',

View 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 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}`;
}

View 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,
};
}

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

View 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();
}

View File

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

View File

@@ -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;

View 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]
);
}

View 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;
}

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