Skip to content

Commit 19feede

Browse files
authored
feat: mobile in-app push notification (#419)
1 parent 39437f1 commit 19feede

File tree

6 files changed

+233
-12
lines changed

6 files changed

+233
-12
lines changed

package-lock.json

Lines changed: 46 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"date-fns": "^2.29.2",
7171
"dotenv": "^16.0.1",
7272
"ejs": "^3.1.8",
73+
"expo-server-sdk": "^3.11.0",
7374
"express": "^4.18.1",
7475
"generate-password": "^1.7.0",
7576
"graphql": "^16.5.0",

src/models/user.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ const userSchema = new Schema(
113113
required: false
114114
},
115115

116+
pushNotificationTokens: {
117+
type: [String],
118+
default: [],
119+
},
116120
},
117121

118122
{

src/resolvers/userResolver.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { Octokit } from '@octokit/rest'
33
import bcrypt from 'bcryptjs'
44
import { GraphQLError } from 'graphql'
55
// import * as jwt from 'jsonwebtoken'
6-
import { JwtPayload, verify } from 'jsonwebtoken'
6+
import Expo from 'expo-server-sdk'
7+
import jwt, { JwtPayload, verify } from 'jsonwebtoken'
78
import mongoose, { Error } from 'mongoose'
89
import generateRandomPassword from '../helpers/generateRandomPassword'
9-
import isAssigned from '../helpers/isAssignedToProgramOrCohort'
10-
import { checkloginAttepmts } from '../helpers/logintracker'
1110
import { checkLoggedInOrganization } from '../helpers/organization.helper'
1211
import {
1312
checkUserLoggedIn,
@@ -26,19 +25,17 @@ import Program from '../models/program.model'
2625
import { Rating } from '../models/ratings'
2726
import Team from '../models/team.model'
2827
import { RoleOfUser, User, UserRole } from '../models/user'
28+
import { encodeOtpToToken, generateOtp } from '../utils/2WayAuthentication'
2929
import { pushNotification } from '../utils/notification/pushNotification'
3030
import { sendEmail } from '../utils/sendEmail'
3131
import forgotPasswordTemplate from '../utils/templates/forgotPasswordTemplate'
32+
import nonTraineeTemplate from '../utils/templates/nonTraineeTemplate'
3233
import organizationApprovedTemplate from '../utils/templates/organizationApprovedTemplate'
3334
import organizationCreatedTemplate from '../utils/templates/organizationCreatedTemplate'
3435
import organizationRejectedTemplate from '../utils/templates/organizationRejectedTemplate'
3536
import registrationRequest from '../utils/templates/registrationRequestTemplate'
3637
import { EmailPattern } from '../utils/validation.utils'
3738
import { Context } from './../context'
38-
import { UserInputError } from 'apollo-server'
39-
import { encodeOtpToToken, generateOtp } from '../utils/2WayAuthentication'
40-
import jwt from 'jsonwebtoken'
41-
import nonTraineeTemplate from '../utils/templates/nonTraineeTemplate'
4239
const octokit = new Octokit({ auth: `${process.env.Org_Repo_Access}` })
4340

4441
const SECRET = (process.env.SECRET as string) || 'mysq_unique_secret'
@@ -1110,10 +1107,56 @@ const resolvers: any = {
11101107
}
11111108
user.pushNotifications = !user.pushNotifications
11121109
await user.save()
1113-
const updatedPushNotifications = user.pushNotifications
11141110
return 'updated successful'
11151111
},
11161112

1113+
async addPushNotificationToken(
1114+
_: any,
1115+
{ pushToken }: { pushToken: string },
1116+
context: Context
1117+
) {
1118+
if (!Expo.isExpoPushToken(pushToken)) {
1119+
throw new Error('Invalid push notification')
1120+
}
1121+
1122+
const user: any = await User.findOne({ _id: context.userId })
1123+
if (!user) {
1124+
throw new Error('User not found')
1125+
}
1126+
1127+
if (!user.pushNotificationTokens.includes(pushToken)) {
1128+
user.pushNotificationTokens.push(pushToken)
1129+
await user.save()
1130+
return 'Notification token added successfully'
1131+
}
1132+
1133+
return 'Notification token already added'
1134+
},
1135+
1136+
async removePushNotificationToken(
1137+
_: any,
1138+
{ pushToken }: { pushToken: string },
1139+
context: Context
1140+
) {
1141+
if (!Expo.isExpoPushToken(pushToken)) {
1142+
throw new Error('Invalid push notification')
1143+
}
1144+
1145+
const user: any = await User.findOne({ _id: context.userId })
1146+
if (!user) {
1147+
throw new Error('User not found')
1148+
}
1149+
1150+
const index = user.pushNotificationTokens.indexOf(pushToken)
1151+
if (index !== -1) {
1152+
user.pushNotificationTokens.splice(index, 1)
1153+
await user.save()
1154+
return 'Notification token removed successfully'
1155+
}
1156+
1157+
return 'Notification token not found'
1158+
},
1159+
11171160
async resetUserPassword(
11181161
_: any,
11191162
{ password, confirmPassword, token }: any,

src/schema/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const Schema = gql`
8484
TwoWayVerificationToken: String
8585
createdAt: String
8686
updatedAt: String
87+
pushNotificationTokens: [String]
8788
}
8889
input RegisterInput {
8990
email: String!
@@ -586,6 +587,8 @@ const Schema = gql`
586587
type Mutation {
587588
updatePushNotifications(id: ID!): String
588589
updateEmailNotifications(id: ID!): String
590+
addPushNotificationToken(pushToken: String): String
591+
removePushNotificationToken(pushToken: String): String
589592
}
590593
type Query {
591594
getUpdatedEmailNotifications(id: ID!): Boolean!

src/utils/notification/pushNotification.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1+
import {
2+
Expo,
3+
ExpoPushErrorReceipt,
4+
ExpoPushMessage,
5+
ExpoPushReceipt,
6+
ExpoPushTicket,
7+
} from 'expo-server-sdk'
18
import mongoose from 'mongoose'
29
import { Notification } from '../../models/notification.model'
3-
import { pubSubPublish } from '../../resolvers/notification.resolvers'
4-
import { User } from '../../models/user'
510
import { Profile } from '../../models/profile.model'
11+
import { User } from '../../models/user'
12+
import { pubSubPublish } from '../../resolvers/notification.resolvers'
13+
14+
export type NotificationData = {
15+
redirectURI?: string
16+
criteria: {
17+
type: 'PUBLIC' | 'PERSONAL' | 'TEAM' | 'ORGANIZATION'
18+
value: string
19+
}
20+
}
21+
22+
let expo = new Expo({
23+
accessToken: process.env.EXPO_ACCESS_TOKEN,
24+
})
625

726
export const pushNotification = async (
827
receiver: mongoose.Types.ObjectId,
@@ -29,8 +48,113 @@ export const pushNotification = async (
2948
id: notification.id,
3049
sender: { profile: profile?.toObject() },
3150
}
32-
const userExists = await User.findOne({ _id: receiver })
33-
if (userExists && userExists.pushNotifications) {
51+
const receivingUser = await User.findOne({ _id: receiver })
52+
if (receivingUser && receivingUser.pushNotifications) {
3453
pubSubPublish(sanitizedNotification)
54+
55+
if (receivingUser.pushNotificationTokens.length > 0) {
56+
const notificationData: NotificationData = {
57+
redirectURI: `/dashboard/trainee${
58+
notification.type ? '/' + notification.type : ''
59+
}`,
60+
criteria: { type: 'PERSONAL', value: receivingUser.id },
61+
}
62+
63+
console.log('Sending push notifications')
64+
65+
sendPushNotifications(
66+
receivingUser.pushNotificationTokens,
67+
`${capitalizeString(notification.type)} notification`,
68+
notification.message,
69+
notificationData
70+
)
71+
}
72+
}
73+
}
74+
75+
const capitalizeString = (str?: string) => {
76+
str = str || 'New'
77+
return str.charAt(0).toUpperCase() + str.slice(1)
78+
}
79+
80+
const sendPushNotifications = async (
81+
pushTokens: string[],
82+
title: string,
83+
body: string,
84+
data: NotificationData
85+
) => {
86+
// Create the messages that you want to send to clients
87+
let messages: ExpoPushMessage[] = []
88+
for (let pushToken of pushTokens) {
89+
// Each push token looks like ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]
90+
// Check that all your push tokens appear to be valid Expo push tokens
91+
if (!Expo.isExpoPushToken(pushToken)) {
92+
console.error(`Push token ${pushToken} is not a valid Expo push token`)
93+
continue
94+
}
95+
96+
messages.push({
97+
to: pushToken,
98+
title: title,
99+
body: body,
100+
data: data,
101+
})
35102
}
103+
104+
// The Expo push notification service accepts batches of notifications so
105+
// that you don't need to send 1000 requests to send 1000 notifications.
106+
let chunks = expo.chunkPushNotifications(messages)
107+
let tickets: ExpoPushTicket[] = []
108+
;(async () => {
109+
for (let chunk of chunks) {
110+
try {
111+
let ticketChunk = await expo.sendPushNotificationsAsync(chunk)
112+
console.log(ticketChunk)
113+
tickets.push(...ticketChunk)
114+
} catch (error) {
115+
console.error(error)
116+
}
117+
}
118+
})()
119+
120+
let receiptIds = []
121+
for (let ticket of tickets) {
122+
// NOTE: Not all tickets have IDs; for example, tickets for notifications
123+
// that could not be enqueued will have error information and no receipt ID.
124+
if (ticket.status === 'ok') {
125+
receiptIds.push(ticket.id)
126+
}
127+
128+
if (ticket.status === 'error' && ticket.details) {
129+
console.error(ticket.details.error)
130+
}
131+
}
132+
133+
let receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds)
134+
;(async () => {
135+
// Like sending notifications, there are different strategies you could use
136+
// to retrieve batches of receipts from the Expo service.
137+
for (let chunk of receiptIdChunks) {
138+
try {
139+
let receipts = await expo.getPushNotificationReceiptsAsync(chunk)
140+
console.log(receipts)
141+
142+
// The receipts specify whether Apple or Google successfully received the
143+
// notification and information about an error, if one occurred.
144+
for (let receiptId in receipts) {
145+
let { status, details }: ExpoPushReceipt | ExpoPushErrorReceipt =
146+
receipts[receiptId]
147+
if (status === 'ok') {
148+
continue
149+
} else if (status === 'error') {
150+
console.error(
151+
`There was an error sending a notification: ${details}`
152+
)
153+
}
154+
}
155+
} catch (error) {
156+
console.error(error)
157+
}
158+
}
159+
})()
36160
}

0 commit comments

Comments
 (0)