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

View File

@@ -489,9 +489,31 @@ api.seenChat = {
// let group = await Group.getGroup({user, groupId});
// if (!group) throw new NotFound(res.t('groupNotFound'));
let update = {$unset: {}};
let update = {
$unset: {},
$pull: {},
};
update.$unset[`newMessages.${groupId}`] = true;
update.$pull.notifications = {
type: 'NEW_CHAT_MESSAGE',
'data.group.id': groupId,
};
// Remove from response
user.notifications = user.notifications.filter(n => {
if (n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupId) {
return false;
}
return true;
});
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
await User.update({_id: user._id}, update).exec();
res.respond(200, {});
},

View File

@@ -701,6 +701,14 @@ function _removeMessagesFromMember (member, groupId) {
delete member.newMessages[groupId];
member.markModified('newMessages');
}
member.notifications = member.notifications.filter(n => {
if (n.type === 'NEW_CHAT_MESSAGE' && n.data.group && n.data.group.id === groupId) {
return false;
}
return true;
});
}
/**
@@ -917,6 +925,7 @@ api.removeGroupMember = {
async function _inviteByUUID (uuid, group, inviter, req, res) {
let userToInvite = await User.findById(uuid).exec();
const publicGuild = group.type === 'guild' && group.privacy === 'public';
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
@@ -932,7 +941,12 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
}
let guildInvite = {id: group._id, name: group.name, inviter: inviter._id};
let guildInvite = {
id: group._id,
name: group.name,
inviter: inviter._id,
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite);
} else if (group.type === 'party') {
@@ -985,7 +999,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
title: group.name,
message: res.t(identifier),
identifier,
payload: {groupID: group._id},
payload: {groupID: group._id, publicGuild},
}
);
}
@@ -1022,6 +1036,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
const groupQueryString = JSON.stringify({
id: group._id,
inviter: inviter._id,
publicGuild: group.type === 'guild' && group.privacy === 'public',
sentAt: Date.now(), // so we can let it expire
cancelledPlan,
});
@@ -1262,7 +1277,9 @@ api.removeGroupManager = {
let manager = await User.findById(managerId, 'notifications').exec();
let newNotifications = manager.notifications.filter((notification) => {
return notification.type !== 'GROUP_TASK_APPROVAL';
const isGroupTaskNotification = notification.type.indexOf('GROUP_TASK_') === 0;
return !isGroupTaskNotification;
});
manager.notifications = newNotifications;
manager.markModified('notifications');

View File

@@ -0,0 +1,125 @@
import { authWithHeaders } from '../../middlewares/auth';
let api = {};
// @TODO export this const, cannot export it from here because only routes are exported from controllers
const LAST_ANNOUNCEMENT_TITLE = 'HABITICA BIRTHDAY CELEBRATION, LAST CHANCE FOR WINTER WONDERLAND ITEMS, AND CONTINUED RESOLUTION PLOT-LINE';
const worldDmg = { // @TODO
bailey: false,
};
/**
* @api {get} /api/v3/news Get latest Bailey announcement
* @apiName GetNews
* @apiGroup News
*
*
* @apiSuccess {Object} html Latest Bailey html
*
*/
api.getNews = {
method: 'GET',
url: '/news',
async handler (req, res) {
const baileyClass = worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey';
res.status(200).send({
html: `
<div class="bailey">
<div class="media">
<div class="align-self-center mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center markdown">${res.t('newStuff')}</h1>
</div>
</div>
<h2>1/30/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<hr/>
<div class="promo_habit_birthday_2018 center-block"></div>
<h3>Habitica Birthday Party!</h3>
<p>January 31st is Habitica's Birthday! Thank you so much for being a part of our community - it means a lot.</p>
<p>Now come join us and the NPCs as we celebrate!</p>
<h4>Cake for Everybody!</h4>
<p>In honor of the festivities, everyone has been awarded an assortment of yummy cake to feed to your pets! Plus, for the next two days
<a href="/shops/market" target="_blank" rel="noopener">Alexander the Merchant</a>
is selling cake in his shop, and cake will sometimes drop when you complete your tasks. Cake works just like normal pet food,
but if you want to know what type of pet likes each slice, <a href="http://habitica.wikia.com/wiki/Food" target="_blank" rel="noopener">the wiki has spoilers</a>.
</p>
<h4>Party Robes</h4>
<p>There are Party Robes available for free in the Rewards column! Don them with pride.</p>
<h4>Birthday Bash Achievement</h4>
<p>In honor of Habitica's birthday, everyone has been awarded the Habitica Birthday Bash achievement! This achievement stacks for each Birthday Bash you celebrate with us.</p>
<div class="media">
<div class="media-body">
<h3>Last Chance for Frost Sprite Set</h3>
<p class="markdown">Reminder: this is the final day to <a href="/user/settings/subscription" target="_blank" rel="noopener">subscribe</a>
and receive the Frost Sprite Set! Subscribing also lets you buy gems for gold. The longer your subscription, the more gems you get!</p>
<p>Thanks so much for your support! You help keep Habitica running.</p>
<div class="small mb-3">by Beffymaroo</div>
<h3>Last Chance for Starry Night and Holly Hatching Potions</h3>
<p class="markdown">Reminder: this is the final day to <a href="/shops/market" target="_blank" rel="noopener">buy Starry Night and Holly Hatching Potions!</a> If they come back, it won't be until next year at the earliest, so don't delay!</p>
<div class="small mb-3">by Vampitch, JinjooHat, Lemoness, and SabreCat</div>
<h3>Resolution Plot-Line: Broken Buildings</h3>
<p>Lemoness, SabreCat, and Beffymaroo call an important meeting to address the rumors that are flying about this strange outbreak of Habiticans who are suddenly losing all faith in their ability to complete their New Year's Resolutions.</p>
<p>“Thank you all for coming,” Lemoness says. “I'm afraid that we have some very serious news to share, but we ask that you remain calm.”</p>
</div>
<div class="promo_starry_potions ml-3"></div>
</div>
<p>“While it's natural to feel a little disheartened as the end of January approaches,” Beffymaroo says, “these sudden outbreaks appear to have some strange magical origin.
We're still investigating the exact cause, but we do know that the buildings where the affected Habiticans live often seem to sustain some damage immediately before the attack.”</p>
<p>SabreCat clears his throat. “For this reason, we strongly encourage everyone to stay away from broken-down structures, and if you feel any strange tremors or hear odd sounds,
please report them immediately.”</p>
<p class="markdown">“Stay safe, Habiticans.” Lemoness flashes her best comforting smile.
“And remember that if your New Year's Resolution goals seem daunting, you can always seek support in the
<a href="https://habitica.com/groups/guild/6e6a8bd3-9f5f-4351-9188-9f11fcd80a99" target="_blank" rel="noopener">New Year's Resolution Guild</a>.”</p>
<p>How mysterious! Hopefully they'll get to the bottom of this soon.</p>
<hr>
</div>
`,
});
},
};
/**
* @api {post} /api/v3/news/tell-me-later Get latest Bailey announcement in a second moment
* @apiName TellMeLaterNews
* @apiGroup News
*
*
* @apiSuccess {Object} data An empty Object
*
*/
api.tellMeLaterNews = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/news/tell-me-later',
async handler (req, res) {
const user = res.locals.user;
user.flags.newStuff = false;
const existingNotificationIndex = user.notifications.findIndex(n => {
return n.type === 'NEW_STUFF';
});
if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1);
user.addNotification('NEW_STUFF', { title: LAST_ANNOUNCEMENT_TITLE }, true); // seen by default
await user.save();
res.respond(200, {});
},
};
module.exports = api;

