refactor: enhance push notification handling with PushNotificationService integration

This commit is contained in:
Nawaz Dhandala
2026-02-18 10:31:12 +00:00
parent 2b313a7702
commit e92e9f08d3
4 changed files with 126 additions and 39 deletions

View File

@@ -7,9 +7,7 @@ import Express, {
import Response from "Common/Server/Utils/Response";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
import { Expo, ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";
import { ExpoAccessToken } from "Common/Server/EnvironmentConfig";
import logger from "Common/Server/Utils/Logger";
import PushNotificationService from "Common/Server/Services/PushNotificationService";
const router: ExpressRouter = Express.getRouter();
@@ -44,11 +42,6 @@ setInterval(() => {
}
}, 5 * 60 * 1000);
// Expo client for sending push notifications (only available when EXPO_ACCESS_TOKEN is set)
const expoClient: Expo | null = ExpoAccessToken
? new Expo({ accessToken: ExpoAccessToken })
: null;
router.post(
"/send",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
@@ -65,7 +58,7 @@ router.post(
return;
}
if (!expoClient) {
if (!PushNotificationService.hasExpoAccessToken()) {
throw new BadDataException(
"Push relay is not configured. EXPO_ACCESS_TOKEN is not set on this server.",
);
@@ -75,7 +68,7 @@ router.post(
const to: string | undefined = body["to"] as string | undefined;
if (!to || !Expo.isExpoPushToken(to)) {
if (!to || !PushNotificationService.isValidExpoPushToken(to)) {
throw new BadDataException(
"Invalid or missing push token. Must be a valid Expo push token.",
);
@@ -92,40 +85,15 @@ router.post(
);
}
const expoPushMessage: ExpoPushMessage = {
await PushNotificationService.sendRelayPushNotification({
to: to,
title: title,
body: messageBody,
data: (body["data"] as { [key: string]: string }) || {},
sound: (body["sound"] as "default" | null) || "default",
priority: (body["priority"] as "default" | "normal" | "high") || "high",
sound: (body["sound"] as string) || "default",
priority: (body["priority"] as string) || "high",
channelId: (body["channelId"] as string) || "default",
};
const tickets: ExpoPushTicket[] =
await expoClient.sendPushNotificationsAsync([expoPushMessage]);
const ticket: ExpoPushTicket | undefined = tickets[0];
if (ticket && ticket.status === "error") {
const errorTicket: ExpoPushTicket & {
message?: string;
details?: { error?: string };
} = ticket as ExpoPushTicket & {
message?: string;
details?: { error?: string };
};
logger.error(
`Push relay: Expo push notification error: ${errorTicket.message}`,
);
throw new BadDataException(
`Failed to send push notification: ${errorTicket.message}`,
);
}
logger.info(`Push relay: notification sent successfully to ${to}`);
});
return Response.sendJsonObjectResponse(req, res, { success: true });
} catch (err) {

58
App/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@sendgrid/mail": "^8.1.0",
"Common": "file:../Common",
"ejs": "^3.1.9",
"expo-server-sdk": "^5.0.0",
"handlebars": "^4.7.8",
"nodemailer": "^6.9.7",
"ts-node": "^10.9.1",
@@ -2166,6 +2167,12 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"node_modules/err-code": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
"integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
"license": "MIT"
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -2299,6 +2306,20 @@
"node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
}
},
"node_modules/expo-server-sdk": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-5.0.0.tgz",
"integrity": "sha512-GEp1XYLU80iS/hdRo3c2n092E8TgTXcHSuw6Lw68dSoWaAgiLPI2R+e5hp5+hGF1TtJZOi2nxtJX63+XA3iz9g==",
"license": "MIT",
"dependencies": {
"promise-limit": "^2.7.0",
"promise-retry": "^2.0.1",
"undici": "^7.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4167,6 +4188,25 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/promise-limit": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
"integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
"license": "ISC"
},
"node_modules/promise-retry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
"integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
"license": "MIT",
"dependencies": {
"err-code": "^2.0.2",
"retry": "^0.12.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -4290,6 +4330,15 @@
"node": ">=10"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -4801,6 +4850,15 @@
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/update-browserslist-db": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",

View File

@@ -23,6 +23,7 @@
"@sendgrid/mail": "^8.1.0",
"Common": "file:../Common",
"ejs": "^3.1.9",
"expo-server-sdk": "^5.0.0",
"handlebars": "^4.7.8",
"nodemailer": "^6.9.7",
"ts-node": "^10.9.1",

View File

@@ -468,6 +468,66 @@ export default class PushNotificationService {
}
}
public static isValidExpoPushToken(token: string): boolean {
return Expo.isExpoPushToken(token);
}
public static hasExpoAccessToken(): boolean {
return Boolean(ExpoAccessToken);
}
public static async sendRelayPushNotification(data: {
to: string;
title?: string;
body?: string;
data?: { [key: string]: string };
sound?: string;
priority?: string;
channelId?: string;
}): Promise<void> {
if (!ExpoAccessToken) {
throw new Error(
"Push relay is not configured. EXPO_ACCESS_TOKEN is not set on this server.",
);
}
const expoPushMessage: ExpoPushMessage = {
to: data.to,
title: data.title,
body: data.body,
data: data.data || {},
sound: (data.sound as "default" | null) || "default",
priority:
(data.priority as "default" | "normal" | "high") || "high",
channelId: data.channelId || "default",
};
const tickets: ExpoPushTicket[] =
await this.expoClient.sendPushNotificationsAsync([expoPushMessage]);
const ticket: ExpoPushTicket | undefined = tickets[0];
if (ticket && ticket.status === "error") {
const errorTicket: ExpoPushTicket & {
message?: string;
details?: { error?: string };
} = ticket as ExpoPushTicket & {
message?: string;
details?: { error?: string };
};
logger.error(
`Push relay: Expo push notification error: ${errorTicket.message}`,
);
throw new Error(
`Failed to send push notification: ${errorTicket.message}`,
);
}
logger.info(`Push relay: notification sent successfully to ${data.to}`);
}
public static async sendPushNotificationToUser(
userId: ObjectID,
projectId: ObjectID,