11import { Repository } from "typeorm" ;
22import { Verification } from "../entities/Verification" ;
33import { Notification } from "../entities/Notification" ;
4+ import { DeviceToken } from "../entities/DeviceToken" ;
45
56export interface DeviceRegistration {
67 eName : string ;
@@ -22,10 +23,24 @@ export interface SendNotificationRequest {
2223 sharedSecret : string ;
2324}
2425
26+ const BAD_TOKEN_ERRORS = [
27+ "messaging/registration-token-not-valid" ,
28+ "messaging/invalid-registration-token" ,
29+ "BadDeviceToken" ,
30+ "Unregistered" ,
31+ "DeviceTokenNotForTopic" ,
32+ ] ;
33+
34+ function isBadTokenError ( error : unknown ) : boolean {
35+ const msg = error instanceof Error ? error . message : String ( error ) ;
36+ return BAD_TOKEN_ERRORS . some ( ( e ) => msg . includes ( e ) ) ;
37+ }
38+
2539export class NotificationService {
2640 constructor (
2741 private verificationRepository : Repository < Verification > ,
28- private notificationRepository : Repository < Notification >
42+ private notificationRepository : Repository < Notification > ,
43+ private deviceTokenRepository ?: Repository < DeviceToken > ,
2944 ) { }
3045
3146 async registerDevice ( registration : DeviceRegistration ) : Promise < Verification > {
@@ -56,10 +71,10 @@ export class NotificationService {
5671 if ( verification ) {
5772 verification . platform = registration . platform ;
5873 if ( token ) {
59- const existing = verification . pushTokens ?? [ ] ;
60- if ( ! existing . includes ( token ) ) {
61- verification . pushTokens = [ ... existing , token ] ;
62- }
74+ // Replace all tokens for this device — the latest token from the
75+ // OS is the only valid one. Appending caused stale tokens to
76+ // accumulate and never get cleaned up.
77+ verification . pushTokens = [ token ] ;
6378 }
6479 verification . deviceActive = true ;
6580 verification . updatedAt = new Date ( ) ;
@@ -118,6 +133,7 @@ export class NotificationService {
118133
119134 // Send actual push notification via notification-trigger service
120135 const triggerUrl = process . env . NOTIFICATION_TRIGGER_URL || `http://localhost:${ process . env . NOTIFICATION_TRIGGER_PORT || 3998 } ` ;
136+ console . log ( `[NOTIF] Using trigger URL: ${ triggerUrl } ` ) ;
121137 const pushPayload = {
122138 title : notification . title ,
123139 body : notification . body ,
@@ -152,8 +168,13 @@ export class NotificationService {
152168
153169 console . log ( `[NOTIF] Sending push to ${ allTokens . length } token(s) for eName: ${ eName } ` ) ;
154170
155- const pushResults = await Promise . allSettled (
156- allTokens . map ( async ( { token, platform } ) => {
171+ // Cycle through tokens sequentially: try each one, remove bad tokens
172+ // inline, and keep going until at least one succeeds.
173+ const badTokens : string [ ] = [ ] ;
174+ let delivered = false ;
175+
176+ for ( const { token, platform } of allTokens ) {
177+ try {
157178 const res = await fetch ( `${ triggerUrl } /api/send` , {
158179 method : "POST" ,
159180 headers : { "Content-Type" : "application/json" } ,
@@ -165,27 +186,106 @@ export class NotificationService {
165186 signal : AbortSignal . timeout ( 10000 ) ,
166187 } ) ;
167188 const data = await res . json ( ) ;
168- if ( ! data . success ) {
169- throw new Error ( data . error || "Push send failed" ) ;
189+
190+ if ( data . success ) {
191+ console . log ( `[NOTIF] Push delivered via token ${ token . slice ( 0 , 8 ) } … for ${ eName } ` ) ;
192+ delivered = true ;
193+ // Keep sending to remaining tokens — user may have multiple
194+ // devices (phone + tablet) that should all receive the notif.
195+ continue ;
170196 }
171- return data ;
172- } )
173- ) ;
174-
175- const pushSucceeded = pushResults . filter ( r => r . status === "fulfilled" ) . length ;
176- const pushFailed = pushResults . filter ( r => r . status === "rejected" ) . length ;
177- if ( pushFailed > 0 ) {
178- console . log ( `[NOTIF] Push results for ${ eName } : ${ pushSucceeded } sent, ${ pushFailed } failed` ) ;
179- pushResults . forEach ( ( r , i ) => {
180- if ( r . status === "rejected" ) {
181- console . error ( `[NOTIF] Push failed for token index ${ i } :` , r . reason ) ;
197+
198+ // Send returned an explicit failure
199+ const error = data . error || "Push send failed" ;
200+ console . error (
201+ `[NOTIF] Push rejected for token ${ token . slice ( 0 , 8 ) } …\n` +
202+ ` platform : ${ platform ?? "auto-detect" } \n` +
203+ ` error : ${ error } ` ,
204+ ) ;
205+
206+ if ( isBadTokenError ( error ) ) {
207+ badTokens . push ( token ) ;
208+ console . log ( `[NOTIF] Bad token ${ token . slice ( 0 , 8 ) } … queued for removal, trying next…` ) ;
182209 }
183- } ) ;
210+ } catch ( err ) {
211+ const msg = err instanceof Error ? err . message : String ( err ) ;
212+ const errObj = err as Record < string , unknown > ;
213+ const rawCause = errObj ?. cause ;
214+ const cause = rawCause
215+ ? rawCause instanceof Error ? rawCause . message : String ( rawCause )
216+ : null ;
217+ console . error (
218+ `[NOTIF] Push error for token ${ token . slice ( 0 , 8 ) } …\n` +
219+ ` platform : ${ platform ?? "auto-detect" } \n` +
220+ ` url : ${ triggerUrl } /api/send\n` +
221+ ` error : ${ msg } \n` +
222+ ( cause ? ` cause : ${ cause } \n` : "" ) +
223+ ` full :` , err ,
224+ ) ;
225+
226+ if ( msg . includes ( "fetch failed" ) || msg . includes ( "ECONNREFUSED" ) ) {
227+ console . error ( `[NOTIF] notification-trigger service appears to be DOWN at ${ triggerUrl } — skipping remaining tokens` ) ;
228+ break ;
229+ }
230+
231+ if ( isBadTokenError ( err ) ) {
232+ badTokens . push ( token ) ;
233+ console . log ( `[NOTIF] Bad token ${ token . slice ( 0 , 8 ) } … queued for removal, trying next…` ) ;
234+ }
235+ }
236+ }
237+
238+ // Purge bad tokens from both Verification and DeviceToken tables
239+ if ( badTokens . length > 0 ) {
240+ console . log ( `[NOTIF] Removing ${ badTokens . length } bad token(s) for ${ eName } ` ) ;
241+ await this . removeBadTokens ( eName , badTokens ) ;
242+ }
243+
244+ if ( delivered ) {
245+ console . log ( `[NOTIF] Push delivered for ${ eName } ` ) ;
184246 } else {
185- console . log ( `[NOTIF] Push sent successfully to ${ pushSucceeded } token(s) for ${ eName } ` ) ;
247+ console . log ( `[NOTIF] Push failed for all ${ allTokens . length } token(s) for ${ eName } ` ) ;
186248 }
187249
188- return pushSucceeded > 0 || pushFailed === 0 ;
250+ return delivered ;
251+ }
252+
253+ private async removeBadTokens ( eName : string , badTokens : string [ ] ) : Promise < void > {
254+ try {
255+ // Clean Verification table
256+ const verifications = await this . verificationRepository . find ( {
257+ where : { linkedEName : eName } ,
258+ } ) ;
259+ for ( const v of verifications ) {
260+ const before = v . pushTokens ?. length ?? 0 ;
261+ v . pushTokens = ( v . pushTokens ?? [ ] ) . filter ( ( t ) => ! badTokens . includes ( t ) ) ;
262+ if ( v . pushTokens . length !== before ) {
263+ v . updatedAt = new Date ( ) ;
264+ await this . verificationRepository . save ( v ) ;
265+ }
266+ }
267+
268+ // Clean DeviceToken table
269+ if ( this . deviceTokenRepository ) {
270+ const normalized = eName . startsWith ( "@" ) ? eName : `@${ eName } ` ;
271+ const withoutAt = eName . replace ( / ^ @ / , "" ) ;
272+ const rows = await this . deviceTokenRepository
273+ . createQueryBuilder ( "dt" )
274+ . where ( "dt.eName = :e1 OR dt.eName = :e2" , { e1 : normalized , e2 : withoutAt } )
275+ . getMany ( ) ;
276+
277+ for ( const row of rows ) {
278+ const before = row . tokens . length ;
279+ row . tokens = row . tokens . filter ( ( t ) => ! badTokens . includes ( t ) ) ;
280+ if ( row . tokens . length !== before ) {
281+ row . updatedAt = new Date ( ) ;
282+ await this . deviceTokenRepository . save ( row ) ;
283+ }
284+ }
285+ }
286+ } catch ( err ) {
287+ console . error ( `[NOTIF] Failed to remove bad tokens for ${ eName } :` , err ) ;
288+ }
189289 }
190290
191291 async getUndeliveredNotifications ( eName : string ) : Promise < Notification [ ] > {
0 commit comments