Skip to content

Commit

Permalink
feat: mobile in-app push notification (#419)
Browse files Browse the repository at this point in the history
  • Loading branch information
fredshema authored Nov 27, 2024
1 parent 39437f1 commit 19feede
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 12 deletions.
46 changes: 46 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ const userSchema = new Schema(
required: false
},

pushNotificationTokens: {
type: [String],
default: [],
},
},

{
Expand Down
59 changes: 51 additions & 8 deletions src/resolvers/userResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const Schema = gql`
TwoWayVerificationToken: String
createdAt: String
updatedAt: String
pushNotificationTokens: [String]
}
input RegisterInput {
email: String!
Expand Down Expand Up @@ -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!
Expand Down
132 changes: 128 additions & 4 deletions src/utils/notification/pushNotification.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
}
}
})()
}

0 comments on commit 19feede

Please sign in to comment.