From 19feede589217cf7630dab9eaece42bca7e5d3eb Mon Sep 17 00:00:00 2001 From: Fred Shema <51389166+fredshema@users.noreply.github.com> Date: Wed, 27 Nov 2024 13:53:17 +0200 Subject: [PATCH] feat: mobile in-app push notification (#419) --- package-lock.json | 46 +++++++ package.json | 1 + src/models/user.ts | 4 + src/resolvers/userResolver.ts | 59 +++++++-- src/schema/index.ts | 3 + src/utils/notification/pushNotification.ts | 132 ++++++++++++++++++++- 6 files changed, 233 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9eb28cd6..0d27ac65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "date-fns": "^2.29.2", "dotenv": "^16.0.1", "ejs": "^3.1.8", + "expo-server-sdk": "^3.11.0", "express": "^4.18.1", "generate-password": "^1.7.0", "graphql": "^16.5.0", @@ -5046,6 +5047,12 @@ "node": ">=8.6" } }, + "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/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -5372,6 +5379,17 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expo-server-sdk": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.11.0.tgz", + "integrity": "sha512-EGH82ZcdAFjKq+6daDE8Xj7BjaSeP1VDvZ3Hmtn/KzEQ3ffqHkauMsgXL2wLEPlvatLq3EsYNcejXRBV54WnFQ==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.0", + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1" + } + }, "node_modules/express": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", @@ -8117,6 +8135,34 @@ "node": ">=0.4.0" } }, + "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/promise-retry/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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 62e6384d..da689594 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "date-fns": "^2.29.2", "dotenv": "^16.0.1", "ejs": "^3.1.8", + "expo-server-sdk": "^3.11.0", "express": "^4.18.1", "generate-password": "^1.7.0", "graphql": "^16.5.0", diff --git a/src/models/user.ts b/src/models/user.ts index 0c418a1a..1f8dda9e 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -113,6 +113,10 @@ const userSchema = new Schema( required: false }, + pushNotificationTokens: { + type: [String], + default: [], + }, }, { diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 694250aa..7d5e13dd 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -3,11 +3,10 @@ import { Octokit } from '@octokit/rest' import bcrypt from 'bcryptjs' import { GraphQLError } from 'graphql' // import * as jwt from 'jsonwebtoken' -import { JwtPayload, verify } from 'jsonwebtoken' +import Expo from 'expo-server-sdk' +import jwt, { JwtPayload, verify } from 'jsonwebtoken' import mongoose, { Error } from 'mongoose' import generateRandomPassword from '../helpers/generateRandomPassword' -import isAssigned from '../helpers/isAssignedToProgramOrCohort' -import { checkloginAttepmts } from '../helpers/logintracker' import { checkLoggedInOrganization } from '../helpers/organization.helper' import { checkUserLoggedIn, @@ -26,19 +25,17 @@ import Program from '../models/program.model' import { Rating } from '../models/ratings' import Team from '../models/team.model' import { RoleOfUser, User, UserRole } from '../models/user' +import { encodeOtpToToken, generateOtp } from '../utils/2WayAuthentication' import { pushNotification } from '../utils/notification/pushNotification' import { sendEmail } from '../utils/sendEmail' import forgotPasswordTemplate from '../utils/templates/forgotPasswordTemplate' +import nonTraineeTemplate from '../utils/templates/nonTraineeTemplate' import organizationApprovedTemplate from '../utils/templates/organizationApprovedTemplate' import organizationCreatedTemplate from '../utils/templates/organizationCreatedTemplate' import organizationRejectedTemplate from '../utils/templates/organizationRejectedTemplate' import registrationRequest from '../utils/templates/registrationRequestTemplate' import { EmailPattern } from '../utils/validation.utils' import { Context } from './../context' -import { UserInputError } from 'apollo-server' -import { encodeOtpToToken, generateOtp } from '../utils/2WayAuthentication' -import jwt from 'jsonwebtoken' -import nonTraineeTemplate from '../utils/templates/nonTraineeTemplate' const octokit = new Octokit({ auth: `${process.env.Org_Repo_Access}` }) const SECRET = (process.env.SECRET as string) || 'mysq_unique_secret' @@ -1110,10 +1107,56 @@ const resolvers: any = { } user.pushNotifications = !user.pushNotifications await user.save() - const updatedPushNotifications = user.pushNotifications return 'updated successful' }, + async addPushNotificationToken( + _: any, + { pushToken }: { pushToken: string }, + context: Context + ) { + if (!Expo.isExpoPushToken(pushToken)) { + throw new Error('Invalid push notification') + } + + const user: any = await User.findOne({ _id: context.userId }) + if (!user) { + throw new Error('User not found') + } + + if (!user.pushNotificationTokens.includes(pushToken)) { + user.pushNotificationTokens.push(pushToken) + await user.save() + return 'Notification token added successfully' + } + + return 'Notification token already added' + }, + + async removePushNotificationToken( + _: any, + { pushToken }: { pushToken: string }, + context: Context + ) { + if (!Expo.isExpoPushToken(pushToken)) { + throw new Error('Invalid push notification') + } + + const user: any = await User.findOne({ _id: context.userId }) + if (!user) { + throw new Error('User not found') + } + + const index = user.pushNotificationTokens.indexOf(pushToken) + if (index !== -1) { + user.pushNotificationTokens.splice(index, 1) + await user.save() + return 'Notification token removed successfully' + } + + return 'Notification token not found' + }, + async resetUserPassword( _: any, { password, confirmPassword, token }: any, diff --git a/src/schema/index.ts b/src/schema/index.ts index be7dab2c..d2b7487d 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -84,6 +84,7 @@ const Schema = gql` TwoWayVerificationToken: String createdAt: String updatedAt: String + pushNotificationTokens: [String] } input RegisterInput { email: String! @@ -586,6 +587,8 @@ const Schema = gql` type Mutation { updatePushNotifications(id: ID!): String updateEmailNotifications(id: ID!): String + addPushNotificationToken(pushToken: String): String + removePushNotificationToken(pushToken: String): String } type Query { getUpdatedEmailNotifications(id: ID!): Boolean! diff --git a/src/utils/notification/pushNotification.ts b/src/utils/notification/pushNotification.ts index 5b9ae24a..ebd59de0 100644 --- a/src/utils/notification/pushNotification.ts +++ b/src/utils/notification/pushNotification.ts @@ -1,8 +1,27 @@ +import { + Expo, + ExpoPushErrorReceipt, + ExpoPushMessage, + ExpoPushReceipt, + ExpoPushTicket, +} from 'expo-server-sdk' import mongoose from 'mongoose' import { Notification } from '../../models/notification.model' -import { pubSubPublish } from '../../resolvers/notification.resolvers' -import { User } from '../../models/user' import { Profile } from '../../models/profile.model' +import { User } from '../../models/user' +import { pubSubPublish } from '../../resolvers/notification.resolvers' + +export type NotificationData = { + redirectURI?: string + criteria: { + type: 'PUBLIC' | 'PERSONAL' | 'TEAM' | 'ORGANIZATION' + value: string + } +} + +let expo = new Expo({ + accessToken: process.env.EXPO_ACCESS_TOKEN, +}) export const pushNotification = async ( receiver: mongoose.Types.ObjectId, @@ -29,8 +48,113 @@ export const pushNotification = async ( id: notification.id, sender: { profile: profile?.toObject() }, } - const userExists = await User.findOne({ _id: receiver }) - if (userExists && userExists.pushNotifications) { + const receivingUser = await User.findOne({ _id: receiver }) + if (receivingUser && receivingUser.pushNotifications) { pubSubPublish(sanitizedNotification) + + if (receivingUser.pushNotificationTokens.length > 0) { + const notificationData: NotificationData = { + redirectURI: `/dashboard/trainee${ + notification.type ? '/' + notification.type : '' + }`, + criteria: { type: 'PERSONAL', value: receivingUser.id }, + } + + console.log('Sending push notifications') + + sendPushNotifications( + receivingUser.pushNotificationTokens, + `${capitalizeString(notification.type)} notification`, + notification.message, + notificationData + ) + } + } +} + +const capitalizeString = (str?: string) => { + str = str || 'New' + return str.charAt(0).toUpperCase() + str.slice(1) +} + +const sendPushNotifications = async ( + pushTokens: string[], + title: string, + body: string, + data: NotificationData +) => { + // Create the messages that you want to send to clients + let messages: ExpoPushMessage[] = [] + for (let pushToken of pushTokens) { + // Each push token looks like ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx] + // Check that all your push tokens appear to be valid Expo push tokens + if (!Expo.isExpoPushToken(pushToken)) { + console.error(`Push token ${pushToken} is not a valid Expo push token`) + continue + } + + messages.push({ + to: pushToken, + title: title, + body: body, + data: data, + }) } + + // The Expo push notification service accepts batches of notifications so + // that you don't need to send 1000 requests to send 1000 notifications. + let chunks = expo.chunkPushNotifications(messages) + let tickets: ExpoPushTicket[] = [] + ;(async () => { + for (let chunk of chunks) { + try { + let ticketChunk = await expo.sendPushNotificationsAsync(chunk) + console.log(ticketChunk) + tickets.push(...ticketChunk) + } catch (error) { + console.error(error) + } + } + })() + + let receiptIds = [] + for (let ticket of tickets) { + // NOTE: Not all tickets have IDs; for example, tickets for notifications + // that could not be enqueued will have error information and no receipt ID. + if (ticket.status === 'ok') { + receiptIds.push(ticket.id) + } + + if (ticket.status === 'error' && ticket.details) { + console.error(ticket.details.error) + } + } + + let receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds) + ;(async () => { + // Like sending notifications, there are different strategies you could use + // to retrieve batches of receipts from the Expo service. + for (let chunk of receiptIdChunks) { + try { + let receipts = await expo.getPushNotificationReceiptsAsync(chunk) + console.log(receipts) + + // The receipts specify whether Apple or Google successfully received the + // notification and information about an error, if one occurred. + for (let receiptId in receipts) { + let { status, details }: ExpoPushReceipt | ExpoPushErrorReceipt = + receipts[receiptId] + if (status === 'ok') { + continue + } else if (status === 'error') { + console.error( + `There was an error sending a notification: ${details}` + ) + } + } + } catch (error) { + console.error(error) + } + } + })() }