View File

@@ -3,11 +3,13 @@ import _ from 'lodash';
import {
NotFound,
} from '../../libs/errors';
import {
model as User,
} from '../../models/user';
let api = {};
/**
* @apiIgnore Not yet part of the public API
* @api {post} /api/v3/notifications/:notificationId/read Mark one notification as read
* @apiName ReadNotification
* @apiGroup Notification
@@ -38,6 +40,11 @@ api.readNotification = {
user.notifications.splice(index, 1);
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
await user.update({
$pull: { notifications: { id: req.params.notificationId } },
}).exec();
@@ -47,12 +54,10 @@ api.readNotification = {
};
/**
* @apiIgnore Not yet part of the public API
* @api {post} /api/v3/notifications Mark notifications as read
* @api {post} /api/v3/notifications/read Mark multiple notifications as read
* @apiName ReadNotifications
* @apiGroup Notification
*
*
* @apiSuccess {Object} data user.notifications
*/
api.readNotifications = {
@@ -84,6 +89,102 @@ api.readNotifications = {
$pull: { notifications: { id: { $in: notifications } } },
}).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, user.notifications);
},
};
/**
* @api {post} /api/v3/notifications/:notificationId/see Mark one notification as seen
* @apiDescription Mark a notification as seen. Different from marking them as read in that the notification isn't removed but the `seen` field is set to `true`
* @apiName SeeNotification
* @apiGroup Notification
*
* @apiParam (Path) {UUID} notificationId
*
* @apiSuccess {Object} data The modified notification
*/
api.seeNotification = {
method: 'POST',
url: '/notifications/:notificationId/see',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('notificationId', res.t('notificationIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const notificationId = req.params.notificationId;
let notification = _.find(user.notifications, {
id: notificationId,
});
if (!notification) {
throw new NotFound(res.t('messageNotificationNotFound'));
}
notification.seen = true;
await User.update({
_id: user._id,
'notifications.id': notificationId,
}, {
$set: {
'notifications.$.seen': true,
},
}).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, notification);
},
};
/**
* @api {post} /api/v3/notifications/see Mark multiple notifications as seen
* @apiName SeeNotifications
* @apiGroup Notification
*
* @apiSuccess {Object} data user.notifications
*/
api.seeNotifications = {
method: 'POST',
url: '/notifications/see',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkBody('notificationIds', res.t('notificationsRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let notificationsIds = req.body.notificationIds;
for (let notificationId of notificationsIds) {
let notification = _.find(user.notifications, {
id: notificationId,
});
if (!notification) {
throw new NotFound(res.t('messageNotificationNotFound'));
}
notification.seen = true;
}
await user.save();
res.respond(200, user.notifications);
},
};

View File

@@ -225,6 +225,11 @@ api.deleteTag = {
$pull: { tags: { id: tagFound.id } },
}).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
// Remove from all the tasks TODO test
await Tasks.Task.update({
userId: user._id,

View File

@@ -589,7 +589,9 @@ api.scoreTask = {
taskName: task.text,
}, manager.preferences.language),
groupId: group._id,
taskId: task._id,
taskId: task._id, // user task id, used to match the notification when the task is approved
userId: user._id,
groupTaskId: task.group.id, // the original task id
direction,
});
managerPromises.push(manager.save());
@@ -729,7 +731,7 @@ api.moveTask = {
moveTask(order, task._id, to);
// Server updates
// @TODO: maybe bulk op?
// Cannot send $pull and $push on same field in one single op
let pullQuery = { $pull: {} };
pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id;
await user.update(pullQuery).exec();
@@ -745,6 +747,11 @@ api.moveTask = {
};
await user.update(updateQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, order);
},
};
@@ -1305,6 +1312,11 @@ api.deleteTask = {
pullQuery.$pull[`tasksOrder.${task.type}s`] = task._id;
let taskOrderUpdate = (challenge || user).update(pullQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
if (!challenge) user._v++;
await Bluebird.all([taskOrderUpdate, task.remove()]);
} else {
await task.remove();

View File

@@ -317,7 +317,7 @@ api.approveTask = {
// Get task direction
const firstManagerNotifications = managers[0].notifications;
const firstNotificationIndex = findIndex(firstManagerNotifications, (notification) => {
return notification.data.taskId === task._id;
return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
});
let direction = 'up';
if (firstManagerNotifications[firstNotificationIndex]) {
@@ -327,8 +327,8 @@ api.approveTask = {
// Remove old notifications
let managerPromises = [];
managers.forEach((manager) => {
let notificationIndex = findIndex(manager.notifications, function findNotification (notification) {
return notification.data.taskId === task._id;
let notificationIndex = findIndex(manager.notifications, function findNotification (notification) {
return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
});
if (notificationIndex !== -1) {
@@ -357,6 +357,105 @@ api.approveTask = {
},
};
/**
* @api {post} /api/v3/tasks/:taskId/needs-work/:userId Group task needs more work
* @apiDescription Mark an assigned group task as needeing more work before it can be approved
* @apiVersion 3.0.0
* @apiName TaskNeedsWork
* @apiGroup Task
*
* @apiParam (Path) {UUID} taskId The id of the task that is the original group task
* @apiParam (Path) {UUID} userId The id of the assigned user
*
* @apiSuccess task The task that needs more work
*/
api.taskNeedsWork = {
method: 'POST',
url: '/tasks/:taskId/needs-work/:userId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let user = res.locals.user;
let assignedUserId = req.params.userId;
let taskId = req.params.taskId;
const [assignedUser, task] = await Promise.all([
User.findById(assignedUserId).exec(),
await Tasks.Task.findOne({
'group.taskId': taskId,
userId: assignedUserId,
}).exec(),
]);
if (!task) {
throw new NotFound(res.t('taskNotFound'));
}
let fields = requiredGroupFields.concat(' managers');
let group = await Group.getGroup({user, groupId: task.group.id, fields});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce'));
if (!task.group.approval.requested) {
throw new NotAuthorized(res.t('taskApprovalWasNotRequested'));
}
// Get Managers
const managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
const managers = await User.find({_id: managerIds}, 'notifications').exec(); // Use this method so we can get access to notifications
const promises = [];
// Remove old notifications
managers.forEach((manager) => {
let notificationIndex = findIndex(manager.notifications, function findNotification (notification) {
return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
});
if (notificationIndex !== -1) {
manager.notifications.splice(notificationIndex, 1);
promises.push(manager.save());
}
});
task.group.approval.requested = false;
task.group.approval.requestedDate = undefined;
const taskText = task.text;
const managerName = user.profile.name;
const message = res.t('taskNeedsWork', {taskText, managerName}, assignedUser.preferences.language);
assignedUser.addNotification('GROUP_TASK_NEEDS_WORK', {
message,
task: {
id: task._id,
text: taskText,
},
group: {
id: group._id,
name: group.name,
},
manager: {
id: user._id,
name: managerName,
},
});
await Promise.all([...promises, assignedUser.save(), task.save()]);
res.respond(200, task);
},
};
/**
* @api {get} /api/v3/approvals/group/:groupId Get a group's approvals
* @apiVersion 3.0.0

View File

@@ -457,6 +457,7 @@ function _cleanChecklist (task) {
* Contributor information
* Special items
* Webhooks
* Notifications
*
* @apiSuccess {Object} data.user
* @apiSuccess {Object} data.tasks
@@ -486,6 +487,7 @@ api.getUserAnonymized = {
delete user.items.special.valentineReceived;
delete user.webhooks;
delete user.achievements.challenges;
delete user.notifications;
_.forEach(user.inbox.messages, (msg) => {
msg.text = 'inbox message text';
@@ -653,6 +655,7 @@ api.castSpell = {
})
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing
// and we need target.notifications to add the notification for the received card
.exec();
partyMembers.unshift(user);