feat(ios): silent push notification geolocation heartbeat sync
All checks were successful
/ build (map[dockerfile:./services/watchers/Dockerfile name:watchers]) (push) Successful in 2m20s
/ build (map[dockerfile:./services/files/Dockerfile name:files]) (push) Successful in 1m32s
/ build (map[dockerfile:./services/hasura/Dockerfile name:hasura]) (push) Successful in 2m25s
/ build (map[dockerfile:./services/tasks/Dockerfile name:tasks]) (push) Successful in 2m26s
/ build (map[dockerfile:./services/app/Dockerfile name:app]) (push) Successful in 1m51s
/ build (map[dockerfile:./services/api/Dockerfile name:api]) (push) Successful in 2m37s
/ build (map[dockerfile:./services/web/Dockerfile name:web]) (push) Successful in 2m12s
/ deploy (push) Successful in 24s
All checks were successful
/ build (map[dockerfile:./services/watchers/Dockerfile name:watchers]) (push) Successful in 2m20s
/ build (map[dockerfile:./services/files/Dockerfile name:files]) (push) Successful in 1m32s
/ build (map[dockerfile:./services/hasura/Dockerfile name:hasura]) (push) Successful in 2m25s
/ build (map[dockerfile:./services/tasks/Dockerfile name:tasks]) (push) Successful in 2m26s
/ build (map[dockerfile:./services/app/Dockerfile name:app]) (push) Successful in 1m51s
/ build (map[dockerfile:./services/api/Dockerfile name:api]) (push) Successful in 2m37s
/ build (map[dockerfile:./services/web/Dockerfile name:web]) (push) Successful in 2m12s
/ deploy (push) Successful in 24s
This commit is contained in:
parent
74d999a9b8
commit
ace5657057
5 changed files with 176 additions and 11 deletions
120
services/tasks/src/queues/ios-geolocation-heartbeat-sync.js
Normal file
120
services/tasks/src/queues/ios-geolocation-heartbeat-sync.js
Normal file
|
@ -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 },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -48,7 +48,9 @@ function deriveNotificationConfig({
|
||||||
android = {},
|
android = {},
|
||||||
apns = {},
|
apns = {},
|
||||||
uid,
|
uid,
|
||||||
|
silent = false,
|
||||||
}) {
|
}) {
|
||||||
|
const isVisible = !silent
|
||||||
const notification = {
|
const notification = {
|
||||||
// https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.notification.md#notification_interface
|
// https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.notification.md#notification_interface
|
||||||
title,
|
title,
|
||||||
|
@ -74,7 +76,14 @@ function deriveNotificationConfig({
|
||||||
priority: "high",
|
priority: "high",
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
defaultSound: true,
|
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 || {}),
|
...(android.notification || {}),
|
||||||
},
|
},
|
||||||
restrictedPackageName: "com.alertesecours",
|
restrictedPackageName: "com.alertesecours",
|
||||||
|
@ -85,7 +94,7 @@ function deriveNotificationConfig({
|
||||||
// https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.apnsconfig.md#apnsconfig_interface
|
// https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.apnsconfig.md#apnsconfig_interface
|
||||||
headers: {
|
headers: {
|
||||||
"apns-priority": priority === "high" ? "10" : "5",
|
"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-collapse-id": uid, // https://firebase.google.com/docs/cloud-messaging/concept-options
|
||||||
...(apns.headers || {}),
|
...(apns.headers || {}),
|
||||||
},
|
},
|
||||||
|
@ -93,16 +102,20 @@ function deriveNotificationConfig({
|
||||||
aps: {
|
aps: {
|
||||||
category: channel,
|
category: channel,
|
||||||
threadId: channel, // Thread ID for grouping notifications
|
threadId: channel, // Thread ID for grouping notifications
|
||||||
// Critical alerts for high importance
|
// 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: {
|
sound: {
|
||||||
critical: priority === "high",
|
critical: priority === "high",
|
||||||
name: "default",
|
name: "default",
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
},
|
},
|
||||||
// Content available flag for background processing
|
}
|
||||||
contentAvailable: true,
|
: {}),
|
||||||
// Support for modification of notification content
|
|
||||||
mutableContent: true,
|
|
||||||
|
|
||||||
// alert: {
|
// alert: {
|
||||||
// // https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.apsalert.md#apsalert_interface
|
// // 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
|
// https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.basemessage
|
||||||
const message = {
|
const message = {
|
||||||
token: fcmToken,
|
token: fcmToken,
|
||||||
// Basic notification for platforms that don't need specific configs
|
data: { json: JSON.stringify(data), uid, actionId: notification?.actionId },
|
||||||
notification: derivedNotification.notification,
|
|
||||||
data: { json: JSON.stringify(data), uid, actionId: notification.actionId },
|
|
||||||
// Platform specific configurations
|
// Platform specific configurations
|
||||||
android: derivedNotification.android,
|
android: derivedNotification.android,
|
||||||
apns: derivedNotification.apns,
|
apns: derivedNotification.apns,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only include notification for non-silent pushes
|
||||||
|
if (
|
||||||
|
notification &&
|
||||||
|
!notification.silent &&
|
||||||
|
(notification.title || notification.body)
|
||||||
|
) {
|
||||||
|
message.notification = derivedNotification.notification
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await admin.messaging().send(message)
|
const res = await admin.messaging().send(message)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
@ -8,6 +8,7 @@ module.exports = {
|
||||||
SCAN_AUTO_CLOSE_CRON: "15 * * * *", // At minute 15
|
SCAN_AUTO_CLOSE_CRON: "15 * * * *", // At minute 15
|
||||||
SCAN_AUTO_ARCHIVE_CRON: "0 4 * * *", // At 4:00
|
SCAN_AUTO_ARCHIVE_CRON: "0 4 * * *", // At 4:00
|
||||||
RELATIVE_UNREGISTERED_RECONCILIATION_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_NOTIFICATION_AGE: "36 hours", // When to send push notification
|
||||||
DEVICE_GEODATA_CLEANUP_AGE: "48 hours", // When to remove/clean data
|
DEVICE_GEODATA_CLEANUP_AGE: "48 hours", // When to remove/clean data
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,4 +10,5 @@ module.exports = {
|
||||||
RELATIVE_INVITATION_NOTIFY: "relativeInvitationNotify",
|
RELATIVE_INVITATION_NOTIFY: "relativeInvitationNotify",
|
||||||
ALERT_CALL_EMERGENCY_INFO_NOTIFY: "alertCallEmergencyInfoNotify",
|
ALERT_CALL_EMERGENCY_INFO_NOTIFY: "alertCallEmergencyInfoNotify",
|
||||||
BACKGROUND_GEOLOCATION_LOST_NOTIFY: "backgroundGeolocationLostNotify",
|
BACKGROUND_GEOLOCATION_LOST_NOTIFY: "backgroundGeolocationLostNotify",
|
||||||
|
IOS_GEOLOCATION_HEARTBEAT_SYNC: "iosGeolocationHeartbeatSync",
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ const { ctx } = require("@modjo/core")
|
||||||
const ms = require("ms")
|
const ms = require("ms")
|
||||||
const cron = require("~/libs/cron")
|
const cron = require("~/libs/cron")
|
||||||
const {
|
const {
|
||||||
|
DEVICE_GEODATA_IOS_SILENT_PUSH_AGE,
|
||||||
DEVICE_GEODATA_NOTIFICATION_AGE,
|
DEVICE_GEODATA_NOTIFICATION_AGE,
|
||||||
DEVICE_GEODATA_CLEANUP_AGE,
|
DEVICE_GEODATA_CLEANUP_AGE,
|
||||||
} = require("~/constants/time")
|
} = require("~/constants/time")
|
||||||
|
@ -14,6 +15,9 @@ const COLDGEODATA_DEVICE_KEY_PREFIX = "device:geodata:"
|
||||||
const COLDGEODATA_OLD_KEY_PREFIX = "old:device:geodata:"
|
const COLDGEODATA_OLD_KEY_PREFIX = "old:device:geodata:"
|
||||||
const COLDGEODATA_NOTIFIED_KEY_PREFIX = "notified:device:geodata:"
|
const COLDGEODATA_NOTIFIED_KEY_PREFIX = "notified:device:geodata:"
|
||||||
const HOTGEODATA_KEY = "device" // The key where hot geodata is stored
|
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 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
|
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)
|
// Handle notification (36h+ but less than 48h)
|
||||||
else if (age > notificationAge) {
|
else if (age > notificationAge) {
|
||||||
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
const notifiedKey = `${COLDGEODATA_NOTIFIED_KEY_PREFIX}${deviceId}`
|
||||||
|
|
Loading…
Add table
Reference in a new issue