Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.

Commit

Permalink
send notification for comments (closes #6)
Browse files Browse the repository at this point in the history
  • Loading branch information
wceolin committed Jun 3, 2020
1 parent 7d41fc1 commit c4e7ddd
Show file tree
Hide file tree
Showing 15 changed files with 376 additions and 21 deletions.
155 changes: 155 additions & 0 deletions functions/src/comments/onCreate/__tests__/sendNotification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import functions from 'firebase-functions-test';
import * as admin from 'firebase-admin';
import { when } from 'jest-when';

const testEnv = functions();
const db = admin.firestore();
const batch = db.batch();

import { onCreateCommentSendNotification } from '../sendNotification';

beforeAll(() => {
spyOn(batch, 'commit').and.returnValue('sent');
});

beforeEach(() => {
jest.clearAllMocks();
});

test('do not send a notification to the post author when they comment their own post', async (done) => {
const comment = { commentId: null, createdById: 'author', postId: 'postId' };
const post = { createdById: 'author' };
const snap = { data: () => comment };

spyOn(db.doc(''), 'get').and.returnValue({ data: () => post });

const wrapped = testEnv.wrap(onCreateCommentSendNotification);
const req = await wrapped(snap);

expect(req).toEqual('sent');
expect(db.doc).toHaveBeenCalledWith('posts/postId');
expect(db.collection).not.toHaveBeenCalled();
expect(batch.set).not.toHaveBeenCalled();
done();
});

test('send a notification to the post author', async (done) => {
const comment = {
commentId: null,
createdBy: { name: 'user name' },
createdById: 'author',
language: 'en',
postId: 'postId',
};
const post = { createdById: 'other', title: 'post title' };
const snap = { data: () => comment, id: 'commentId' };

spyOn(db.doc(''), 'get').and.returnValue({ data: () => post });
when(db.collection as any)
.calledWith('users/other/notifications')
.mockReturnValue({
doc: jest.fn().mockReturnValue('notRef'),
});

const wrapped = testEnv.wrap(onCreateCommentSendNotification);
const req = await wrapped(snap);
const notification = {
action: 'created',
activityId: null,
category: 'comments',
itemPath: 'comments/commentId',
language: 'en',
title: 'post title',
type: 'comments',
updatedAt: 'timestamp',
user: { name: 'user name' },
};

expect(req).toEqual('sent');
expect(db.doc).toHaveBeenCalledWith('posts/postId');
expect(db.collection).toHaveBeenCalledWith('users/other/notifications');
expect(db.collection).toHaveBeenCalledTimes(1);
expect(batch.set).toHaveBeenCalledWith('notRef', notification);
expect(batch.set).toHaveBeenCalledTimes(1);
done();
});

test('do not send a notification to the comment author when replying to their own comment', async (done) => {
const comment = {
commentId: 'commentId',
createdById: 'author',
postId: 'postId',
};
const parent = { createdById: 'author' };
const post = { createdById: 'author' };
const snap = { data: () => comment };

when(db.doc as any)
.calledWith('posts/postId')
.mockReturnValue({ get: jest.fn().mockReturnValue({ data: () => post }) });
when(db.doc as any)
.calledWith('comments/commentId')
.mockReturnValue({
get: jest.fn().mockReturnValue({ data: () => parent }),
});

const wrapped = testEnv.wrap(onCreateCommentSendNotification);
const req = await wrapped(snap);

expect(req).toEqual('sent');
expect(db.doc).toHaveBeenCalledWith('posts/postId');
expect(db.doc).toHaveBeenCalledWith('comments/commentId');
expect(db.collection).not.toHaveBeenCalled();
expect(batch.set).not.toHaveBeenCalled();
done();
});

test('send a notification for replies to the comment author', async (done) => {
const comment = {
commentId: 'parent',
createdBy: { name: 'user name' },
createdById: 'author',
language: 'en',
postId: 'postId',
};
const post = { createdById: 'author', title: 'post title' };
const parent = { createdById: 'other' };
const snap = { data: () => comment, id: 'commentId' };

when(db.doc as any)
.calledWith('posts/postId')
.mockReturnValue({ get: jest.fn().mockReturnValue({ data: () => post }) });
when(db.doc as any)
.calledWith('comments/parent')
.mockReturnValue({
get: jest.fn().mockReturnValue({ data: () => parent }),
});
when(db.collection as any)
.calledWith('users/other/notifications')
.mockReturnValue({
doc: jest.fn().mockReturnValue('notRef'),
});

const wrapped = testEnv.wrap(onCreateCommentSendNotification);
const req = await wrapped(snap);
const notification = {
action: 'created',
activityId: null,
category: 'comments',
itemPath: 'comments/commentId',
language: 'en',
title: 'post title',
type: 'comments',
updatedAt: 'timestamp',
user: { name: 'user name' },
};

expect(req).toEqual('sent');
expect(db.doc).toHaveBeenCalledWith('posts/postId');
expect(db.doc).toHaveBeenCalledWith('comments/parent');
expect(db.collection).toHaveBeenCalledWith('users/other/notifications');
expect(db.collection).toHaveBeenCalledTimes(1);
expect(batch.set).toHaveBeenCalledWith('notRef', notification);
expect(batch.set).toHaveBeenCalledTimes(1);
done();
});
1 change: 1 addition & 0 deletions functions/src/comments/onCreate/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './earnXp';
export * from './sendNotification';
export * from './updateCommentsCount';
49 changes: 49 additions & 0 deletions functions/src/comments/onCreate/sendNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import { Comment, Notification, Post } from '@zoonk/models';

const db = admin.firestore();

export const onCreateCommentSendNotification = functions.firestore
.document('comments/{id}')
.onCreate(async (snap) => {
const batch = db.batch();
const data = snap.data() as Comment.Response;
const post = await db.doc(`posts/${data.postId}`).get();
const postData = post.data() as Post.Response;
const isAuthor = data.createdById === postData.createdById;

const notification: Notification.Create = {
action: 'created',
activityId: null,
category: 'comments',
itemPath: `comments/${snap.id}`,
language: data.language,
title: postData.title,
type: 'comments',
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
user: data.createdBy,
};

if (!isAuthor) {
const postAuthorRef = db
.collection(`users/${postData.createdById}/notifications`)
.doc();
batch.set(postAuthorRef, notification);
}

if (data.commentId) {
const parent = await db.doc(`comments/${data.commentId}`).get();
const parentData = parent.data() as Comment.Response;
const isCommentAuthor = data.createdById === parentData.createdById;

if (!isCommentAuthor) {
const commentAuthorRef = db
.collection(`users/${parentData.createdById}/notifications`)
.doc();
batch.set(commentAuthorRef, notification);
}
}

return batch.commit();
});
7 changes: 6 additions & 1 deletion functions/src/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ const contentTemplate = {
pt: 'd-c343bf9961e0498ab807352f10eb57c0',
};

export { contentTemplate, mailClient };
const commentsTemplate = {
en: 'd-09ba3117481749b1aa082f86e986f79c',
pt: 'd-c63b1c03031c4a409b689e9b6b919ec5',
};

export { commentsTemplate, contentTemplate, mailClient };
42 changes: 41 additions & 1 deletion functions/src/notifications/onCreate/__tests__/sendEmail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const testEnv = functions();
testEnv.mockConfig({ sendgrid: { api_key: 'SG.random-key' } });
const db = admin.firestore();

import { contentTemplate, mailClient } from '../../../mail';
import { commentsTemplate, contentTemplate, mailClient } from '../../../mail';
import { onCreateNotificationSendEmail } from '../sendEmail';

beforeAll(() => {
Expand Down Expand Up @@ -91,3 +91,43 @@ test('send an email notification', async (done) => {
expect(mailClient.send).toHaveBeenCalledWith(msg);
done();
});

test('send an email notification using the comments template', async (done) => {
const userData = {
email: 'test@test.com',
notificationSettings: { comments: ['email'] },
username: 'test',
};
const params = { userId: 'userId' };
const data = {
activityId: null,
category: 'comments',
itemPath: 'comments/commentId',
language: 'en',
title: 'title',
type: 'comments',
user: { name: 'name' },
};
const snap = { data: () => data };

spyOn(db.doc(''), 'get').and.returnValue({ data: () => userData });

const wrapped = testEnv.wrap(onCreateNotificationSendEmail);
const req = await wrapped(snap, { params });
const msg = {
to: 'test@test.com',
from: 'support@zoonk.org',
templateId: commentsTemplate.en,
dynamic_template_data: {
editId: 'comments/commentId',
name: 'name',
title: 'title',
username: 'test',
},
};

expect(req).toBe('sent');
expect(db.doc).toHaveBeenCalledWith('users/userId');
expect(mailClient.send).toHaveBeenCalledWith(msg);
done();
});
8 changes: 5 additions & 3 deletions functions/src/notifications/onCreate/sendEmail.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import { Notification, User } from '@zoonk/models';
import { contentTemplate, mailClient } from '../../mail';
import { commentsTemplate, contentTemplate, mailClient } from '../../mail';

const db = admin.firestore();

Expand All @@ -21,16 +21,18 @@ export const onCreateNotificationSendEmail = functions.firestore
if (!isEnabled || !userData.email) return false;

const templateData: Notification.Email = {
editId: data.activityId,
editId: data.activityId || data.itemPath,
name: data.user.name,
title: data.title,
username: userData.username,
};
const templateId =
data.category === 'comments' ? commentsTemplate : contentTemplate;

const msg = {
to: userData.email,
from: 'support@zoonk.org',
templateId: contentTemplate[data.language],
templateId: templateId[data.language],
dynamic_template_data: templateData,
};

Expand Down
9 changes: 7 additions & 2 deletions src/components/DiscussionListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import Viewer from './rich-text/Viewer';

interface DiscussionListItemProps {
comment: Comment.Get;
link?: 'posts' | 'comments';
}

const DiscussionListItem = ({ comment }: DiscussionListItemProps) => {
const DiscussionListItem = ({
comment,
link = 'comments',
}: DiscussionListItemProps) => {
const { translate, user } = useContext(GlobalContext);
const [snackbar, setSnackbar] = useState<SnackbarAction | null>(null);
const { createdAt, createdBy, createdById, html, id, postId } = comment;
const isAuthor = createdById === user?.uid;
const isModerator = user?.role === 'moderator' || user?.role === 'admin';
const linkId = link === 'posts' ? postId : id;

/**
* Delete current comment.
Expand Down Expand Up @@ -82,7 +87,7 @@ const DiscussionListItem = ({ comment }: DiscussionListItemProps) => {
<Snackbar action={snackbar} />
</CardContent>
<CardActions disableSpacing>
<NextLink href="/posts/[id]" as={`/posts/${postId}`} passHref>
<NextLink href={`/${link}/[id]`} as={`/${link}/${linkId}`} passHref>
<Button component="a" color="primary">
{translate('see_discussion')}
</Button>
Expand Down
14 changes: 10 additions & 4 deletions src/components/NotificationListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ interface NotificationListItemProps {
*/
const NotificationListItem = ({ item, divider }: NotificationListItemProps) => {
const { translate } = useContext(GlobalContext);
const { action, activityId, title, updatedAt, user } = item;
const text = `${user.name} ${translate(action)} ${title}.`;
const { action, activityId, category, title, updatedAt, user } = item;
const editableText = `${user.name} ${translate(action)} ${title}.`;
const commentText = translate('comment_notification', { name: user.name });
const text = category === 'comments' ? commentText : editableText;

return (
<ListItem alignItems="flex-start" divider={divider} disableGutters>
Expand All @@ -36,8 +38,12 @@ const NotificationListItem = ({ item, divider }: NotificationListItemProps) => {
</NextLink>
<ListItemText primary={text} secondary={updatedAt} />
<ListItemSecondaryAction>
{action === 'updated' && <NotificationView item={item} />}
{action === 'deleted' && <NotificationRestore id={activityId} />}
{(action === 'created' || action === 'updated') && (
<NotificationView item={item} />
)}
{action === 'deleted' && activityId && (
<NotificationRestore id={activityId} />
)}
</ListItemSecondaryAction>
</ListItem>
);
Expand Down
2 changes: 2 additions & 0 deletions src/locale/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const translate: TranslationFn = (key, args) => {
collapse: 'Collapse',
comment_leave: 'Leave a comment',
comment_login_required: 'You need to be logged in to leave a comment.',
comment_not_found: 'Comment not found.',
comment_notification: `${args?.name} commented your post.`,
comments: 'Comments',
confirm: 'Confirm',
contact_us: 'Contact us',
Expand Down
2 changes: 2 additions & 0 deletions src/locale/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const translate: TranslationFn = (key, args) => {
comment_leave: 'Deixe um comentário',
comment_login_required:
'Você precisa estar logado para deixar um comentário.',
comment_not_found: 'Comentário não encontrado.',
comment_notification: `${args?.name} comentou o seu post.`,
comments: 'Comentários',
confirm: 'Confirmar',
contact_us: 'Fale conosco',
Expand Down
2 changes: 2 additions & 0 deletions src/models/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export type TranslationKeys =
| 'collapse'
| 'comment_leave'
| 'comment_login_required'
| 'comment_not_found'
| 'comment_notification'
| 'comments'
| 'confirm'
| 'contact_us'
Expand Down
2 changes: 1 addition & 1 deletion src/models/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export namespace Notification {
*/
export interface Create {
action: UserAction;
activityId: string;
activityId: string | null;
category: ContentCategory;
itemPath: string;
language: UILanguage;
Expand Down
Loading

1 comment on commit c4e7ddd

@vercel
Copy link

@vercel vercel bot commented on c4e7ddd Jun 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.