-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: notifications for missed chat messages #754
Changes from all commits
c715e72
7b85368
bf4d929
dae2d32
0ba3332
254804d
e9671d0
e4711ec
da2676c
a079550
0d12dd7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,6 +43,23 @@ const sampleAppointment = { | |
}, | ||
}; | ||
|
||
const sampleMissedCourseMessage = { | ||
sender: 'Leon', | ||
conversationId: '1', | ||
message: 'Hey', | ||
totalUnread: '1', | ||
courseId: '1', | ||
courseName: 'Gen Z', | ||
}; | ||
|
||
const sampleMissedOneOnOneMessage = { | ||
sender: 'Max', | ||
conversationId: '2', | ||
message: 'Hey!', | ||
totalUnread: '1', | ||
matchId: '1', | ||
}; | ||
|
||
const DEPRECATED = { | ||
description: 'DEPRECATED - DO NOT USE', | ||
}; | ||
|
@@ -412,6 +429,20 @@ const _notificationActions = { | |
appointment: sampleAppointment, | ||
}, | ||
}, | ||
missed_one_on_one_chat_message: { | ||
description: 'Missed message in 1:1 chat', | ||
sampleContext: { | ||
user: sampleUser, | ||
message: sampleMissedOneOnOneMessage, | ||
}, | ||
}, | ||
missed_course_chat_message: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. action for missed group chat messages |
||
description: 'Missed message in group chat', | ||
sampleContext: { | ||
user: sampleUser, | ||
message: sampleMissedCourseMessage, | ||
}, | ||
}, | ||
person_inactivity_reminder: { | ||
description: 'Person / Inactive Reminder. User will soon be deleted.', | ||
sampleContext: {}, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { getLogger } from '../../../common/logger/logger'; | ||
import { Request, Response, Router } from 'express'; | ||
import { NotificationTriggered, WithRawBody } from './types'; | ||
import { talkJsIdToUserId } from '../../../common/chat/helper'; | ||
import { getPupil, getStudent, getUser } from '../../../common/user'; | ||
import * as Notification from '../../../common/notification'; | ||
import { pupil as Pupil, student as Student } from '@prisma/client'; | ||
import { ChatType, InvalidSignatureError, getChatType, getNotificationContext, verifyChatUser } from './util'; | ||
import { createHmac } from 'crypto'; | ||
|
||
const logger = getLogger('ChatNotification'); | ||
|
||
export const chatNotificationRouter = Router(); | ||
|
||
chatNotificationRouter.post('/chat-notification', handleChatNotification); | ||
|
||
const secretKey = process.env.TALKJS_API_KEY; | ||
async function handleChatNotification(req: WithRawBody<Request>, res: Response): Promise<void> { | ||
try { | ||
LomyW marked this conversation as resolved.
Show resolved
Hide resolved
|
||
logger.info('Request at /chat/chat-notification'); | ||
|
||
const receivedSignature = req.headers['x-talkjs-signature']; | ||
const timestamp = req.headers['x-talkjs-timestamp']; | ||
|
||
const payload = timestamp + '.' + req.rawBody; | ||
const hash = createHmac('sha256', secretKey).update(payload); | ||
const validSignature = hash.digest('hex').toUpperCase(); | ||
|
||
if (receivedSignature !== validSignature) { | ||
throw new InvalidSignatureError('Invalid Signature'); | ||
} | ||
|
||
const notificationBody: NotificationTriggered = req.body; | ||
const { data } = notificationBody; | ||
const recipient = data.recipient; | ||
const recipientUserId = talkJsIdToUserId(recipient.id); | ||
const recipientUser = await getUser(recipientUserId); | ||
|
||
const conversationParticipants = Object.keys(data.conversation.participants); | ||
const chatType = getChatType(conversationParticipants); | ||
|
||
const isUserVerified = await verifyChatUser(recipientUser); | ||
|
||
if (isUserVerified) { | ||
const notificationContext = await getNotificationContext(notificationBody); | ||
let userToNotify: Pupil | Student; | ||
if (recipientUser.pupilId) { | ||
userToNotify = await getPupil(recipientUser); | ||
} else { | ||
userToNotify = await getStudent(recipientUser); | ||
} | ||
|
||
const notificationAction = chatType === ChatType.ONE_ON_ONE ? 'missed_one_on_one_chat_message' : 'missed_course_chat_message'; | ||
await Notification.actionTaken(userToNotify, notificationAction, notificationContext); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we dont need to iterate over each participant of a group conversation, because TalkJS itselfs triggers notifications for each participant of the group chat.
Jonasdoubleyou marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
res.status(200).send({ status: 'ok' }); | ||
} catch (error) { | ||
if (error instanceof InvalidSignatureError) { | ||
logger.info('Invalid Signature'); | ||
res.status(401).send({ error: 'Unauthorized' }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. missing return / else |
||
return; | ||
} | ||
logger.error(`Failed to send notification for missed messages`, error); | ||
res.status(500).send({ error: 'Internal Server Error' }); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import { TJConversation } from '../../../common/chat/types'; | ||
|
||
type Role = 'student' | 'pupil'; | ||
|
||
export type NotificationTriggered = { | ||
id: string; | ||
createdAt: string; | ||
data: { | ||
notificationId: string; | ||
recipient: Recipient; | ||
sender: Sender; | ||
conversation: TJConversation; | ||
messages: UserMessage[]; | ||
}; | ||
type: 'notification.triggered'; | ||
}; | ||
|
||
export interface Recipient { | ||
availabilityText: string | null; | ||
createdAt: number; | ||
custom: Record<string, any>; | ||
email: string[]; | ||
id: string; | ||
locale: string | null; | ||
name: string; | ||
phone: string | null; | ||
photoUrl: string | null; | ||
pushTokens: Record<string, any>; | ||
role: Role; | ||
welcomeMessage: string | null; | ||
} | ||
|
||
export interface Sender { | ||
availabilityText: string | null; | ||
createdAt: number; | ||
custom: Record<string, any>; | ||
email: string[]; | ||
id: string; | ||
locale: string | null; | ||
name: string; | ||
phone: string | null; | ||
photoUrl: string | null; | ||
pushTokens: Record<string, any>; | ||
role: Role; | ||
welcomeMessage: string | null; | ||
} | ||
|
||
export interface UserMessage { | ||
attachment: any | null; | ||
conversationId: string; | ||
createdAt: number; | ||
custom: Record<string, any>; | ||
editedAt: number | null; | ||
id: string; | ||
location: any | null; | ||
origin: string; | ||
readBy: string[]; | ||
referencedMessageId: string | null; | ||
senderId: string; | ||
text: string; | ||
type: string; | ||
} | ||
|
||
type ChatParticipant = { | ||
firstname?: string; | ||
fullname?: string; | ||
}; | ||
|
||
type NotificationContextBase = { | ||
sender: ChatParticipant; | ||
conversationId: string; | ||
message: string; | ||
totalUnread: string; | ||
loginToken?: string; | ||
}; | ||
|
||
export type GroupNotificationContext = NotificationContextBase & { | ||
courseId: string; | ||
courseName: string; | ||
}; | ||
|
||
export type OneOnOneNotificationContext = NotificationContextBase & { | ||
matchId?: string; | ||
courseId?: string; | ||
subcourseIds?: string; | ||
}; | ||
|
||
export type WithRawBody<T> = T & { rawBody: Buffer }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { getChatUser } from '../../../common/chat'; | ||
import { getLogger } from '../../../common/logger/logger'; | ||
import { User } from '../../../common/user'; | ||
import { getCourse, getSubcourse } from '../../../graphql/util'; | ||
import { GroupNotificationContext, NotificationTriggered, OneOnOneNotificationContext } from './types'; | ||
|
||
const logger = getLogger('ChatNotification'); | ||
|
||
export enum ChatType { | ||
GROUP = 'group', | ||
ONE_ON_ONE = 'one_on_one', | ||
} | ||
|
||
export function getChatType(participants: string[]) { | ||
if (participants.length === 2) { | ||
return ChatType.ONE_ON_ONE; | ||
} | ||
return ChatType.GROUP; | ||
} | ||
|
||
export async function verifyChatUser(user: User) { | ||
const chatUser = await getChatUser(user); | ||
if (chatUser) { | ||
return true; | ||
} | ||
return false; | ||
} | ||
|
||
export async function getNotificationContext(notificationBody: NotificationTriggered): Promise<GroupNotificationContext | OneOnOneNotificationContext> { | ||
const { sender, conversation, messages } = notificationBody.data; | ||
const firstnameSender: string = sender.name.split(' ')[0]; | ||
|
||
const subcourseConversation = conversation.custom.subcourse ? JSON.parse(conversation.custom.subcourse) : undefined; | ||
const match = conversation.custom.match ? JSON.parse(conversation.custom.match) : undefined; | ||
const participants = Object.keys(conversation.participants); | ||
|
||
const chatType = getChatType(participants); | ||
|
||
let notificationContext: GroupNotificationContext | OneOnOneNotificationContext; | ||
|
||
if (chatType === ChatType.ONE_ON_ONE) { | ||
let courseId: number; | ||
if (subcourseConversation?.length === 1) { | ||
const subcourse = await getSubcourse(subcourseConversation[0]); | ||
courseId = subcourse.courseId; | ||
} | ||
|
||
notificationContext = { | ||
sender: { firstname: firstnameSender }, | ||
conversationId: conversation.id, | ||
message: messages[0].text, | ||
totalUnread: messages.length.toString(), | ||
...(match ? { matchId: match.matchId } : {}), | ||
...(courseId ? { courseId: courseId.toString() } : {}), | ||
...(subcourseConversation && subcourseConversation.length > 1 ? { subcourseIds: subcourseConversation.toString() } : {}), | ||
}; | ||
} else if (chatType === ChatType.GROUP && subcourseConversation) { | ||
const subcourse = await getSubcourse(subcourseConversation[0]); | ||
const course = await getCourse(subcourse.courseId); | ||
|
||
notificationContext = { | ||
sender: { firstname: firstnameSender }, | ||
conversationId: conversation.id, | ||
message: messages[0].text, | ||
totalUnread: messages.length.toString(), | ||
courseId: subcourse.courseId.toString(), | ||
courseName: course.name, | ||
}; | ||
} | ||
|
||
logger.info('Created Notification context for chat message', notificationContext); | ||
|
||
return notificationContext; | ||
} | ||
|
||
export class InvalidSignatureError extends Error {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
action for missed 1:1 messages