11import { Injectable , Logger } from '@nestjs/common'
22import { Cron , CronExpression } from '@nestjs/schedule'
33import { InjectModel } from '@nestjs/mongoose'
4- import { Model } from 'mongoose'
4+ import { Model , Types } from 'mongoose'
55import { Device , DeviceDocument } from '../schemas/device.schema'
66import * as firebaseAdmin from 'firebase-admin'
77import { Message } from 'firebase-admin/messaging'
88
99const FCM_BATCH_SIZE = 500
1010
11+ function isPermanentFcmTokenError (
12+ error : { code ?: string ; message ?: string } | null | undefined ,
13+ ) : boolean {
14+ if ( ! error ) {
15+ return false
16+ }
17+
18+ const normalizedCode = String ( error . code || '' )
19+ . toLowerCase ( )
20+ . replace ( / ^ m e s s a g i n g \/ / , '' )
21+ const normalizedMessage = String ( error . message || '' ) . toLowerCase ( )
22+
23+ if (
24+ normalizedCode === 'registration-token-not-registered' ||
25+ normalizedCode === 'unregistered' ||
26+ normalizedCode === 'invalid-registration-token'
27+ ) {
28+ return true
29+ }
30+
31+ if (
32+ normalizedMessage . includes ( 'requested entity was not found' ) ||
33+ normalizedMessage . includes ( 'not registered' ) ||
34+ normalizedMessage . includes ( 'registration token is not a valid' )
35+ ) {
36+ return true
37+ }
38+
39+ return false
40+ }
41+
1142@Injectable ( )
1243export class HeartbeatCheckTask {
1344 private readonly logger = new Logger ( HeartbeatCheckTask . name )
@@ -17,10 +48,10 @@ export class HeartbeatCheckTask {
1748 ) { }
1849
1950 /**
20- * Cron job that runs every 5 minutes to check for devices with stale heartbeats
51+ * Cron job that runs hourly to check for devices with stale heartbeats
2152 * (>30 minutes) and send FCM push notifications to trigger heartbeat requests.
2253 */
23- @Cron ( CronExpression . EVERY_5_MINUTES )
54+ @Cron ( CronExpression . EVERY_HOUR )
2455 async checkAndTriggerStaleHeartbeats ( ) {
2556 this . logger . log ( 'Running cron job to check for stale heartbeats' )
2657
@@ -36,6 +67,7 @@ export class HeartbeatCheckTask {
3667 { lastHeartbeat : { $lt : thirtyMinutesAgo } } ,
3768 ] ,
3869 fcmToken : { $exists : true , $ne : null } ,
70+ fcmTokenInvalidatedAt : { $exists : false } ,
3971 } )
4072
4173 if ( devices . length === 0 ) {
@@ -88,13 +120,49 @@ export class HeartbeatCheckTask {
88120 totalFailureCount += response . failureCount
89121
90122 if ( response . failureCount > 0 ) {
123+ const invalidationUpdates : Array < {
124+ deviceId : string
125+ reason : string
126+ } > = [ ]
127+
91128 response . responses . forEach ( ( resp , index ) => {
92129 if ( ! resp . success ) {
130+ const errorMessage = resp . error ?. message || 'Unknown error'
131+ const errorCode = resp . error ?. code || 'UNKNOWN_ERROR'
132+
93133 this . logger . error (
94- `Failed to send heartbeat check to device ${ batchDeviceIds [ index ] } : ${ resp . error ?. message || 'Unknown error' } ` ,
134+ `Failed to send heartbeat check to device ${ batchDeviceIds [ index ] } : ${ errorMessage } ` ,
95135 )
136+
137+ if ( isPermanentFcmTokenError ( resp . error ) ) {
138+ invalidationUpdates . push ( {
139+ deviceId : batchDeviceIds [ index ] ,
140+ reason : `${ errorCode } : ${ errorMessage } ` ,
141+ } )
142+ }
96143 }
97144 } )
145+
146+ if ( invalidationUpdates . length > 0 ) {
147+ const invalidatedAt = new Date ( )
148+ await this . deviceModel . bulkWrite (
149+ invalidationUpdates . map ( ( { deviceId, reason } ) => ( {
150+ updateOne : {
151+ filter : { _id : new Types . ObjectId ( deviceId ) } ,
152+ update : {
153+ $set : {
154+ fcmTokenInvalidatedAt : invalidatedAt ,
155+ fcmTokenInvalidReason : reason ,
156+ } ,
157+ } ,
158+ } ,
159+ } ) ) ,
160+ )
161+
162+ this . logger . warn (
163+ `Marked ${ invalidationUpdates . length } device(s) as FCM-token-invalid; heartbeat retries paused until token update` ,
164+ )
165+ }
98166 }
99167 }
100168
0 commit comments