Skip to content
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

Merged
merged 11 commits into from
Jul 29, 2023
6 changes: 6 additions & 0 deletions common/chat/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import { ChatAccess, ChatMetaData, Conversation, ConversationInfos, TJConversati
import { MatchContactPupil, MatchContactStudent } from './contacts';

type TalkJSUserId = `${'pupil' | 'student'}_${number}`;
export type UserId = `${'pupil' | 'student'}/${number}`;

const userIdToTalkJsId = (userId: string): TalkJSUserId => {
return userId.replace('/', '_') as TalkJSUserId;
};

const talkJsIdToUserId = (userId: string): UserId => {
return userId.replace('_', '/') as UserId;
};
const createChatSignature = async (user: User): Promise<string> => {
const userId = (await getOrCreateChatUser(user)).id;
const key = process.env.TALKJS_API_KEY;
Expand Down Expand Up @@ -201,6 +206,7 @@ const isPupilContact = (contact: MatchContactPupil | MatchContactStudent): conta

export {
userIdToTalkJsId,
talkJsIdToUserId,
parseUnderscoreToSlash,
checkResponseStatus,
createChatSignature,
Expand Down
31 changes: 31 additions & 0 deletions common/notification/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down Expand Up @@ -412,6 +429,20 @@ const _notificationActions = {
appointment: sampleAppointment,
},
},
missed_one_on_one_chat_message: {
Copy link
Contributor Author

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

description: 'Missed message in 1:1 chat',
sampleContext: {
user: sampleUser,
message: sampleMissedOneOnOneMessage,
},
},
missed_course_chat_message: {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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: {},
Expand Down
66 changes: 66 additions & 0 deletions web/controllers/chatNotificationController/index.ts
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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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' });
Copy link
Member

Choose a reason for hiding this comment

The 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' });
}
}
88 changes: 88 additions & 0 deletions web/controllers/chatNotificationController/types.ts
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 };
76 changes: 76 additions & 0 deletions web/controllers/chatNotificationController/util.ts
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 {}
15 changes: 12 additions & 3 deletions web/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from 'express';
import http from 'http';
import http, { IncomingMessage } from 'http';
import bodyParser from 'body-parser';
import helmet from 'helmet';
import cors from 'cors';
Expand All @@ -17,6 +17,8 @@ import { fileRouter } from './controllers/fileController';
import { attachmentRouter } from './controllers/attachmentController';
import { certificateRouter } from './controllers/certificateController';
import { convertCertificateLinkToApiLink } from '../common/certificate';
import { chatNotificationRouter } from './controllers/chatNotificationController';
import { WithRawBody } from './controllers/chatNotificationController/types';

// ------------------ Setup Logging, Common Headers, Routes ----------------

Expand All @@ -42,7 +44,14 @@ export const server = (async function setupWebserver() {
});

// Parse Cookies and JSON Bodies:
app.use(bodyParser.json());
app.use(
bodyParser.json({
// To be able to persist the raw body of the request we use the verify function and extend the request object by the key `rawBody`
verify: (req: WithRawBody<IncomingMessage>, res, buf) => {
req.rawBody = buf;
LomyW marked this conversation as resolved.
Show resolved Hide resolved
},
})
);
app.use(cookieParser());

// Add a Favicon to the Backend (as we have some URLs that are directly opened on the backend)
Expand Down Expand Up @@ -84,7 +93,6 @@ export const server = (async function setupWebserver() {

app.use(cors(options));


// ------------------------ GraphQL ---------------------------
await apolloServer.start();
apolloServer.applyMiddleware({ app, path: '/apollo' });
Expand All @@ -93,6 +101,7 @@ export const server = (async function setupWebserver() {
app.use('/api/attachments', attachmentRouter);
app.use('/api/certificate', certificateRouter);
app.use('/api/files', fileRouter);
app.use('/api/chat', chatNotificationRouter);

app.get('/:certificateId', (req, res, next) => {
if (!req.subdomains.includes('verify')) {
Expand Down
Loading