feat(attendance): add notification delivery and NFC security foundation
This commit is contained in:
@@ -6,6 +6,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
|
"dispatch:notifications": "node scripts/dispatch-notifications.mjs",
|
||||||
"migrate:idempotency": "node scripts/migrate-idempotency.mjs",
|
"migrate:idempotency": "node scripts/migrate-idempotency.mjs",
|
||||||
"migrate:v2-schema": "node scripts/migrate-v2-schema.mjs",
|
"migrate:v2-schema": "node scripts/migrate-v2-schema.mjs",
|
||||||
"seed:v2-demo": "node scripts/seed-v2-demo-data.mjs",
|
"seed:v2-demo": "node scripts/seed-v2-demo-data.mjs",
|
||||||
|
|||||||
14
backend/command-api/scripts/dispatch-notifications.mjs
Normal file
14
backend/command-api/scripts/dispatch-notifications.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { dispatchPendingNotifications } from '../src/services/notification-dispatcher.js';
|
||||||
|
import { closePool } from '../src/services/db.js';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await dispatchPendingNotifications();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(JSON.stringify({ ok: true, summary }, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(JSON.stringify({ ok: false, error: error?.message || String(error) }, null, 2));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await closePool();
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS device_push_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
|
||||||
|
business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL,
|
||||||
|
vendor_membership_id UUID REFERENCES vendor_memberships(id) ON DELETE SET NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'FCM'
|
||||||
|
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
|
||||||
|
platform TEXT NOT NULL
|
||||||
|
CHECK (platform IN ('IOS', 'ANDROID', 'WEB')),
|
||||||
|
push_token TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
device_id TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
app_build TEXT,
|
||||||
|
locale TEXT,
|
||||||
|
timezone TEXT,
|
||||||
|
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
invalidated_at TIMESTAMPTZ,
|
||||||
|
invalidation_reason TEXT,
|
||||||
|
last_registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_delivery_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_device_push_tokens_membership_scope
|
||||||
|
CHECK (
|
||||||
|
business_membership_id IS NOT NULL
|
||||||
|
OR vendor_membership_id IS NOT NULL
|
||||||
|
OR staff_id IS NOT NULL
|
||||||
|
OR user_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_push_tokens_provider_hash
|
||||||
|
ON device_push_tokens (provider, token_hash);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_user_active
|
||||||
|
ON device_push_tokens (user_id, last_seen_at DESC)
|
||||||
|
WHERE invalidated_at IS NULL AND notifications_enabled = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_push_tokens_staff_active
|
||||||
|
ON device_push_tokens (staff_id, last_seen_at DESC)
|
||||||
|
WHERE staff_id IS NOT NULL AND invalidated_at IS NULL AND notifications_enabled = TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
notification_outbox_id UUID NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE,
|
||||||
|
device_push_token_id UUID REFERENCES device_push_tokens(id) ON DELETE SET NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'FCM'
|
||||||
|
CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')),
|
||||||
|
delivery_status TEXT NOT NULL
|
||||||
|
CHECK (delivery_status IN ('SIMULATED', 'SENT', 'FAILED', 'INVALID_TOKEN', 'SKIPPED')),
|
||||||
|
provider_message_id TEXT,
|
||||||
|
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number >= 1),
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_outbox_created
|
||||||
|
ON notification_deliveries (notification_outbox_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_token_created
|
||||||
|
ON notification_deliveries (device_push_token_id, created_at DESC)
|
||||||
|
WHERE device_push_token_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance_security_proofs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||||
|
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
|
||||||
|
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
|
||||||
|
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT')),
|
||||||
|
source_type TEXT NOT NULL
|
||||||
|
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
|
||||||
|
device_id TEXT,
|
||||||
|
nfc_tag_uid TEXT,
|
||||||
|
proof_nonce TEXT,
|
||||||
|
proof_timestamp TIMESTAMPTZ,
|
||||||
|
request_fingerprint TEXT,
|
||||||
|
attestation_provider TEXT
|
||||||
|
CHECK (attestation_provider IS NULL OR attestation_provider IN ('PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK')),
|
||||||
|
attestation_token_hash TEXT,
|
||||||
|
attestation_status TEXT NOT NULL DEFAULT 'NOT_PROVIDED'
|
||||||
|
CHECK (attestation_status IN ('NOT_PROVIDED', 'RECORDED_UNVERIFIED', 'VERIFIED', 'REJECTED', 'BYPASSED')),
|
||||||
|
attestation_reason TEXT,
|
||||||
|
object_uri TEXT,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_security_proofs_nonce
|
||||||
|
ON attendance_security_proofs (tenant_id, proof_nonce)
|
||||||
|
WHERE proof_nonce IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_assignment_created
|
||||||
|
ON attendance_security_proofs (assignment_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_staff_created
|
||||||
|
ON attendance_security_proofs (staff_id, created_at DESC);
|
||||||
@@ -17,6 +17,8 @@ const preferredLocationSchema = z.object({
|
|||||||
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
|
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 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 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({
|
const shiftPositionSchema = z.object({
|
||||||
roleId: z.string().uuid().optional(),
|
roleId: z.string().uuid().optional(),
|
||||||
@@ -205,6 +207,11 @@ export const staffClockInSchema = z.object({
|
|||||||
longitude: z.number().min(-180).max(180).optional(),
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||||
capturedAt: z.string().datetime().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(),
|
notes: z.string().max(2000).optional(),
|
||||||
overrideReason: z.string().max(2000).optional(),
|
overrideReason: z.string().max(2000).optional(),
|
||||||
rawPayload: z.record(z.any()).optional(),
|
rawPayload: z.record(z.any()).optional(),
|
||||||
@@ -224,6 +231,11 @@ export const staffClockOutSchema = z.object({
|
|||||||
longitude: z.number().min(-180).max(180).optional(),
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||||
capturedAt: z.string().datetime().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(),
|
notes: z.string().max(2000).optional(),
|
||||||
overrideReason: z.string().max(2000).optional(),
|
overrideReason: z.string().max(2000).optional(),
|
||||||
breakMinutes: z.number().int().nonnegative().optional(),
|
breakMinutes: z.number().int().nonnegative().optional(),
|
||||||
@@ -253,6 +265,27 @@ export const staffLocationBatchSchema = z.object({
|
|||||||
message: 'assignmentId or shiftId is required',
|
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({
|
export const staffProfileSetupSchema = z.object({
|
||||||
fullName: z.string().min(2).max(160),
|
fullName: z.string().min(2).max(160),
|
||||||
bio: z.string().max(5000).optional(),
|
bio: z.string().max(5000).optional(),
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
disputeInvoice,
|
disputeInvoice,
|
||||||
quickSetStaffAvailability,
|
quickSetStaffAvailability,
|
||||||
rateWorkerFromCoverage,
|
rateWorkerFromCoverage,
|
||||||
|
registerClientPushToken,
|
||||||
|
registerStaffPushToken,
|
||||||
requestShiftSwap,
|
requestShiftSwap,
|
||||||
saveTaxFormDraft,
|
saveTaxFormDraft,
|
||||||
setupStaffProfile,
|
setupStaffProfile,
|
||||||
@@ -28,6 +30,8 @@ import {
|
|||||||
staffClockOut,
|
staffClockOut,
|
||||||
submitLocationStreamBatch,
|
submitLocationStreamBatch,
|
||||||
submitTaxForm,
|
submitTaxForm,
|
||||||
|
unregisterClientPushToken,
|
||||||
|
unregisterStaffPushToken,
|
||||||
updateEmergencyContact,
|
updateEmergencyContact,
|
||||||
updateHub,
|
updateHub,
|
||||||
updatePersonalInfo,
|
updatePersonalInfo,
|
||||||
@@ -62,6 +66,8 @@ import {
|
|||||||
preferredLocationsUpdateSchema,
|
preferredLocationsUpdateSchema,
|
||||||
privacyUpdateSchema,
|
privacyUpdateSchema,
|
||||||
profileExperienceSchema,
|
profileExperienceSchema,
|
||||||
|
pushTokenDeleteSchema,
|
||||||
|
pushTokenRegisterSchema,
|
||||||
shiftApplySchema,
|
shiftApplySchema,
|
||||||
shiftDecisionSchema,
|
shiftDecisionSchema,
|
||||||
staffClockInSchema,
|
staffClockInSchema,
|
||||||
@@ -91,6 +97,8 @@ const defaultHandlers = {
|
|||||||
disputeInvoice,
|
disputeInvoice,
|
||||||
quickSetStaffAvailability,
|
quickSetStaffAvailability,
|
||||||
rateWorkerFromCoverage,
|
rateWorkerFromCoverage,
|
||||||
|
registerClientPushToken,
|
||||||
|
registerStaffPushToken,
|
||||||
requestShiftSwap,
|
requestShiftSwap,
|
||||||
saveTaxFormDraft,
|
saveTaxFormDraft,
|
||||||
setupStaffProfile,
|
setupStaffProfile,
|
||||||
@@ -98,6 +106,8 @@ const defaultHandlers = {
|
|||||||
staffClockOut,
|
staffClockOut,
|
||||||
submitLocationStreamBatch,
|
submitLocationStreamBatch,
|
||||||
submitTaxForm,
|
submitTaxForm,
|
||||||
|
unregisterClientPushToken,
|
||||||
|
unregisterStaffPushToken,
|
||||||
updateEmergencyContact,
|
updateEmergencyContact,
|
||||||
updateHub,
|
updateHub,
|
||||||
updatePersonalInfo,
|
updatePersonalInfo,
|
||||||
@@ -285,6 +295,26 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
|||||||
handler: handlers.setupStaffProfile,
|
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', {
|
router.post(...mobileCommand('/staff/clock-in', {
|
||||||
schema: staffClockInSchema,
|
schema: staffClockInSchema,
|
||||||
policyAction: 'attendance.clock-in',
|
policyAction: 'attendance.clock-in',
|
||||||
@@ -306,6 +336,26 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
|||||||
handler: handlers.submitLocationStreamBatch,
|
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', {
|
router.put(...mobileCommand('/staff/availability', {
|
||||||
schema: availabilityDayUpdateSchema,
|
schema: availabilityDayUpdateSchema,
|
||||||
policyAction: 'staff.availability.write',
|
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 { AppError } from '../lib/errors.js';
|
||||||
import { withTransaction } from './db.js';
|
import { withTransaction } from './db.js';
|
||||||
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
import { recordGeofenceIncident } from './attendance-monitoring.js';
|
||||||
|
import { recordAttendanceSecurityProof } from './attendance-security.js';
|
||||||
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
||||||
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
||||||
|
|
||||||
@@ -1091,32 +1092,40 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
|||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||||
const validation = evaluateClockInAttempt(assignment, payload);
|
|
||||||
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
||||||
|
let securityProof = null;
|
||||||
|
|
||||||
if (validation.validationStatus === 'REJECTED') {
|
async function rejectAttendanceAttempt({
|
||||||
const incidentType = validation.validationCode === 'NFC_MISMATCH'
|
errorCode,
|
||||||
? 'NFC_MISMATCH'
|
reason,
|
||||||
: 'CLOCK_IN_REJECTED';
|
incidentType = 'CLOCK_IN_REJECTED',
|
||||||
|
severity = 'WARNING',
|
||||||
|
effectiveClockInMode = null,
|
||||||
|
distance = null,
|
||||||
|
withinGeofence = null,
|
||||||
|
metadata = {},
|
||||||
|
details = {},
|
||||||
|
}) {
|
||||||
const incidentId = await recordGeofenceIncident(client, {
|
const incidentId = await recordGeofenceIncident(client, {
|
||||||
assignment,
|
assignment,
|
||||||
actorUserId: actor.uid,
|
actorUserId: actor.uid,
|
||||||
incidentType,
|
incidentType,
|
||||||
severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING',
|
severity,
|
||||||
effectiveClockInMode: validation.effectiveClockInMode,
|
effectiveClockInMode,
|
||||||
sourceType: payload.sourceType,
|
sourceType: payload.sourceType,
|
||||||
nfcTagUid: payload.nfcTagUid || null,
|
nfcTagUid: payload.nfcTagUid || null,
|
||||||
deviceId: payload.deviceId || null,
|
deviceId: payload.deviceId || null,
|
||||||
latitude: payload.latitude ?? null,
|
latitude: payload.latitude ?? null,
|
||||||
longitude: payload.longitude ?? null,
|
longitude: payload.longitude ?? null,
|
||||||
accuracyMeters: payload.accuracyMeters ?? null,
|
accuracyMeters: payload.accuracyMeters ?? null,
|
||||||
distanceToClockPointMeters: validation.distance,
|
distanceToClockPointMeters: distance,
|
||||||
withinGeofence: validation.withinGeofence,
|
withinGeofence,
|
||||||
message: validation.validationReason,
|
overrideReason: payload.overrideReason || null,
|
||||||
|
message: reason,
|
||||||
occurredAt: capturedAt,
|
occurredAt: capturedAt,
|
||||||
metadata: {
|
metadata: {
|
||||||
validationCode: validation.validationCode,
|
|
||||||
eventType,
|
eventType,
|
||||||
|
...metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const rejectedEvent = await client.query(
|
const rejectedEvent = await client.query(
|
||||||
@@ -1161,11 +1170,15 @@ async function createAttendanceEvent(actor, payload, eventType) {
|
|||||||
payload.latitude ?? null,
|
payload.latitude ?? null,
|
||||||
payload.longitude ?? null,
|
payload.longitude ?? null,
|
||||||
payload.accuracyMeters ?? null,
|
payload.accuracyMeters ?? null,
|
||||||
validation.distance,
|
distance,
|
||||||
validation.withinGeofence,
|
withinGeofence,
|
||||||
validation.validationReason,
|
reason,
|
||||||
capturedAt,
|
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: {
|
payload: {
|
||||||
assignmentId: assignment.id,
|
assignmentId: assignment.id,
|
||||||
sourceType: payload.sourceType,
|
sourceType: payload.sourceType,
|
||||||
validationReason: validation.validationReason,
|
reason,
|
||||||
incidentId,
|
incidentId,
|
||||||
|
...details,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new AppError('ATTENDANCE_VALIDATION_FAILED', validation.validationReason, 409, {
|
throw new AppError(errorCode, reason, 409, {
|
||||||
assignmentId: payload.assignmentId,
|
assignmentId: payload.assignmentId,
|
||||||
attendanceEventId: rejectedEvent.rows[0].id,
|
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,
|
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.validationStatus,
|
||||||
validation.overrideUsed ? validation.overrideReason : validation.validationReason,
|
validation.overrideUsed ? validation.overrideReason : validation.validationReason,
|
||||||
capturedAt,
|
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,
|
validationStatus: eventResult.rows[0].validation_status,
|
||||||
effectiveClockInMode: validation.effectiveClockInMode,
|
effectiveClockInMode: validation.effectiveClockInMode,
|
||||||
overrideUsed: validation.overrideUsed,
|
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 { getFirebaseAdminAuth } from './firebase-admin.js';
|
||||||
import { getAuth } from 'firebase-admin/auth';
|
|
||||||
|
|
||||||
function ensureAdminApp() {
|
|
||||||
if (getApps().length === 0) {
|
|
||||||
initializeApp({ credential: applicationDefault() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyFirebaseToken(token) {
|
export async function verifyFirebaseToken(token) {
|
||||||
ensureAdminApp();
|
return getFirebaseAdminAuth().verifyIdToken(token);
|
||||||
return getAuth().verifyIdToken(token);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { recordGeofenceIncident } from './attendance-monitoring.js';
|
|||||||
import { distanceMeters, resolveEffectiveClockInPolicy } from './clock-in-policy.js';
|
import { distanceMeters, resolveEffectiveClockInPolicy } from './clock-in-policy.js';
|
||||||
import { uploadLocationBatch } from './location-log-storage.js';
|
import { uploadLocationBatch } from './location-log-storage.js';
|
||||||
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
||||||
|
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
||||||
import {
|
import {
|
||||||
cancelOrder as cancelOrderCommand,
|
cancelOrder as cancelOrderCommand,
|
||||||
clockIn as clockInCommand,
|
clockIn as clockInCommand,
|
||||||
@@ -1614,8 +1615,13 @@ export async function staffClockIn(actor, payload) {
|
|||||||
accuracyMeters: payload.accuracyMeters,
|
accuracyMeters: payload.accuracyMeters,
|
||||||
capturedAt: payload.capturedAt,
|
capturedAt: payload.capturedAt,
|
||||||
overrideReason: payload.overrideReason || null,
|
overrideReason: payload.overrideReason || null,
|
||||||
|
proofNonce: payload.proofNonce || null,
|
||||||
|
proofTimestamp: payload.proofTimestamp || null,
|
||||||
|
attestationProvider: payload.attestationProvider || null,
|
||||||
|
attestationToken: payload.attestationToken || null,
|
||||||
rawPayload: {
|
rawPayload: {
|
||||||
notes: payload.notes || null,
|
notes: payload.notes || null,
|
||||||
|
isMockLocation: payload.isMockLocation ?? null,
|
||||||
...(payload.rawPayload || {}),
|
...(payload.rawPayload || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1640,15 +1646,116 @@ export async function staffClockOut(actor, payload) {
|
|||||||
accuracyMeters: payload.accuracyMeters,
|
accuracyMeters: payload.accuracyMeters,
|
||||||
capturedAt: payload.capturedAt,
|
capturedAt: payload.capturedAt,
|
||||||
overrideReason: payload.overrideReason || null,
|
overrideReason: payload.overrideReason || null,
|
||||||
|
proofNonce: payload.proofNonce || null,
|
||||||
|
proofTimestamp: payload.proofTimestamp || null,
|
||||||
|
attestationProvider: payload.attestationProvider || null,
|
||||||
|
attestationToken: payload.attestationToken || null,
|
||||||
rawPayload: {
|
rawPayload: {
|
||||||
notes: payload.notes || null,
|
notes: payload.notes || null,
|
||||||
breakMinutes: payload.breakMinutes ?? null,
|
breakMinutes: payload.breakMinutes ?? null,
|
||||||
applicationId: payload.applicationId || null,
|
applicationId: payload.applicationId || null,
|
||||||
|
isMockLocation: payload.isMockLocation ?? null,
|
||||||
...(payload.rawPayload || {}),
|
...(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) {
|
function summarizeLocationPoints(points, assignment) {
|
||||||
let outOfGeofenceCount = 0;
|
let outOfGeofenceCount = 0;
|
||||||
let missingCoordinateCount = 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',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,13 +48,30 @@ function createMobileHandlers() {
|
|||||||
invoiceId: payload.invoiceId,
|
invoiceId: payload.invoiceId,
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
}),
|
}),
|
||||||
|
registerClientPushToken: async (_actor, payload) => ({
|
||||||
|
tokenId: 'push-token-client-1',
|
||||||
|
platform: payload.platform,
|
||||||
|
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||||
|
}),
|
||||||
|
unregisterClientPushToken: async () => ({
|
||||||
|
removedCount: 1,
|
||||||
|
}),
|
||||||
applyForShift: async (_actor, payload) => ({
|
applyForShift: async (_actor, payload) => ({
|
||||||
shiftId: payload.shiftId,
|
shiftId: payload.shiftId,
|
||||||
status: 'APPLIED',
|
status: 'APPLIED',
|
||||||
}),
|
}),
|
||||||
|
registerStaffPushToken: async (_actor, payload) => ({
|
||||||
|
tokenId: 'push-token-staff-1',
|
||||||
|
platform: payload.platform,
|
||||||
|
notificationsEnabled: payload.notificationsEnabled ?? true,
|
||||||
|
}),
|
||||||
|
unregisterStaffPushToken: async () => ({
|
||||||
|
removedCount: 1,
|
||||||
|
}),
|
||||||
staffClockIn: async (_actor, payload) => ({
|
staffClockIn: async (_actor, payload) => ({
|
||||||
assignmentId: payload.assignmentId || 'assignment-1',
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
status: 'CLOCK_IN',
|
status: 'CLOCK_IN',
|
||||||
|
proofNonce: payload.proofNonce || null,
|
||||||
}),
|
}),
|
||||||
staffClockOut: async (_actor, payload) => ({
|
staffClockOut: async (_actor, payload) => ({
|
||||||
assignmentId: payload.assignmentId || 'assignment-1',
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
@@ -157,6 +174,36 @@ test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice
|
|||||||
assert.equal(res.body.status, 'APPROVED');
|
assert.equal(res.body.status, 'APPROVED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /commands/client/devices/push-tokens registers a client push token', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/client/devices/push-tokens')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'client-push-token-1')
|
||||||
|
.send({
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'IOS',
|
||||||
|
pushToken: 'f'.repeat(160),
|
||||||
|
deviceId: 'iphone-15-pro',
|
||||||
|
notificationsEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.tokenId, 'push-token-client-1');
|
||||||
|
assert.equal(res.body.platform, 'IOS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /commands/client/devices/push-tokens accepts tokenId from query params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/commands/client/devices/push-tokens?tokenId=11111111-1111-4111-8111-111111111111&reason=SMOKE_CLEANUP')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'client-push-token-delete-1');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.removedCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
|
test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => {
|
||||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
@@ -183,11 +230,13 @@ test('POST /commands/staff/clock-in accepts shift-based payload', async () => {
|
|||||||
sourceType: 'GEO',
|
sourceType: 'GEO',
|
||||||
latitude: 37.422,
|
latitude: 37.422,
|
||||||
longitude: -122.084,
|
longitude: -122.084,
|
||||||
|
proofNonce: 'nonce-12345678',
|
||||||
overrideReason: 'GPS timed out near the hub',
|
overrideReason: 'GPS timed out near the hub',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.body.status, 'CLOCK_IN');
|
assert.equal(res.body.status, 'CLOCK_IN');
|
||||||
|
assert.equal(res.body.proofNonce, 'nonce-12345678');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
|
test('POST /commands/staff/clock-out accepts assignment-based payload', async () => {
|
||||||
@@ -230,6 +279,35 @@ test('POST /commands/staff/location-streams accepts batched location payloads',
|
|||||||
assert.equal(res.body.pointCount, 1);
|
assert.equal(res.body.pointCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/devices/push-tokens registers a staff push token', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/devices/push-tokens')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'staff-push-token-1')
|
||||||
|
.send({
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'ANDROID',
|
||||||
|
pushToken: 'g'.repeat(170),
|
||||||
|
deviceId: 'pixel-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.tokenId, 'push-token-staff-1');
|
||||||
|
assert.equal(res.body.platform, 'ANDROID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /commands/staff/devices/push-tokens accepts tokenId from query params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/commands/staff/devices/push-tokens?tokenId=22222222-2222-4222-8222-222222222222&reason=SMOKE_CLEANUP')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'staff-push-token-delete-1');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.removedCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
|
test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => {
|
||||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
38
backend/command-api/test/notification-dispatcher.test.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { computeRetryDelayMinutes } from '../src/services/notification-dispatcher.js';
|
||||||
|
import { createPushSender, classifyMessagingError } from '../src/services/notification-fcm.js';
|
||||||
|
|
||||||
|
test('computeRetryDelayMinutes backs off exponentially with a cap', () => {
|
||||||
|
assert.equal(computeRetryDelayMinutes(1), 5);
|
||||||
|
assert.equal(computeRetryDelayMinutes(2), 10);
|
||||||
|
assert.equal(computeRetryDelayMinutes(3), 20);
|
||||||
|
assert.equal(computeRetryDelayMinutes(5), 60);
|
||||||
|
assert.equal(computeRetryDelayMinutes(9), 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyMessagingError distinguishes invalid and retryable push failures', () => {
|
||||||
|
assert.equal(classifyMessagingError('messaging/registration-token-not-registered'), 'INVALID_TOKEN');
|
||||||
|
assert.equal(classifyMessagingError('messaging/server-unavailable'), 'RETRYABLE');
|
||||||
|
assert.equal(classifyMessagingError('messaging/unknown-problem'), 'FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createPushSender log-only mode simulates successful delivery results', async () => {
|
||||||
|
const sender = createPushSender({ deliveryMode: 'log-only' });
|
||||||
|
const results = await sender.send(
|
||||||
|
{
|
||||||
|
id: 'notification-1',
|
||||||
|
notification_type: 'SHIFT_START_REMINDER',
|
||||||
|
priority: 'HIGH',
|
||||||
|
tenant_id: 'tenant-1',
|
||||||
|
payload: { assignmentId: 'assignment-1' },
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ id: 'token-1', provider: 'FCM', pushToken: 'demo-token' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].deliveryStatus, 'SIMULATED');
|
||||||
|
assert.equal(results[0].transient, false);
|
||||||
|
});
|
||||||
@@ -153,6 +153,50 @@ async function main() {
|
|||||||
assert.equal(clientSession.business.businessId, fixture.business.id);
|
assert.equal(clientSession.business.businessId, fixture.business.id);
|
||||||
logStep('client.session.ok', clientSession);
|
logStep('client.session.ok', clientSession);
|
||||||
|
|
||||||
|
const clientPushTokenPrimary = await apiCall('/client/devices/push-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('client-push-token-primary'),
|
||||||
|
body: {
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'IOS',
|
||||||
|
pushToken: `smoke-client-primary-${Date.now()}-abcdefghijklmnop`,
|
||||||
|
deviceId: 'smoke-client-iphone-15-pro',
|
||||||
|
appVersion: '2.0.0-smoke',
|
||||||
|
appBuild: '2000',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezone: 'America/Los_Angeles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(clientPushTokenPrimary.tokenId);
|
||||||
|
logStep('client.push-token.register-primary.ok', clientPushTokenPrimary);
|
||||||
|
|
||||||
|
const clientPushTokenCleanup = await apiCall('/client/devices/push-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('client-push-token-cleanup'),
|
||||||
|
body: {
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'ANDROID',
|
||||||
|
pushToken: `smoke-client-cleanup-${Date.now()}-abcdefghijklmnop`,
|
||||||
|
deviceId: 'smoke-client-pixel-9',
|
||||||
|
appVersion: '2.0.0-smoke',
|
||||||
|
appBuild: '2001',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezone: 'America/Los_Angeles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(clientPushTokenCleanup.tokenId);
|
||||||
|
logStep('client.push-token.register-cleanup.ok', clientPushTokenCleanup);
|
||||||
|
|
||||||
|
const clientPushTokenDeleted = await apiCall(`/client/devices/push-tokens?tokenId=${encodeURIComponent(clientPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
idempotencyKey: uniqueKey('client-push-token-delete'),
|
||||||
|
});
|
||||||
|
assert.equal(clientPushTokenDeleted.removedCount, 1);
|
||||||
|
logStep('client.push-token.delete.ok', clientPushTokenDeleted);
|
||||||
|
|
||||||
const clientDashboard = await apiCall('/client/dashboard', {
|
const clientDashboard = await apiCall('/client/dashboard', {
|
||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
});
|
});
|
||||||
@@ -517,6 +561,50 @@ async function main() {
|
|||||||
assert.equal(staffSession.staff.staffId, fixture.staff.ana.id);
|
assert.equal(staffSession.staff.staffId, fixture.staff.ana.id);
|
||||||
logStep('staff.session.ok', staffSession);
|
logStep('staff.session.ok', staffSession);
|
||||||
|
|
||||||
|
const staffPushTokenPrimary = await apiCall('/staff/devices/push-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-push-token-primary'),
|
||||||
|
body: {
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'IOS',
|
||||||
|
pushToken: `smoke-staff-primary-${Date.now()}-abcdefghijklmnop`,
|
||||||
|
deviceId: 'smoke-staff-iphone-15-pro',
|
||||||
|
appVersion: '2.0.0-smoke',
|
||||||
|
appBuild: '2000',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezone: 'America/Los_Angeles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(staffPushTokenPrimary.tokenId);
|
||||||
|
logStep('staff.push-token.register-primary.ok', staffPushTokenPrimary);
|
||||||
|
|
||||||
|
const staffPushTokenCleanup = await apiCall('/staff/devices/push-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-push-token-cleanup'),
|
||||||
|
body: {
|
||||||
|
provider: 'FCM',
|
||||||
|
platform: 'ANDROID',
|
||||||
|
pushToken: `smoke-staff-cleanup-${Date.now()}-abcdefghijklmnop`,
|
||||||
|
deviceId: 'smoke-staff-pixel-9',
|
||||||
|
appVersion: '2.0.0-smoke',
|
||||||
|
appBuild: '2001',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezone: 'America/Los_Angeles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(staffPushTokenCleanup.tokenId);
|
||||||
|
logStep('staff.push-token.register-cleanup.ok', staffPushTokenCleanup);
|
||||||
|
|
||||||
|
const staffPushTokenDeleted = await apiCall(`/staff/devices/push-tokens?tokenId=${encodeURIComponent(staffPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-push-token-delete'),
|
||||||
|
});
|
||||||
|
assert.equal(staffPushTokenDeleted.removedCount, 1);
|
||||||
|
logStep('staff.push-token.delete.ok', staffPushTokenDeleted);
|
||||||
|
|
||||||
const staffDashboard = await apiCall('/staff/dashboard', {
|
const staffDashboard = await apiCall('/staff/dashboard', {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
@@ -871,6 +959,8 @@ async function main() {
|
|||||||
latitude: fixture.clockPoint.latitude + 0.0075,
|
latitude: fixture.clockPoint.latitude + 0.0075,
|
||||||
longitude: fixture.clockPoint.longitude + 0.0075,
|
longitude: fixture.clockPoint.longitude + 0.0075,
|
||||||
accuracyMeters: 8,
|
accuracyMeters: 8,
|
||||||
|
proofNonce: uniqueKey('geo-proof-clock-in'),
|
||||||
|
proofTimestamp: isoTimestamp(0),
|
||||||
overrideReason: 'Parking garage entrance is outside the marked hub geofence',
|
overrideReason: 'Parking garage entrance is outside the marked hub geofence',
|
||||||
capturedAt: isoTimestamp(0),
|
capturedAt: isoTimestamp(0),
|
||||||
},
|
},
|
||||||
@@ -878,6 +968,7 @@ async function main() {
|
|||||||
assert.equal(clockIn.validationStatus, 'FLAGGED');
|
assert.equal(clockIn.validationStatus, 'FLAGGED');
|
||||||
assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode);
|
assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode);
|
||||||
assert.equal(clockIn.overrideUsed, true);
|
assert.equal(clockIn.overrideUsed, true);
|
||||||
|
assert.ok(clockIn.securityProofId);
|
||||||
logStep('staff.clock-in.ok', clockIn);
|
logStep('staff.clock-in.ok', clockIn);
|
||||||
|
|
||||||
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
|
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
|
||||||
@@ -950,10 +1041,13 @@ async function main() {
|
|||||||
latitude: fixture.clockPoint.latitude,
|
latitude: fixture.clockPoint.latitude,
|
||||||
longitude: fixture.clockPoint.longitude,
|
longitude: fixture.clockPoint.longitude,
|
||||||
accuracyMeters: 10,
|
accuracyMeters: 10,
|
||||||
|
proofNonce: uniqueKey('geo-proof-clock-out'),
|
||||||
|
proofTimestamp: isoTimestamp(1),
|
||||||
breakMinutes: 30,
|
breakMinutes: 30,
|
||||||
capturedAt: isoTimestamp(1),
|
capturedAt: isoTimestamp(1),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assert.ok(clockOut.securityProofId);
|
||||||
logStep('staff.clock-out.ok', clockOut);
|
logStep('staff.clock-out.ok', clockOut);
|
||||||
|
|
||||||
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
||||||
|
|||||||
@@ -22,16 +22,17 @@ What was validated live against the deployed stack:
|
|||||||
- staff auth bootstrap
|
- staff auth bootstrap
|
||||||
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports
|
||||||
- client coverage incident feed for geofence and override review
|
- client coverage incident feed for geofence and override review
|
||||||
- client hub, order, coverage review, and late-worker cancellation flows
|
- client hub, order, coverage review, device token, and late-worker cancellation flows
|
||||||
- client invoice approve and dispute
|
- client invoice approve and dispute
|
||||||
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
|
- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card
|
||||||
- staff availability, profile, tax form, bank account, shift apply, shift accept, clock-in, clock-out, location stream upload, and swap request
|
- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request
|
||||||
- direct file upload helpers and verification job creation through the unified host
|
- direct file upload helpers and verification job creation through the unified host
|
||||||
- client and staff sign-out
|
- client and staff sign-out
|
||||||
|
|
||||||
The live validation command is:
|
The live validation command is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export FIREBASE_WEB_API_KEY="$(gcloud secrets versions access latest --secret=firebase-web-api-key --project=krow-workforce-dev)"
|
||||||
source ~/.nvm/nvm.sh
|
source ~/.nvm/nvm.sh
|
||||||
nvm use 23.5.0
|
nvm use 23.5.0
|
||||||
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
|
node backend/unified-api/scripts/live-smoke-v2-unified.mjs
|
||||||
@@ -101,10 +102,12 @@ Important operational rules:
|
|||||||
|
|
||||||
- outside-geofence clock-ins can be accepted only when override is enabled and a written reason is provided
|
- outside-geofence clock-ins can be accepted only when override is enabled and a written reason is provided
|
||||||
- NFC mismatches are rejected and are not overrideable
|
- NFC mismatches are rejected and are not overrideable
|
||||||
|
- attendance proof logs are durable in SQL and raw object storage
|
||||||
|
- device push tokens are durable in SQL and can be registered separately for client and staff apps
|
||||||
- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed
|
- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed
|
||||||
- incident review lives on `GET /client/coverage/incidents`
|
- incident review lives on `GET /client/coverage/incidents`
|
||||||
- confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel`
|
- confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel`
|
||||||
- queued manager alerts are written to `notification_outbox`; this is durable notification orchestration, not a full push delivery worker yet
|
- queued alerts are written to `notification_outbox`, dispatched by Cloud Run job `krow-notification-dispatcher-v2`, and recorded in `notification_deliveries`
|
||||||
|
|
||||||
## 5) Route model
|
## 5) Route model
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ The gateway keeps backend services separate internally, but frontend should trea
|
|||||||
|
|
||||||
### Client writes
|
### Client writes
|
||||||
|
|
||||||
|
- `POST /client/devices/push-tokens`
|
||||||
|
- `DELETE /client/devices/push-tokens`
|
||||||
- `POST /client/orders/one-time`
|
- `POST /client/orders/one-time`
|
||||||
- `POST /client/orders/recurring`
|
- `POST /client/orders/recurring`
|
||||||
- `POST /client/orders/permanent`
|
- `POST /client/orders/permanent`
|
||||||
@@ -113,6 +115,8 @@ The gateway keeps backend services separate internally, but frontend should trea
|
|||||||
### Staff writes
|
### Staff writes
|
||||||
|
|
||||||
- `POST /staff/profile/setup`
|
- `POST /staff/profile/setup`
|
||||||
|
- `POST /staff/devices/push-tokens`
|
||||||
|
- `DELETE /staff/devices/push-tokens`
|
||||||
- `POST /staff/clock-in`
|
- `POST /staff/clock-in`
|
||||||
- `POST /staff/clock-out`
|
- `POST /staff/clock-out`
|
||||||
- `POST /staff/location-streams`
|
- `POST /staff/location-streams`
|
||||||
@@ -170,11 +174,40 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
- For `POST /staff/clock-in` and `POST /staff/clock-out`:
|
- For `POST /staff/clock-in` and `POST /staff/clock-out`:
|
||||||
- send `nfcTagId` when clocking with NFC
|
- send `nfcTagId` when clocking with NFC
|
||||||
- send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation
|
- send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation
|
||||||
|
- send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths
|
||||||
|
- send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward
|
||||||
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
|
- send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
|
||||||
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
|
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
|
||||||
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
|
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
|
||||||
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
|
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
|
||||||
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
|
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
|
||||||
|
- Push delivery is backed by:
|
||||||
|
- SQL token registry in `device_push_tokens`
|
||||||
|
- durable queue in `notification_outbox`
|
||||||
|
- per-attempt delivery records in `notification_deliveries`
|
||||||
|
- Cloud Run job `krow-notification-dispatcher-v2`
|
||||||
|
|
||||||
|
### Push token request example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "FCM",
|
||||||
|
"platform": "IOS",
|
||||||
|
"pushToken": "expo-or-fcm-device-token",
|
||||||
|
"deviceId": "iphone-15-pro-max",
|
||||||
|
"appVersion": "2.0.0",
|
||||||
|
"appBuild": "2000",
|
||||||
|
"locale": "en-US",
|
||||||
|
"timezone": "America/Los_Angeles"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Push-token delete requests may send `tokenId` or `pushToken` either:
|
||||||
|
|
||||||
|
- as JSON in the request body
|
||||||
|
- or as query params on the `DELETE` URL
|
||||||
|
|
||||||
|
Using query params is safer when the client stack or proxy is inconsistent about forwarding `DELETE` bodies.
|
||||||
|
|
||||||
### Clock-in request example
|
### Clock-in request example
|
||||||
|
|
||||||
@@ -186,6 +219,8 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
"latitude": 37.4221,
|
"latitude": 37.4221,
|
||||||
"longitude": -122.0841,
|
"longitude": -122.0841,
|
||||||
"accuracyMeters": 12,
|
"accuracyMeters": 12,
|
||||||
|
"proofNonce": "nonce-generated-on-device",
|
||||||
|
"proofTimestamp": "2026-03-16T09:00:00.000Z",
|
||||||
"overrideReason": "Parking garage entrance is outside the marked hub geofence",
|
"overrideReason": "Parking garage entrance is outside the marked hub geofence",
|
||||||
"capturedAt": "2026-03-16T09:00:00.000Z"
|
"capturedAt": "2026-03-16T09:00:00.000Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2
|
|||||||
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
|
BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2
|
||||||
BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-api-v2
|
BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-api-v2
|
||||||
BACKEND_V2_UNIFIED_SERVICE_NAME ?= krow-api-v2
|
BACKEND_V2_UNIFIED_SERVICE_NAME ?= krow-api-v2
|
||||||
|
BACKEND_V2_NOTIFICATION_JOB_NAME ?= krow-notification-dispatcher-v2
|
||||||
BACKEND_V2_RUNTIME_SA_NAME ?= krow-backend-v2-runtime
|
BACKEND_V2_RUNTIME_SA_NAME ?= krow-backend-v2-runtime
|
||||||
BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
|
BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com
|
||||||
|
|
||||||
@@ -76,8 +77,17 @@ BACKEND_V2_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$
|
|||||||
BACKEND_V2_QUERY_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/query-api-v2:latest
|
BACKEND_V2_QUERY_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/query-api-v2:latest
|
||||||
BACKEND_V2_UNIFIED_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/unified-api-v2:latest
|
BACKEND_V2_UNIFIED_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/unified-api-v2:latest
|
||||||
BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET ?= firebase-web-api-key
|
BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET ?= firebase-web-api-key
|
||||||
|
BACKEND_V2_NOTIFICATION_BATCH_LIMIT ?= 50
|
||||||
|
BACKEND_V2_PUSH_DELIVERY_MODE ?= live
|
||||||
|
BACKEND_V2_SHIFT_REMINDERS_ENABLED ?= true
|
||||||
|
BACKEND_V2_SHIFT_REMINDER_LEAD_MINUTES ?= 60,15
|
||||||
|
BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES ?= 5
|
||||||
|
BACKEND_V2_NFC_ENFORCE_PROOF_NONCE ?= false
|
||||||
|
BACKEND_V2_NFC_ENFORCE_DEVICE_ID ?= false
|
||||||
|
BACKEND_V2_NFC_ENFORCE_ATTESTATION ?= false
|
||||||
|
BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS ?= 120
|
||||||
|
|
||||||
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
|
.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-deploy-notification-job-v2 backend-run-notification-job-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema
|
||||||
|
|
||||||
backend-help:
|
backend-help:
|
||||||
@echo "--> Backend Foundation Commands"
|
@echo "--> Backend Foundation Commands"
|
||||||
@@ -97,6 +107,8 @@ backend-help:
|
|||||||
@echo " make backend-deploy-commands-v2 [ENV=dev] Build + deploy command API v2 service"
|
@echo " make backend-deploy-commands-v2 [ENV=dev] Build + deploy command API v2 service"
|
||||||
@echo " make backend-deploy-query-v2 [ENV=dev] Build + deploy query API v2 service"
|
@echo " make backend-deploy-query-v2 [ENV=dev] Build + deploy query API v2 service"
|
||||||
@echo " make backend-deploy-unified-v2 [ENV=dev] Build + deploy unified API v2 gateway"
|
@echo " make backend-deploy-unified-v2 [ENV=dev] Build + deploy unified API v2 gateway"
|
||||||
|
@echo " make backend-deploy-notification-job-v2 Deploy notification dispatcher v2 job"
|
||||||
|
@echo " make backend-run-notification-job-v2 Run notification dispatcher v2 job once"
|
||||||
@echo " make backend-v2-migrate-schema Apply v2 domain schema against krow-sql-v2"
|
@echo " make backend-v2-migrate-schema Apply v2 domain schema against krow-sql-v2"
|
||||||
@echo " make backend-v2-migrate-idempotency Apply command idempotency migration against v2 DB"
|
@echo " make backend-v2-migrate-idempotency Apply command idempotency migration against v2 DB"
|
||||||
@echo " make backend-smoke-core-v2 [ENV=dev] Smoke test core API v2 /health"
|
@echo " make backend-smoke-core-v2 [ENV=dev] Smoke test core API v2 /health"
|
||||||
@@ -283,6 +295,10 @@ backend-bootstrap-v2-dev: backend-enable-apis
|
|||||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||||
--role="roles/secretmanager.secretAccessor" \
|
--role="roles/secretmanager.secretAccessor" \
|
||||||
--quiet >/dev/null
|
--quiet >/dev/null
|
||||||
|
@gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \
|
||||||
|
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||||
|
--role="roles/firebasecloudmessaging.admin" \
|
||||||
|
--quiet >/dev/null
|
||||||
@gcloud iam service-accounts add-iam-policy-binding $(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
@gcloud iam service-accounts add-iam-policy-binding $(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||||
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
--member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \
|
||||||
--role="roles/iam.serviceAccountTokenCreator" \
|
--role="roles/iam.serviceAccountTokenCreator" \
|
||||||
@@ -357,7 +373,7 @@ backend-deploy-core-v2:
|
|||||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||||
--set-env-vars=$$EXTRA_ENV \
|
--set-env-vars=$$EXTRA_ENV \
|
||||||
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||||
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||||
@echo "✅ Core backend v2 service deployed."
|
@echo "✅ Core backend v2 service deployed."
|
||||||
|
|
||||||
@@ -366,7 +382,7 @@ backend-deploy-commands-v2:
|
|||||||
@test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1)
|
@test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1)
|
||||||
@test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1)
|
@test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1)
|
||||||
@gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID)
|
@gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID)
|
||||||
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),IDEMPOTENCY_STORE=sql,INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER)"; \
|
@EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),IDEMPOTENCY_STORE=sql,INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS)"; \
|
||||||
gcloud run deploy $(BACKEND_V2_COMMAND_SERVICE_NAME) \
|
gcloud run deploy $(BACKEND_V2_COMMAND_SERVICE_NAME) \
|
||||||
--image=$(BACKEND_V2_COMMAND_IMAGE) \
|
--image=$(BACKEND_V2_COMMAND_IMAGE) \
|
||||||
--region=$(BACKEND_REGION) \
|
--region=$(BACKEND_REGION) \
|
||||||
@@ -374,10 +390,39 @@ backend-deploy-commands-v2:
|
|||||||
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||||
--set-env-vars=$$EXTRA_ENV \
|
--set-env-vars=$$EXTRA_ENV \
|
||||||
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||||
--add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||||
$(BACKEND_V2_RUN_AUTH_FLAG)
|
$(BACKEND_V2_RUN_AUTH_FLAG)
|
||||||
@echo "✅ Command backend v2 service deployed."
|
@echo "✅ Command backend v2 service deployed."
|
||||||
|
|
||||||
|
backend-deploy-notification-job-v2:
|
||||||
|
@echo "--> Deploying notification dispatcher v2 job [$(BACKEND_V2_NOTIFICATION_JOB_NAME)]..."
|
||||||
|
@test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1)
|
||||||
|
@test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1)
|
||||||
|
@gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID)
|
||||||
|
@gcloud run jobs deploy $(BACKEND_V2_NOTIFICATION_JOB_NAME) \
|
||||||
|
--image=$(BACKEND_V2_COMMAND_IMAGE) \
|
||||||
|
--region=$(BACKEND_REGION) \
|
||||||
|
--project=$(GCP_PROJECT_ID) \
|
||||||
|
--service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \
|
||||||
|
--command=node \
|
||||||
|
--args=scripts/dispatch-notifications.mjs \
|
||||||
|
--set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS) \
|
||||||
|
--set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \
|
||||||
|
--set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \
|
||||||
|
--tasks=1 \
|
||||||
|
--parallelism=1 \
|
||||||
|
--max-retries=1 \
|
||||||
|
--task-timeout=10m
|
||||||
|
@echo "✅ Notification dispatcher v2 job deployed."
|
||||||
|
|
||||||
|
backend-run-notification-job-v2:
|
||||||
|
@echo "--> Running notification dispatcher v2 job [$(BACKEND_V2_NOTIFICATION_JOB_NAME)]..."
|
||||||
|
@gcloud run jobs execute $(BACKEND_V2_NOTIFICATION_JOB_NAME) \
|
||||||
|
--region=$(BACKEND_REGION) \
|
||||||
|
--project=$(GCP_PROJECT_ID) \
|
||||||
|
--wait
|
||||||
|
@echo "✅ Notification dispatcher v2 job completed."
|
||||||
|
|
||||||
backend-deploy-query-v2:
|
backend-deploy-query-v2:
|
||||||
@echo "--> Deploying query backend v2 service [$(BACKEND_V2_QUERY_SERVICE_NAME)] to [$(ENV)]..."
|
@echo "--> Deploying query backend v2 service [$(BACKEND_V2_QUERY_SERVICE_NAME)] to [$(ENV)]..."
|
||||||
@test -d $(BACKEND_V2_QUERY_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_QUERY_DIR)" && exit 1)
|
@test -d $(BACKEND_V2_QUERY_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_QUERY_DIR)" && exit 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user