Skip to content

Commit fef65f4

Browse files
committed
feat: mobile in-app push notification
1 parent 0317ff7 commit fef65f4

File tree

6 files changed

+244
-11
lines changed

6 files changed

+244
-11
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
@@ -69,6 +69,7 @@
6969
"date-fns": "^2.29.2",
7070
"dotenv": "^16.0.1",
7171
"ejs": "^3.1.8",
72+
"expo-server-sdk": "^3.11.0",
7273
"express": "^4.18.1",
7374
"faker": "^6.6.6",
7475
"generate-password": "^1.7.0",

src/models/user.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ const userSchema = new Schema(
9393
type: Boolean,
9494
default: true,
9595
},
96+
pushNotificationTokens: {
97+
type: [String],
98+
default: [],
99+
},
96100
},
97101

98102
{

src/resolvers/userResolver.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Octokit } from '@octokit/rest'
33
import bcrypt from 'bcryptjs'
44
import { GraphQLError } from 'graphql'
55
// import * as jwt from 'jsonwebtoken'
6+
import Expo from 'expo-server-sdk'
67
import { JwtPayload, verify } from 'jsonwebtoken'
78
import mongoose, { Error } from 'mongoose'
89
import generateRandomPassword from '../helpers/generateRandomPassword'
@@ -35,7 +36,6 @@ import organizationRejectedTemplate from '../utils/templates/organizationRejecte
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'
3939
const octokit = new Octokit({ auth: `${process.env.Org_Repo_Access}` })
4040

4141
const SECRET = (process.env.SECRET as string) || 'mysq_unique_secret'
@@ -824,19 +824,28 @@ const resolvers: any = {
824824
})
825825

826826
// Create the organization with 'pending' status
827-
const {name:nm,admin:adm,description:desc}=await Organization.create({
827+
const {
828+
name: nm,
829+
admin: adm,
830+
description: desc,
831+
} = await Organization.create({
828832
admin: newAdmin._id,
829833
name,
830834
description,
831835
status: 'pending',
832836
})
833-
const newOrgToken=genericToken({nm,adm,desc,email})
837+
const newOrgToken = genericToken({ nm, adm, desc, email })
834838

835839
const superAdmin = await User.find({ role: RoleOfUser.SUPER_ADMIN })
836840
// Get the email content
837-
const link = process.env.FRONTEND_LINK ?? ""
838-
const content = registrationRequest(email, name, description,link,newOrgToken)
839-
841+
const link = process.env.FRONTEND_LINK ?? ''
842+
const content = registrationRequest(
843+
email,
844+
name,
845+
description,
846+
link,
847+
newOrgToken
848+
)
840849

841850
// Send registration request email to super admin
842851
await sendEmail(
@@ -1162,10 +1171,56 @@ const {name:nm,admin:adm,description:desc}=await Organization.create({
11621171
}
11631172
user.pushNotifications = !user.pushNotifications
11641173
await user.save()
1165-
const updatedPushNotifications = user.pushNotifications
11661174
return 'updated successful'
11671175
},
11681176

1177+
async addPushNotificationToken(
1178+
_: any,
1179+
{ pushToken }: { pushToken: string },
1180+
context: Context
1181+
) {
1182+
if (!Expo.isExpoPushToken(pushToken)) {
1183+
throw new Error('Invalid push notification')
1184+
}
1185+
1186+
const user: any = await User.findOne({ _id: context.userId })
1187+
if (!user) {
1188+
throw new Error('User not found')
1189+
}
1190+
1191+
if (!user.pushNotificationTokens.includes(pushToken)) {
1192+
user.pushNotificationTokens.push(pushToken)
1193+
await user.save()
1194+
return 'Notification token added successfully'
1195+
}
1196+
1197+
return 'Notification token already added'
1198+
},
1199+
1200+
async removePushNotificationToken(
1201+
_: any,
1202+
{ pushToken }: { pushToken: string },
1203+
context: Context
1204+
) {
1205+
if (!Expo.isExpoPushToken(pushToken)) {
1206+
throw new Error('Invalid push notification')
1207+
}
1208+
1209+
const user: any = await User.findOne({ _id: context.userId })
1210+
if (!user) {
1211+
throw new Error('User not found')
1212+
}
1213+
1214+
const index = user.pushNotificationTokens.indexOf(pushToken)
1215+
if (index !== -1) {
1216+
user.pushNotificationTokens.splice(index, 1)
1217+
await user.save()
1218+
return 'Notification token removed successfully'
1219+
}
1220+
1221+
return 'Notification token not found'
1222+
},
1223+
11691224
async resetUserPassword(
11701225
_: any,
11711226
{ password, confirmPassword, token }: any,

src/schema/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const Schema = gql`
8181
emailNotifications: Boolean!
8282
status: StatusType
8383
ratings: [Rating]
84+
pushNotificationTokens: [String]
8485
}
8586
input RegisterInput {
8687
email: String!
@@ -561,6 +562,8 @@ const Schema = gql`
561562
type Mutation {
562563
updatePushNotifications(id: ID!): String
563564
updateEmailNotifications(id: ID!): String
565+
addPushNotificationToken(pushToken: String): String
566+
removePushNotificationToken(pushToken: String): String
564567
}
565568
type Query {
566569
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)