From ace56570573adbda671d660ba0e854a4ab038082 Mon Sep 17 00:00:00 2001 From: devthejo Date: Wed, 23 Jul 2025 14:48:44 +0200 Subject: [PATCH] feat(ios): silent push notification geolocation heartbeat sync --- .../queues/ios-geolocation-heartbeat-sync.js | 120 ++++++++++++++++++ .../tasks/src/services/push-notification.js | 42 ++++-- services/watchers/src/constants/time.js | 1 + services/watchers/src/tasks/index.js | 1 + .../src/watchers/geodata-cleanup-cron.js | 23 ++++ 5 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 services/tasks/src/queues/ios-geolocation-heartbeat-sync.js diff --git a/services/tasks/src/queues/ios-geolocation-heartbeat-sync.js b/services/tasks/src/queues/ios-geolocation-heartbeat-sync.js new file mode 100644 index 0000000..8b29411 --- /dev/null +++ b/services/tasks/src/queues/ios-geolocation-heartbeat-sync.js @@ -0,0 +1,120 @@ +const { ctx } = require("@modjo/core") +const { taskCtx } = require("@modjo/microservice-worker/ctx") + +const pushNotification = require("~/services/push-notification") + +function createIosGeolocationHeartbeatSyncNotification() { + return { + data: { + action: "geolocation-heartbeat-sync", + }, + // Silent push notification + notification: { + silent: true, + }, + } +} + +module.exports = async function () { + return Object.assign( + async function iosGeolocationHeartbeatSync(params) { + const logger = taskCtx.require("logger") + const sql = ctx.require("postgres") + const redisCold = ctx.require("keydbColdGeodata") + + const { deviceId } = params + + try { + // Get the device information including phone_os and fcm_token + const deviceResult = await sql` + SELECT + "user_id" as "userId", + "phone_os" as "phoneOs", + "fcm_token" as "fcmToken" + FROM + "device" + WHERE + id = ${deviceId} + ` + + if (!deviceResult || deviceResult.length === 0) { + logger.warn( + { deviceId }, + "No device found when sending iOS geolocation heartbeat sync" + ) + return + } + + const { userId, phoneOs, fcmToken } = deviceResult[0] + + // Only proceed if device is iOS + if (phoneOs !== "ios") { + logger.debug( + { deviceId, phoneOs }, + "Skipping iOS heartbeat sync - device is not iOS" + ) + return + } + + if (!fcmToken) { + logger.warn( + { deviceId, userId }, + "No FCM token found for iOS device when sending heartbeat sync" + ) + return + } + + // Check if we've already sent a heartbeat sync for this device in the last 24h + const heartbeatSentKey = `ios_heartbeat_sent:device:${deviceId}` + const alreadySent = await redisCold.exists(heartbeatSentKey) + + if (alreadySent) { + logger.debug( + { deviceId, userId }, + "iOS heartbeat sync already sent for this device in the last 24h" + ) + return + } + + // Create silent notification config + const notificationConfig = + createIosGeolocationHeartbeatSyncNotification() + + // Send silent push notification + logger.info( + { deviceId, userId, notificationConfig }, + "Sending iOS silent push for geolocation heartbeat sync" + ) + + const { success } = await pushNotification({ + fcmToken, + deviceId, + notification: notificationConfig.notification, + data: notificationConfig.data, + }) + + if (!success) { + throw new Error( + "Unable to send iOS geolocation heartbeat sync notification" + ) + } + + // Mark as sent with 24h expiry to prevent duplicate sends + await redisCold.set(heartbeatSentKey, "1", "EX", 24 * 60 * 60) + + logger.info( + { deviceId, userId }, + "Successfully sent iOS geolocation heartbeat sync notification" + ) + } catch (error) { + logger.error( + { deviceId, error }, + "Error sending iOS geolocation heartbeat sync notification" + ) + } + }, + { + dedupOptions: { enabled: true }, + } + ) +} diff --git a/services/tasks/src/services/push-notification.js b/services/tasks/src/services/push-notification.js index 72adb30..a7e4881 100644 --- a/services/tasks/src/services/push-notification.js +++ b/services/tasks/src/services/push-notification.js @@ -48,7 +48,9 @@ function deriveNotificationConfig({ android = {}, apns = {}, uid, + silent = false, }) { + const isVisible = !silent const notification = { // https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.notification.md#notification_interface title, @@ -74,7 +76,14 @@ function deriveNotificationConfig({ priority: "high", visibility: "public", defaultSound: true, - clickAction: `com.alertesecours.${snakeCase(actionId).toUpperCase()}`, + // Only include clickAction for visible notifications (not silent ones) + ...(actionId && isVisible + ? { + clickAction: `com.alertesecours.${snakeCase( + actionId + ).toUpperCase()}`, + } + : {}), ...(android.notification || {}), }, restrictedPackageName: "com.alertesecours", @@ -85,7 +94,7 @@ function deriveNotificationConfig({ // https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.apnsconfig.md#apnsconfig_interface headers: { "apns-priority": priority === "high" ? "10" : "5", - "apns-push-type": "alert", + "apns-push-type": isVisible ? "alert" : "background", // Use background for silent pushes "apns-collapse-id": uid, // https://firebase.google.com/docs/cloud-messaging/concept-options ...(apns.headers || {}), }, @@ -93,16 +102,20 @@ function deriveNotificationConfig({ aps: { category: channel, threadId: channel, // Thread ID for grouping notifications - // Critical alerts for high importance - sound: { - critical: priority === "high", - name: "default", - volume: 1.0, - }, // Content available flag for background processing contentAvailable: true, // Support for modification of notification content mutableContent: true, + // Only include sound for non-silent notifications + ...(isVisible + ? { + sound: { + critical: priority === "high", + name: "default", + volume: 1.0, + }, + } + : {}), // alert: { // // https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.apsalert.md#apsalert_interface @@ -160,14 +173,21 @@ async function pushNotification({ // https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.basemessage const message = { token: fcmToken, - // Basic notification for platforms that don't need specific configs - notification: derivedNotification.notification, - data: { json: JSON.stringify(data), uid, actionId: notification.actionId }, + data: { json: JSON.stringify(data), uid, actionId: notification?.actionId }, // Platform specific configurations android: derivedNotification.android, apns: derivedNotification.apns, } + // Only include notification for non-silent pushes + if ( + notification && + !notification.silent && + (notification.title || notification.body) + ) { + message.notification = derivedNotification.notification + } + try { const res = await admin.messaging().send(message) logger.info( diff --git a/services/watchers/src/constants/time.js b/services/watchers/src/constants/time.js index 30a27ba..58fd799 100644 --- a/services/watchers/src/constants/time.js +++ b/services/watchers/src/constants/time.js @@ -8,6 +8,7 @@ module.exports = { SCAN_AUTO_CLOSE_CRON: "15 * * * *", // At minute 15 SCAN_AUTO_ARCHIVE_CRON: "0 4 * * *", // At 4:00 RELATIVE_UNREGISTERED_RECONCILIATION_CRON: "0 4 * * *", // At 4:00 + DEVICE_GEODATA_IOS_SILENT_PUSH_AGE: "24 hours", // When to send iOS silent push for heartbeat sync DEVICE_GEODATA_NOTIFICATION_AGE: "36 hours", // When to send push notification DEVICE_GEODATA_CLEANUP_AGE: "48 hours", // When to remove/clean data } diff --git a/services/watchers/src/tasks/index.js b/services/watchers/src/tasks/index.js index 06dcc92..8305094 100644 --- a/services/watchers/src/tasks/index.js +++ b/services/watchers/src/tasks/index.js @@ -10,4 +10,5 @@ module.exports = { RELATIVE_INVITATION_NOTIFY: "relativeInvitationNotify", ALERT_CALL_EMERGENCY_INFO_NOTIFY: "alertCallEmergencyInfoNotify", BACKGROUND_GEOLOCATION_LOST_NOTIFY: "backgroundGeolocationLostNotify", + IOS_GEOLOCATION_HEARTBEAT_SYNC: "iosGeolocationHeartbeatSync", } diff --git a/services/watchers/src/watchers/geodata-cleanup-cron.js b/services/watchers/src/watchers/geodata-cleanup-cron.js index 58441c0..92cadbf 100644 --- a/services/watchers/src/watchers/geodata-cleanup-cron.js +++ b/services/watchers/src/watchers/geodata-cleanup-cron.js @@ -3,6 +3,7 @@ const { ctx } = require("@modjo/core") const ms = require("ms") const cron = require("~/libs/cron") const { + DEVICE_GEODATA_IOS_SILENT_PUSH_AGE, DEVICE_GEODATA_NOTIFICATION_AGE, DEVICE_GEODATA_CLEANUP_AGE, } = require("~/constants/time") @@ -14,6 +15,9 @@ const COLDGEODATA_DEVICE_KEY_PREFIX = "device:geodata:" const COLDGEODATA_OLD_KEY_PREFIX = "old:device:geodata:" const COLDGEODATA_NOTIFIED_KEY_PREFIX = "notified:device:geodata:" const HOTGEODATA_KEY = "device" // The key where hot geodata is stored +const iosHeartbeatAge = Math.floor( + ms(DEVICE_GEODATA_IOS_SILENT_PUSH_AGE) / 1000 +) // Convert to seconds const notificationAge = Math.floor(ms(DEVICE_GEODATA_NOTIFICATION_AGE) / 1000) // Convert to seconds const cleanupAge = Math.floor(ms(DEVICE_GEODATA_CLEANUP_AGE) / 1000) // Convert to seconds @@ -84,6 +88,25 @@ module.exports = async function () { ) } } + // Handle iOS silent push for heartbeat sync (24h+ but less than 36h) + else if (age > iosHeartbeatAge) { + try { + // Enqueue task to send iOS silent push for geolocation heartbeat sync + await addTask(tasks.IOS_GEOLOCATION_HEARTBEAT_SYNC, { + deviceId, + }) + + logger.info( + { deviceId, age: `${Math.floor(age / 3600)}h` }, + "Enqueued iOS geolocation heartbeat sync task" + ) + } catch (heartbeatError) { + logger.error( + { deviceId, error: heartbeatError }, + "Error enqueueing iOS geolocation heartbeat sync task" + ) + } + } // Handle notification (36h+ but less than 48h) else if (age > notificationAge) { const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`