Notifications v2 and Bailey API (#9716)

* Added initial bailey api

* wip

* implement new panel header

* Fixed lint

* add ability to mark notification as seen

* add notification count, remove top badge from user and add ability to mark multiple notifications as seen

* add support dismissall and mark all as read

* do not dismiss actionable notif

* mark as seen when menu is opened instead of closed

* implement ordering, list of actionable notifications

* add groups messages and fix badges count

* add notifications for received cards

* send card received notification to target not sender

* rename notificaion field

* fix integration tests

* mark cards notifications as read and update tests

* add mystery items notifications

* add unallocated stats points notifications

* fix linting

* simplify code

* refactoring and fixes

* fix dropdown opening

* start splitting notifications into their own component

* add notifications for inbox messages

* fix unit tests

* fix default buttons styles

* add initial bailey support

* add title and tests to new stuff notification

* add notification if a group task needs more work

* add tests and fixes for marking a task as needing more work

* make sure user._v is updated

* remove console.log

* notification: hover status and margins

* start styling notifications, add separate files and basic functionalities

* fix tests

* start adding mystery items notification

* wip card notification

* fix cards text

* initial implementation inbox messages

* initial implementation group messages

* disable inbox notifications until mobile is ready

* wip group chat messages

* finish mystery and card notifications

* add bailey notification and fix a lot of stuff

* start adding guilds and parties invitations

* misc invitation fixes

* fix lint issues

* remove old code and add key to notifications

* fix tests

* remove unused code

* add link for public guilds invite

* starts to implement needs work notification design and feature

* fixes to needs work, add group task approved notification

* finish needs work feature

* lots of fixes

* implement quest notification

* bailey fixes and static page

* routing fixes

* fixes #      this.$store.dispatch(guilds:join, {groupId: group.id, type: party});

* read notifications on click

* chat notifications

* fix tests for chat notifications

* fix chat notification test

* fix tests

* fix tests (again)

* try awaiting

* remove only

* more sleep

* add bailey tests

* fix icons alignment

* fix issue with multiple points notifications

* remove merge code

* fix rejecting guild invitation

* make remove area bigger

* fix error with notifications and add migration

* fix migration

* fix typos

* add cleanup migration too

* notifications empty state, new counter color, fix marking messages as seen in guilds

* fixes

* add image and install correct packages

* fix mongoose version

* update bailey

* typo

* make sure chat is marked as read after other requests
This commit is contained in:
Matteo Pagliazzi
2018-01-31 11:55:39 +01:00
committed by GitHub
parent a85282763f
commit 33b249d078
98 changed files with 3003 additions and 1026 deletions
@@ -426,6 +426,9 @@ describe('POST /chat', () => {
expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist;
expect(memberWithNotification.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupWithChat._id;
})).to.exist;
});
it('notifies other users of new messages for a party', async () => {
@@ -443,6 +446,9 @@ describe('POST /chat', () => {
expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${group._id}`]).to.exist;
expect(memberWithNotification.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === group._id;
})).to.exist;
});
context('Spam prevention', () => {
@@ -24,10 +24,13 @@ describe('POST /groups/:id/chat/seen', () => {
});
it('clears new messages for a guild', async () => {
await guildMember.sync();
const initialNotifications = guildMember.notifications.length;
await guildMember.post(`/groups/${guild._id}/chat/seen`);
let guildThatHasSeenChat = await guildMember.get('/user');
expect(guildThatHasSeenChat.notifications.length).to.equal(initialNotifications - 1);
expect(guildThatHasSeenChat.newMessages).to.be.empty;
});
});
@@ -53,10 +56,13 @@ describe('POST /groups/:id/chat/seen', () => {
});
it('clears new messages for a party', async () => {
await partyMember.sync();
const initialNotifications = partyMember.notifications.length;
await partyMember.post(`/groups/${party._id}/chat/seen`);
let partyMemberThatHasSeenChat = await partyMember.get('/user');
expect(partyMemberThatHasSeenChat.notifications.length).to.equal(initialNotifications - 1);
expect(partyMemberThatHasSeenChat.newMessages).to.be.empty;
});
});
@@ -70,13 +70,21 @@ describe('POST /groups/:groupId/leave', () => {
it('removes new messages for that group from user', async () => {
await member.post(`/groups/${groupToLeave._id}/chat`, { message: 'Some message' });
await sleep(0.5);
await leader.sync();
expect(leader.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupToLeave._id;
})).to.exist;
expect(leader.newMessages[groupToLeave._id]).to.not.be.empty;
await leader.post(`/groups/${groupToLeave._id}/leave`);
await leader.sync();
expect(leader.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupToLeave._id;
})).to.not.exist;
expect(leader.newMessages[groupToLeave._id]).to.be.empty;
});
@@ -2,6 +2,7 @@ import {
generateUser,
createAndPopulateGroup,
translate as t,
sleep,
} from '../../../../helpers/api-v3-integration.helper';
import * as email from '../../../../../website/server/libs/email';
@@ -188,13 +189,20 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => {
it('removes new messages from a member who is removed', async () => {
await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some message' });
await sleep(0.5);
await removedMember.sync();
expect(removedMember.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === party._id;
})).to.exist;
expect(removedMember.newMessages[party._id]).to.not.be.empty;
await partyLeader.post(`/groups/${party._id}/removeMember/${removedMember._id}`);
await removedMember.sync();
expect(removedMember.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === party._id;
})).to.not.exist;
expect(removedMember.newMessages[party._id]).to.be.empty;
});
@@ -110,6 +110,7 @@ describe('Post /groups/:groupId/invite', () => {
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
}]);
await expect(userToInvite.get('/user'))
@@ -127,11 +128,13 @@ describe('Post /groups/:groupId/invite', () => {
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
},
{
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
},
]);
@@ -98,6 +98,7 @@ describe('POST /members/send-private-message', () => {
it('sends a private message to a user', async () => {
let receiver = await generateUser();
// const initialNotifications = receiver.notifications.length;
await userToSendMessage.post('/members/send-private-message', {
message: messageToSend,
@@ -115,10 +116,44 @@ describe('POST /members/send-private-message', () => {
return message.uuid === receiver._id && message.text === messageToSend;
});
// @TODO waiting for mobile support
// expect(updatedReceiver.notifications.length).to.equal(initialNotifications + 1);
// const notification = updatedReceiver.notifications[updatedReceiver.notifications.length - 1];
// expect(notification.type).to.equal('NEW_INBOX_MESSAGE');
// expect(notification.data.messageId).to.equal(sendersMessageInReceiversInbox.id);
// expect(notification.data.excerpt).to.equal(messageToSend);
// expect(notification.data.sender.id).to.equal(updatedSender._id);
// expect(notification.data.sender.name).to.equal(updatedSender.profile.name);
expect(sendersMessageInReceiversInbox).to.exist;
expect(sendersMessageInSendersInbox).to.exist;
});
// @TODO waiting for mobile support
xit('creates a notification with an excerpt if the message is too long', async () => {
let receiver = await generateUser();
let longerMessageToSend = 'A very long message, that for sure exceeds the limit of 100 chars for the excerpt that we set to 100 chars';
let messageExcerpt = `${longerMessageToSend.substring(0, 100)}...`;
await userToSendMessage.post('/members/send-private-message', {
message: longerMessageToSend,
toUserId: receiver._id,
});
let updatedReceiver = await receiver.get('/user');
let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => {
return message.uuid === userToSendMessage._id && message.text === longerMessageToSend;
});
const notification = updatedReceiver.notifications[updatedReceiver.notifications.length - 1];
expect(notification.type).to.equal('NEW_INBOX_MESSAGE');
expect(notification.data.messageId).to.equal(sendersMessageInReceiversInbox.id);
expect(notification.data.excerpt).to.equal(messageExcerpt);
});
it('allows admin to send when sender has blocked the admin', async () => {
userToSendMessage = await generateUser({
'contributor.admin': 1,
@@ -0,0 +1,16 @@
import {
requester,
} from '../../../../helpers/api-v3-integration.helper';
describe('GET /news', () => {
let api;
beforeEach(async () => {
api = requester();
});
it('returns the latest news in html format, does not require authentication', async () => {
const res = await api.get('/news');
expect(res).to.be.a.string;
});
});
@@ -0,0 +1,42 @@
import {
generateUser,
} from '../../../../helpers/api-v3-integration.helper';
describe('POST /news/tell-me-later', () => {
let user;
beforeEach(async () => {
user = await generateUser({
'flags.newStuff': true,
});
});
it('marks new stuff as read and adds notification', async () => {
expect(user.flags.newStuff).to.equal(true);
const initialNotifications = user.notifications.length;
await user.post('/news/tell-me-later');
await user.sync();
expect(user.flags.newStuff).to.equal(false);
expect(user.notifications.length).to.equal(initialNotifications + 1);
const notification = user.notifications[user.notifications.length - 1];
expect(notification.type).to.equal('NEW_STUFF');
// should be marked as seen by default so it's not counted in the number of notifications
expect(notification.seen).to.equal(true);
expect(notification.data.title).to.be.a.string;
});
it('never adds two notifications', async () => {
const initialNotifications = user.notifications.length;
await user.post('/news/tell-me-later');
await user.post('/news/tell-me-later');
await user.sync();
expect(user.notifications.length).to.equal(initialNotifications + 1);
});
});
@@ -47,6 +47,7 @@ describe('POST /notifications/:notificationId/read', () => {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}]);
await user.sync();
@@ -0,0 +1,59 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/:notificationId/see', () => {
let user;
before(async () => {
user = await generateUser();
});
it('errors when notification is not found', async () => {
let dummyId = generateUUID();
await expect(user.post(`/notifications/${dummyId}/see`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageNotificationNotFound'),
});
});
it('mark a notification as seen', async () => {
expect(user.notifications.length).to.equal(0);
const id = generateUUID();
const id2 = generateUUID();
await user.update({
notifications: [{
id,
type: 'DROPS_ENABLED',
data: {},
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
}],
});
const userObj = await user.get('/user'); // so we can check that defaults have been applied
expect(userObj.notifications.length).to.equal(2);
expect(userObj.notifications[0].seen).to.equal(false);
const res = await user.post(`/notifications/${id}/see`);
expect(res).to.deep.equal({
id,
type: 'DROPS_ENABLED',
data: {},
seen: true,
});
await user.sync();
expect(user.notifications.length).to.equal(2);
expect(user.notifications[0].id).to.equal(id);
expect(user.notifications[0].seen).to.equal(true);
});
});
@@ -4,7 +4,7 @@ import {
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/:notificationId/read', () => {
describe('POST /notifications/read', () => {
let user;
before(async () => {
@@ -57,6 +57,7 @@ describe('POST /notifications/:notificationId/read', () => {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}]);
await user.sync();
@@ -0,0 +1,88 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/see', () => {
let user;
before(async () => {
user = await generateUser();
});
it('errors when notification is not found', async () => {
let dummyId = generateUUID();
await expect(user.post('/notifications/see', {
notificationIds: [dummyId],
})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageNotificationNotFound'),
});
});
it('mark multiple notifications as seen', async () => {
expect(user.notifications.length).to.equal(0);
const id = generateUUID();
const id2 = generateUUID();
const id3 = generateUUID();
await user.update({
notifications: [{
id,
type: 'DROPS_ENABLED',
data: {},
seen: false,
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}, {
id: id3,
type: 'CRON',
data: {},
seen: false,
}],
});
await user.sync();
expect(user.notifications.length).to.equal(3);
const res = await user.post('/notifications/see', {
notificationIds: [id, id3],
});
expect(res).to.deep.equal([
{
id,
type: 'DROPS_ENABLED',
data: {},
seen: true,
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}, {
id: id3,
type: 'CRON',
data: {},
seen: true,
}]);
await user.sync();
expect(user.notifications.length).to.equal(3);
expect(user.notifications[0].id).to.equal(id);
expect(user.notifications[0].seen).to.equal(true);
expect(user.notifications[1].id).to.equal(id2);
expect(user.notifications[1].seen).to.equal(false);
expect(user.notifications[2].id).to.equal(id3);
expect(user.notifications[2].seen).to.equal(true);
});
});
@@ -0,0 +1,189 @@
import {
createAndPopulateGroup,
translate as t,
} from '../../../../../helpers/api-integration/v3';
import { find } from 'lodash';
describe('POST /tasks/:id/needs-work/:userId', () => {
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
}
beforeEach(async () => {
let {group, members, groupLeader} = await createAndPopulateGroup({
groupDetails: {
name: 'Test Guild',
type: 'guild',
},
members: 2,
});
guild = group;
user = groupLeader;
member = members[0];
member2 = members[1];
task = await user.post(`/tasks/group/${guild._id}`, {
text: 'test todo',
type: 'todo',
requiresApproval: true,
});
});
it('errors when user is not assigned', async () => {
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
it('errors when user is not the group leader', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);
await expect(member.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('onlyGroupLeaderCanEditTasks'),
});
});
it('marks as task as needing more work', async () => {
const initialNotifications = member.notifications.length;
await user.post(`/tasks/${task._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
// score task to require approval
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
await user.post(`/tasks/${task._id}/needs-work/${member._id}`);
[memberTasks] = await Promise.all([member.get('/tasks/user'), member.sync()]);
syncedTask = find(memberTasks, findAssignedTask);
// Check that the notification approval request has been removed
expect(syncedTask.group.approval.requested).to.equal(false);
expect(syncedTask.group.approval.requestedDate).to.equal(undefined);
// Check that the notification is correct
expect(member.notifications.length).to.equal(initialNotifications + 1);
const notification = member.notifications[member.notifications.length - 1];
expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK');
const taskText = syncedTask.text;
const managerName = user.profile.name;
expect(notification.data.message).to.equal(t('taskNeedsWork', {taskText, managerName}));
expect(notification.data.task.id).to.equal(syncedTask._id);
expect(notification.data.task.text).to.equal(taskText);
expect(notification.data.group.id).to.equal(syncedTask.group.id);
expect(notification.data.group.name).to.equal(guild.name);
expect(notification.data.manager.id).to.equal(user._id);
expect(notification.data.manager.name).to.equal(managerName);
// Check that the managers' GROUP_TASK_APPROVAL notifications have been removed
await user.sync();
expect(user.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
});
it('allows a manager to mark a task as needing work', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
// score task to require approval
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
const initialNotifications = member.notifications.length;
await member2.post(`/tasks/${task._id}/needs-work/${member._id}`);
[memberTasks] = await Promise.all([member.get('/tasks/user'), member.sync()]);
syncedTask = find(memberTasks, findAssignedTask);
// Check that the notification approval request has been removed
expect(syncedTask.group.approval.requested).to.equal(false);
expect(syncedTask.group.approval.requestedDate).to.equal(undefined);
expect(member.notifications.length).to.equal(initialNotifications + 1);
const notification = member.notifications[member.notifications.length - 1];
expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK');
const taskText = syncedTask.text;
const managerName = member2.profile.name;
expect(notification.data.message).to.equal(t('taskNeedsWork', {taskText, managerName}));
expect(notification.data.task.id).to.equal(syncedTask._id);
expect(notification.data.task.text).to.equal(taskText);
expect(notification.data.group.id).to.equal(syncedTask.group.id);
expect(notification.data.group.name).to.equal(guild.name);
expect(notification.data.manager.id).to.equal(member2._id);
expect(notification.data.manager.name).to.equal(managerName);
// Check that the managers' GROUP_TASK_APPROVAL notifications have been removed
await Promise.all([user.sync(), member2.sync()]);
expect(user.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
expect(member2.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
});
it('prevents marking a task as needing work if it was already approved', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
await member2.post(`/tasks/${task._id}/approve/${member._id}`);
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('canOnlyApproveTaskOnce'),
});
});
it('prevents marking a task as needing work if it is not waiting for approval', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalWasNotRequested'),
});
});
});
@@ -25,6 +25,7 @@ describe('GET /user/anonymized', () => {
'achievements.challenges': 'some',
'inbox.messages': [{ text: 'some text' }],
tags: [{ name: 'some name', challenge: 'some challenge' }],
notifications: [],
});
await generateHabit({ userId: user._id });
@@ -65,6 +66,7 @@ describe('GET /user/anonymized', () => {
expect(returnedUser.stats.toNextLevel).to.eql(common.tnl(user.stats.lvl));
expect(returnedUser.stats.maxMP).to.eql(30); // TODO why 30?
expect(returnedUser.newMessages).to.not.exist;
expect(returnedUser.notifications).to.not.exist;
expect(returnedUser.profile).to.not.exist;
expect(returnedUser.purchased.plan).to.not.exist;
expect(returnedUser.contributor).to.not.exist;
@@ -13,15 +13,20 @@ describe('POST /user/open-mystery-item', () => {
beforeEach(async () => {
user = await generateUser({
'purchased.plan.mysteryItems': [mysteryItemKey],
notifications: [
{type: 'NEW_MYSTERY_ITEMS', data: { items: [mysteryItemKey] }},
],
});
});
// More tests in common code unit tests
it('opens a mystery item', async () => {
expect(user.notifications.length).to.equal(1);
let response = await user.post('/user/open-mystery-item');
await user.sync();
expect(user.notifications.length).to.equal(0);
expect(user.items.gear.owned[mysteryItemKey]).to.be.true;
expect(response.message).to.equal(t('mysteryItemOpened'));
expect(response.data.key).to.eql(mysteryItemKey);
@@ -26,13 +26,21 @@ describe('POST /user/read-card/:cardType', () => {
await user.update({
'items.special.greetingReceived': [true],
'flags.cardReceived': true,
notifications: [{
type: 'CARD_RECEIVED',
data: {card: cardType},
}],
});
await user.sync();
expect(user.notifications.length).to.equal(1);
let response = await user.post(`/user/read-card/${cardType}`);
await user.sync();
expect(response.message).to.equal(t('readCard', {cardType}));
expect(user.items.special[`${cardType}Received`]).to.be.empty;
expect(user.flags.cardReceived).to.be.false;
expect(user.notifications.length).to.equal(0);
});
});