Skip to content

Commit 7326bd7

Browse files
authored
Merge pull request #205 from vernu/dev
invalidate stale fcm tokens
2 parents ef4e42a + 01af5a9 commit 7326bd7

File tree

3 files changed

+100
-6
lines changed

3 files changed

+100
-6
lines changed

api/src/gateway/gateway.service.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class GatewayService {
4646
buildId: input.buildId,
4747
})
4848

49+
const now = new Date()
4950
const deviceData: any = { ...input, user }
5051

5152
// Set default name to "brand model" if not provided
@@ -57,10 +58,16 @@ export class GatewayService {
5758
if (input.simInfo) {
5859
deviceData.simInfo = {
5960
...input.simInfo,
60-
lastUpdated: input.simInfo.lastUpdated || new Date(),
61+
lastUpdated: input.simInfo.lastUpdated || now,
6162
}
6263
}
6364

65+
if (input.fcmToken) {
66+
deviceData.fcmTokenUpdatedAt = now
67+
deviceData.fcmTokenInvalidatedAt = undefined
68+
deviceData.fcmTokenInvalidReason = undefined
69+
}
70+
6471
if (device && device.appVersionCode <= 11) {
6572
return await this.updateDevice(device._id.toString(), {
6673
...deviceData,
@@ -98,15 +105,22 @@ export class GatewayService {
98105
input.enabled = true;
99106
}
100107

108+
const now = new Date()
101109
const updateData: any = { ...input }
102110

103111
// Handle simInfo if provided
104112
if (input.simInfo) {
105113
updateData.simInfo = {
106114
...input.simInfo,
107-
lastUpdated: input.simInfo.lastUpdated || new Date(),
115+
lastUpdated: input.simInfo.lastUpdated || now,
108116
}
109117
}
118+
119+
if (input.fcmToken && input.fcmToken !== device.fcmToken) {
120+
updateData.fcmTokenUpdatedAt = now
121+
updateData.fcmTokenInvalidatedAt = undefined
122+
updateData.fcmTokenInvalidReason = undefined
123+
}
110124

111125
return await this.deviceModel.findByIdAndUpdate(
112126
deviceId,
@@ -1078,6 +1092,9 @@ const updatedSms = await this.smsModel.findByIdAndUpdate(
10781092
// Update FCM token if provided and different
10791093
if (input.fcmToken && input.fcmToken !== device.fcmToken) {
10801094
updateData.fcmToken = input.fcmToken
1095+
updateData.fcmTokenUpdatedAt = now
1096+
updateData.fcmTokenInvalidatedAt = undefined
1097+
updateData.fcmTokenInvalidReason = undefined
10811098
fcmTokenUpdated = true
10821099
}
10831100

api/src/gateway/schemas/device.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export class Device {
2020
@Prop({ type: String })
2121
fcmToken: string
2222

23+
@Prop({ type: Date })
24+
fcmTokenUpdatedAt?: Date
25+
26+
@Prop({ type: Date })
27+
fcmTokenInvalidatedAt?: Date
28+
29+
@Prop({ type: String })
30+
fcmTokenInvalidReason?: string
31+
2332
@Prop({ type: String })
2433
brand: string
2534

api/src/gateway/tasks/heartbeat-check.task.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
11
import { Injectable, Logger } from '@nestjs/common'
22
import { Cron, CronExpression } from '@nestjs/schedule'
33
import { InjectModel } from '@nestjs/mongoose'
4-
import { Model } from 'mongoose'
4+
import { Model, Types } from 'mongoose'
55
import { Device, DeviceDocument } from '../schemas/device.schema'
66
import * as firebaseAdmin from 'firebase-admin'
77
import { Message } from 'firebase-admin/messaging'
88

99
const 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(/^messaging\//, '')
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()
1243
export 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

Comments
 (0)